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:
ARIA committed 2026-08-21 09:39:32 +02:00
1 parent d7cd59b685
commit 10e9565e2e
9 files changed
+256 -37

No files matched your search

+50 -1
View File
@@ -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():
+43 -5
View File
@@ -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"]),
}
+3
View File
@@ -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
+2
View File
@@ -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: