Channels: default-only threading + automation (read-only) channels — threading (topic switcher, Ctrl+T, auto-threading) is now only active on the default channel, and the gateway ignores auto_thread for other channels; new channel.set_automation frame marks a channel read-only for cron/webhook output (app hides the composer behind a notice, gateway rejects message.send, default channel cannot be marked, flag syncs to all devices via the full-entry channel.renamed response, gear badge in the list); header pill drops the 'Bot' subtitle and shows the chat_id on automation channels (tap to copy for cron delivery targeting); also fixes a pre-existing stray brace that made frames.schema.json invalid JSON
This commit is contained in:
1 parent
d7cd59b685
commit
10e9565e2e
9 files changed
+256
-37
No files matched your search
@@ -1702,6 +1702,23 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
chat_id = self.home_channel
|
||||
chat_id = chat_id.strip()
|
||||
|
||||
# Automation channels are read-only for the user: they only receive
|
||||
# gateway-originated output (cron jobs, webhooks). Reject direct sends
|
||||
# (the app hides the composer for them, this is the server-side
|
||||
# enforcement).
|
||||
target = self._channels.get(chat_id)
|
||||
if target is not None and target.get("automation"):
|
||||
await self._ws_server.send_to(
|
||||
device_id,
|
||||
protocol.error(
|
||||
protocol.ERR_UNSUPPORTED,
|
||||
f"{target.get('name') or chat_id} is an automation channel "
|
||||
"(read-only: cron/webhook output only)",
|
||||
id=frame.id,
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
thread_id = frame.thread_id or payload.get("thread_id")
|
||||
if not isinstance(thread_id, str) or not thread_id.strip():
|
||||
thread_id = None
|
||||
@@ -1717,14 +1734,18 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
# LLM upgrades the name in the background. The user echo, the agent
|
||||
# turn, and all streaming frames then carry the new thread_id.
|
||||
# Skipped for slash commands (session-scoped, not conversation
|
||||
# starters) and replies (they continue where the user is).
|
||||
# starters) and replies (they continue where the user is). Threading
|
||||
# is only active on the default channel; other channels stay flat.
|
||||
auto_thread = payload.get("auto_thread") is True
|
||||
default_entry = self._channels.default()
|
||||
if (
|
||||
auto_thread
|
||||
and thread_id is None
|
||||
and text.strip()
|
||||
and not text.lstrip().startswith("/")
|
||||
and reply_to is None
|
||||
and default_entry is not None
|
||||
and chat_id == default_entry["chat_id"]
|
||||
):
|
||||
entry = self._channels.create(
|
||||
name=_derive_thread_name(text),
|
||||
@@ -2160,6 +2181,34 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
resp.id = frame.id
|
||||
await self._ws_server.broadcast(resp)
|
||||
|
||||
async def on_channel_set_automation(self, frame: protocol.Frame, device_id: str) -> None:
|
||||
chat_id = frame.chat_id or frame.payload.get("chat_id")
|
||||
if not isinstance(chat_id, str) or not chat_id.strip():
|
||||
await self._ws_server.send_to(
|
||||
device_id,
|
||||
protocol.error(
|
||||
protocol.ERR_NOT_FOUND, "channel.set_automation requires chat_id", id=frame.id
|
||||
),
|
||||
)
|
||||
return
|
||||
on = bool(frame.payload.get("on"))
|
||||
entry = self._channels.set_automation(chat_id, on)
|
||||
if entry is None:
|
||||
await self._ws_server.send_to(
|
||||
device_id,
|
||||
protocol.error(
|
||||
protocol.ERR_NOT_FOUND,
|
||||
f"cannot set automation on {chat_id} (unknown or default)",
|
||||
id=frame.id,
|
||||
),
|
||||
)
|
||||
return
|
||||
# Reuse the renamed event shape: it carries the full entry (incl. the
|
||||
# new automation flag) so every device reconciles the change.
|
||||
resp = protocol.channel_renamed(entry)
|
||||
resp.id = frame.id
|
||||
await self._ws_server.broadcast(resp)
|
||||
|
||||
async def on_channel_delete(self, frame: protocol.Frame, device_id: str) -> None:
|
||||
chat_id = frame.chat_id or frame.payload.get("chat_id")
|
||||
if not isinstance(chat_id, str) or not chat_id.strip():
|
||||
|
||||
@@ -67,7 +67,8 @@ class ChannelDirectory:
|
||||
created REAL NOT NULL DEFAULT 0,
|
||||
favorite INTEGER NOT NULL DEFAULT 0,
|
||||
icon TEXT,
|
||||
color TEXT
|
||||
color TEXT,
|
||||
automation INTEGER NOT NULL DEFAULT 0
|
||||
)
|
||||
"""
|
||||
)
|
||||
@@ -83,6 +84,10 @@ class ChannelDirectory:
|
||||
self._conn.execute("ALTER TABLE channels ADD COLUMN icon TEXT")
|
||||
if "color" not in existing:
|
||||
self._conn.execute("ALTER TABLE channels ADD COLUMN color TEXT")
|
||||
if "automation" not in existing:
|
||||
self._conn.execute(
|
||||
"ALTER TABLE channels ADD COLUMN automation INTEGER NOT NULL DEFAULT 0"
|
||||
)
|
||||
self._conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS counters (
|
||||
@@ -138,9 +143,12 @@ class ChannelDirectory:
|
||||
else:
|
||||
# Refresh the name only if it was never renamed by the user
|
||||
# (heuristic: still equals the previous default name is not
|
||||
# trackable, so leave user-renamed names alone).
|
||||
# trackable, so leave user-renamed names alone). The default
|
||||
# channel is the user's chat surface, so it is never an
|
||||
# automation channel.
|
||||
self._conn.execute(
|
||||
"UPDATE channels SET kind = ?, is_default = 1 WHERE chat_id = ?",
|
||||
"UPDATE channels SET kind = ?, is_default = 1, automation = 0 "
|
||||
"WHERE chat_id = ?",
|
||||
(KIND_DEFAULT, chat_id),
|
||||
)
|
||||
# Exactly one default: clear any other default flag.
|
||||
@@ -206,7 +214,11 @@ class ChannelDirectory:
|
||||
return self.get(chat_id)
|
||||
|
||||
def set_default(self, chat_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Mark *chat_id* as the default channel (clears the previous one)."""
|
||||
"""Mark *chat_id* as the default channel (clears the previous one).
|
||||
|
||||
The default channel is the user's chat surface, so the automation
|
||||
flag is cleared on it (a read-only home channel would be unusable).
|
||||
"""
|
||||
with self._lock:
|
||||
row = self._conn.execute(
|
||||
"SELECT 1 FROM channels WHERE chat_id = ? AND archived = 0",
|
||||
@@ -216,7 +228,9 @@ class ChannelDirectory:
|
||||
return None
|
||||
self._conn.execute("UPDATE channels SET is_default = 0")
|
||||
self._conn.execute(
|
||||
"UPDATE channels SET is_default = 1 WHERE chat_id = ?", (chat_id,)
|
||||
"UPDATE channels SET is_default = 1, automation = 0 "
|
||||
"WHERE chat_id = ?",
|
||||
(chat_id,),
|
||||
)
|
||||
self._conn.commit()
|
||||
return self.get(chat_id)
|
||||
@@ -250,6 +264,29 @@ class ChannelDirectory:
|
||||
return None
|
||||
return self.get(chat_id)
|
||||
|
||||
def set_automation(self, chat_id: str, on: bool) -> Optional[Dict[str, Any]]:
|
||||
"""Mark *chat_id* as an automation channel (or clear the flag).
|
||||
|
||||
Automation channels are read-only for the user: they only receive
|
||||
gateway-originated output (cron jobs, webhooks). The app hides the
|
||||
composer and the gateway rejects ``message.send`` into them. The
|
||||
default channel cannot be marked automation (it is the user's chat
|
||||
surface), so this returns ``None`` for it, like ``delete``.
|
||||
"""
|
||||
with self._lock:
|
||||
row = self._conn.execute(
|
||||
"SELECT is_default FROM channels WHERE chat_id = ? AND archived = 0",
|
||||
(chat_id,),
|
||||
).fetchone()
|
||||
if row is None or row["is_default"]:
|
||||
return None
|
||||
self._conn.execute(
|
||||
"UPDATE channels SET automation = ? WHERE chat_id = ?",
|
||||
(1 if on else 0, chat_id),
|
||||
)
|
||||
self._conn.commit()
|
||||
return self.get(chat_id)
|
||||
|
||||
def delete(self, chat_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Soft-delete (archive) a channel. History stays for search.
|
||||
|
||||
@@ -365,6 +402,7 @@ def _row_to_entry(row: sqlite3.Row) -> Dict[str, Any]:
|
||||
"favorite": bool(row["favorite"]),
|
||||
"icon": row["icon"],
|
||||
"color": row["color"],
|
||||
"automation": bool(row["automation"]),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -60,6 +60,7 @@ TYPE_CHANNEL_RENAME = "channel.rename"
|
||||
TYPE_CHANNEL_SET_DEFAULT = "channel.set_default"
|
||||
TYPE_CHANNEL_FAVORITE = "channel.favorite"
|
||||
TYPE_CHANNEL_ICON = "channel.icon"
|
||||
TYPE_CHANNEL_SET_AUTOMATION = "channel.set_automation"
|
||||
TYPE_CHANNEL_DELETE = "channel.delete"
|
||||
TYPE_CHANNEL_CREATED = "channel.created"
|
||||
TYPE_CHANNEL_RENAMED = "channel.renamed"
|
||||
@@ -442,6 +443,8 @@ def _channel_payload(entry: Dict[str, Any]) -> Dict[str, Any]:
|
||||
payload["icon"] = entry["icon"]
|
||||
if entry.get("color"):
|
||||
payload["color"] = entry["color"]
|
||||
if entry.get("automation"):
|
||||
payload["automation"] = True
|
||||
return payload
|
||||
|
||||
|
||||
|
||||
@@ -374,6 +374,8 @@ class WsServer:
|
||||
await self._adapter.on_channel_favorite(frame, device_id)
|
||||
elif frame.type == protocol.TYPE_CHANNEL_ICON:
|
||||
await self._adapter.on_channel_icon(frame, device_id)
|
||||
elif frame.type == protocol.TYPE_CHANNEL_SET_AUTOMATION:
|
||||
await self._adapter.on_channel_set_automation(frame, device_id)
|
||||
elif frame.type == protocol.TYPE_CHANNEL_DELETE:
|
||||
await self._adapter.on_channel_delete(frame, device_id)
|
||||
elif frame.type == protocol.TYPE_CHANNEL_LIST:
|
||||
|
||||
Reference in new issue
Block a user