diff --git a/app/shared/src/commonMain/kotlin/iris/protocol/Protocol.kt b/app/shared/src/commonMain/kotlin/iris/protocol/Protocol.kt index 96b4864..f0c0669 100644 --- a/app/shared/src/commonMain/kotlin/iris/protocol/Protocol.kt +++ b/app/shared/src/commonMain/kotlin/iris/protocol/Protocol.kt @@ -71,6 +71,7 @@ const val TYPE_CHANNEL_RENAME = "channel.rename" const val TYPE_CHANNEL_SET_DEFAULT = "channel.set_default" const val TYPE_CHANNEL_FAVORITE = "channel.favorite" const val TYPE_CHANNEL_ICON = "channel.icon" +const val TYPE_CHANNEL_SET_AUTOMATION = "channel.set_automation" const val TYPE_CHANNEL_DELETE = "channel.delete" const val TYPE_CHANNEL_CREATED = "channel.created" const val TYPE_CHANNEL_RENAMED = "channel.renamed" @@ -168,6 +169,10 @@ data class ChannelInfo( /** Cosmetic avatar color override ("#RRGGBB"), or null for the * auto-generated name-hash color. */ val color: String? = null, + /** Automation channel: read-only for the user. It only receives + * gateway-originated output (cron jobs, webhooks); the app hides the + * composer and the gateway rejects direct sends into it. */ + val automation: Boolean = false, ) @Serializable @@ -339,6 +344,9 @@ data class ChannelRenamePayload(val name: String) @Serializable data class ChannelFavoritePayload(val on: Boolean) +@Serializable +data class ChannelSetAutomationPayload(val on: Boolean) + @Serializable data class ChannelIconPayload( /** base64-encoded image, or null to clear the icon. */ @@ -540,6 +548,17 @@ fun channelFavoriteFrame(id: Int, chatId: String, on: Boolean): Frame = ), ) +fun channelSetAutomationFrame(id: Int, chatId: String, on: Boolean): Frame = + Frame( + id = id, + type = TYPE_CHANNEL_SET_AUTOMATION, + chatId = chatId, + payload = IrisJson.instance.encodeToJsonElement( + ChannelSetAutomationPayload.serializer(), + ChannelSetAutomationPayload(on = on), + ), + ) + fun channelIconFrame(id: Int, chatId: String, icon: String?, color: String?): Frame = Frame( id = id, diff --git a/app/shared/src/commonMain/kotlin/iris/state/IrisController.kt b/app/shared/src/commonMain/kotlin/iris/state/IrisController.kt index 18e2175..8a42947 100644 --- a/app/shared/src/commonMain/kotlin/iris/state/IrisController.kt +++ b/app/shared/src/commonMain/kotlin/iris/state/IrisController.kt @@ -59,6 +59,7 @@ import iris.protocol.channelFavoriteFrame import iris.protocol.channelIconFrame import iris.protocol.channelListFrame import iris.protocol.channelRenameFrame +import iris.protocol.channelSetAutomationFrame import iris.protocol.channelSetDefaultFrame import iris.protocol.historyFrame import iris.protocol.messageDeleteFrame @@ -553,6 +554,12 @@ class IrisController( client.sendFrame(channelFavoriteFrame(0, chatId, on)) } + /** Mark the channel as automation (read-only; cron/webhook output only). + * The default channel cannot be marked (the server enforces this too). */ + fun setChannelAutomation(chatId: String, on: Boolean) { + client.sendFrame(channelSetAutomationFrame(0, chatId, on)) + } + /** Set the channel's cosmetic icon (base64 image) and/or avatar color. * Pass null to clear a field. */ fun setChannelIcon(chatId: String, icon: String?, color: String?) { @@ -618,16 +625,21 @@ class IrisController( ) } chat.addPending(trimmed, lane, media) - client.sendMessage(chatId, trimmed, threadId, refs, autoThread = wantsAutoThread(trimmed, threadId)) + client.sendMessage(chatId, trimmed, threadId, refs, autoThread = wantsAutoThread(trimmed, threadId, chatId)) _attachments.value = emptyList() } - /** Auto-threading (Settings → "Threads", docs/06 §6.3): a message in a - * channel's flat lane gets its own fresh thread, AI-named by the gateway - * (Telegram topic-mode workflow). Slash commands and media-only sends - * stay in the flat lane. */ - private fun wantsAutoThread(text: String, threadId: String?): Boolean = - _threadsEnabled.value && threadId == null && text.isNotEmpty() && !text.startsWith("/") + /** Auto-threading (Settings → "Threads", docs/06 §6.3): a message in the + * default channel's flat lane gets its own fresh thread, AI-named by the + * gateway (Telegram topic-mode workflow). Threading is only active on + * the default channel; other channels stay flat. Slash commands and + * media-only sends stay in the flat lane. */ + private fun wantsAutoThread(text: String, threadId: String?, chatId: String): Boolean = + _threadsEnabled.value && + threadId == null && + text.isNotEmpty() && + !text.startsWith("/") && + channels.byId(chatId)?.isDefault == true /** M7: resend a failed user message (tap on the failed bubble). */ fun retrySend(messageId: String) { @@ -641,7 +653,7 @@ class IrisController( item.text, threadId, item.media.map { it.mediaId }, - autoThread = wantsAutoThread(item.text, threadId), + autoThread = wantsAutoThread(item.text, threadId, chatId), ) } diff --git a/app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt b/app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt index 35a78a5..b674ca5 100644 --- a/app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt +++ b/app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt @@ -44,6 +44,7 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Star import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button @@ -264,7 +265,11 @@ fun ChatScreen(controller: IrisController) { showSearch || showPicker || showPalette || showNewChannel || showNewThread || showSettings when { ctrlOrMeta && e.key == Key.N -> { showNewChannel = true; true } - ctrlOrMeta && e.key == Key.T -> { showNewThread = true; true } + // Threads are only active on the default channel. + ctrlOrMeta && e.key == Key.T -> { + if (currentChannel?.isDefault == true) showNewThread = true + true + } ctrlOrMeta && e.key == Key.F -> { showSearch = true; true } ctrlOrMeta && e.key == Key.K -> { showPalette = true; true } e.key == Key.Escape && overlayOpen -> { @@ -304,6 +309,11 @@ fun ChatScreen(controller: IrisController) { selectedIds = setOf(id) } + // Automation channels are read-only for the user (cron/webhook output + // only): the composer is replaced by a notice, and the gateway rejects + // direct sends into them anyway. + val isAutomation = currentChannel?.automation == true + fun toggleSelect(id: String) { selectedIds = if (id in selectedIds) selectedIds - id else selectedIds + id if (selectedIds.isEmpty()) selectionMode = false @@ -335,7 +345,11 @@ fun ChatScreen(controller: IrisController) { Text("☰", fontSize = 18.sp) } } - TitlePill(currentChannel, Modifier.weight(1f)) + val headerClipboard = LocalClipboardManager.current + TitlePill(currentChannel, Modifier.weight(1f)) { id -> + headerClipboard.setText(AnnotatedString(id)) + toastMessage = "Copied $id" + } Spacer(modifier = Modifier.width(6.dp)) StatusBubble(state) { toastMessage = statusToastText(state) } Spacer(modifier = Modifier.width(4.dp)) @@ -358,8 +372,9 @@ fun ChatScreen(controller: IrisController) { } } - // Topic switcher (threads on) - if (threadsEnabled) { + // Topic switcher (threads on; the default channel only — other channels + // don't use threads). + if (threadsEnabled && currentChannel?.isDefault == true) { TopicSwitcher( threads = threads, currentThreadId = currentThreadId, @@ -469,8 +484,8 @@ fun ChatScreen(controller: IrisController) { } } - // Pending attachments (M4) - if (attachments.isNotEmpty()) { + // Pending attachments (M4; hidden in automation channels — no composer) + if (attachments.isNotEmpty() && !isAutomation) { Row( modifier = Modifier .fillMaxWidth() @@ -552,7 +567,28 @@ fun ChatScreen(controller: IrisController) { isConnected && (input.isNotBlank() || attachments.any { it.mediaRef != null && it.error == null }) val layoutDensity = LocalDensity.current.density var textHeightPx by remember { mutableFloatStateOf(0f) } - if (!selectionMode) Row( + if (isAutomation) { + // Read-only notice in place of the composer. + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp), + contentAlignment = Alignment.Center, + ) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(16.dp)) + .background(IrisColors.panel) + .padding(horizontal = 14.dp, vertical = 8.dp), + ) { + Text( + "⚙ Automation channel — read-only. Hermes posts cron/webhook output here; direct messages are not allowed.", + fontSize = 12.sp, + color = IrisColors.textTertiary, + ) + } + } + } else if (!selectionMode) Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 12.dp, vertical = 8.dp) @@ -687,6 +723,7 @@ fun ChatScreen(controller: IrisController) { onRename = { ch -> channelToRename = ch }, onToggleFavorite = { ch -> controller.setChannelFavorite(ch.chatId, !ch.favorite) }, onEditIcon = { ch -> channelToEditIcon = ch }, + onToggleAutomation = { ch -> controller.setChannelAutomation(ch.chatId, !ch.automation) }, onDelete = { ch -> channelToDelete = ch }, onOpenSettings = { showSettings = true }, ) @@ -734,6 +771,10 @@ fun ChatScreen(controller: IrisController) { channelToEditIcon = ch drawerScope.launch { drawerState.close() } }, + onToggleAutomation = { ch -> + controller.setChannelAutomation(ch.chatId, !ch.automation) + drawerScope.launch { drawerState.close() } + }, onDelete = { ch -> channelToDelete = ch drawerScope.launch { drawerState.close() } @@ -973,14 +1014,30 @@ private fun SystemMessage(msg: MessageItem) { } } -/** M7: header title pill — channel avatar + channel name + "Bot" subtitle. */ +/** M7: header title pill — channel avatar + channel name. Automation + * channels show their chat_id as a subtitle (tap the pill to copy it) so + * the user can target them for cron delivery (`deliver="android:"`) + * without asking the agent which channel is in use. */ @Composable -private fun TitlePill(channel: ChannelInfo?, modifier: Modifier = Modifier) { +private fun TitlePill( + channel: ChannelInfo?, + modifier: Modifier = Modifier, + onCopyChatId: (String) -> Unit = {}, +) { val channelName = channel?.name ?: "Iris" + val chatId = channel?.chatId + val isAutomation = channel?.automation == true Box( modifier = modifier .clip(RoundedCornerShape(16.dp)) .background(IrisColors.surface) + .then( + if (isAutomation) { + Modifier.clickable { chatId?.let(onCopyChatId) } + } else { + Modifier + } + ) .padding(start = 8.dp, end = 12.dp, top = 4.dp, bottom = 4.dp), ) { Row(verticalAlignment = Alignment.CenterVertically) { @@ -995,11 +1052,15 @@ private fun TitlePill(channel: ChannelInfo?, modifier: Modifier = Modifier) { maxLines = 1, overflow = TextOverflow.Ellipsis, ) - Text( - "Bot", - style = MaterialTheme.typography.bodySmall, - color = IrisColors.textDim, - ) + if (isAutomation && chatId != null) { + Text( + chatId, + style = MaterialTheme.typography.bodySmall, + color = IrisColors.textDim, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } } } } @@ -1087,7 +1148,8 @@ private fun IconImage(base64: String, size: Dp) { /** * Trailing badges for a channel row: a star marks the default (home) channel, - * a heart marks a favorited channel. A channel that is both shows both. + * a heart marks a favorited channel, a gear marks an automation channel + * (read-only; cron/webhook output only). */ @Composable private fun ChannelBadges(ch: ChannelInfo) { @@ -1109,12 +1171,22 @@ private fun ChannelBadges(ch: ChannelInfo) { tint = IrisColors.textMuted, ) } + if (ch.automation) { + Spacer(modifier = Modifier.width(4.dp)) + Icon( + imageVector = Icons.Filled.Settings, + contentDescription = "Automation channel (read-only)", + modifier = Modifier.size(14.dp), + tint = IrisColors.textMuted, + ) + } } /** * Long-press / right-click context menu for a channel row: rename, favorite - * (state-aware), icon (state-aware), delete. The default channel cannot be - * deleted (the server enforces this too), so its "Delete" item is hidden. + * (state-aware), icon (state-aware), automation (state-aware), delete. The + * default channel cannot be deleted or marked automation (the server enforces + * both), so those items are hidden for it. */ @Composable private fun ChannelContextMenu( @@ -1124,6 +1196,7 @@ private fun ChannelContextMenu( onRename: () -> Unit, onToggleFavorite: () -> Unit, onEditIcon: () -> Unit, + onToggleAutomation: () -> Unit, onDelete: () -> Unit, ) { val hasIcon = ch.icon != null || ch.color != null @@ -1138,6 +1211,10 @@ private fun ChannelContextMenu( onClick = { onDismiss(); onEditIcon() }, ) if (!ch.isDefault) { + DropdownMenuItem( + text = { Text(if (ch.automation) "Unmark automation" else "Mark as automation") }, + onClick = { onDismiss(); onToggleAutomation() }, + ) DropdownMenuItem(text = { Text("Delete") }, onClick = { onDismiss(); onDelete() }) } } @@ -1236,6 +1313,7 @@ private fun ChannelDrawer( onRename: (ChannelInfo) -> Unit, onToggleFavorite: (ChannelInfo) -> Unit, onEditIcon: (ChannelInfo) -> Unit, + onToggleAutomation: (ChannelInfo) -> Unit, onDelete: (ChannelInfo) -> Unit, onOpenSettings: () -> Unit, ) { @@ -1280,6 +1358,7 @@ private fun ChannelDrawer( onRename = { onRename(ch) }, onToggleFavorite = { onToggleFavorite(ch) }, onEditIcon = { onEditIcon(ch) }, + onToggleAutomation = { onToggleAutomation(ch) }, onDelete = { onDelete(ch) }, ) } @@ -1313,6 +1392,7 @@ private fun ChannelRail( onRename: (ChannelInfo) -> Unit, onToggleFavorite: (ChannelInfo) -> Unit, onEditIcon: (ChannelInfo) -> Unit, + onToggleAutomation: (ChannelInfo) -> Unit, onDelete: (ChannelInfo) -> Unit, onOpenSettings: () -> Unit, ) { @@ -1401,6 +1481,7 @@ private fun ChannelRail( onRename = { onRename(ch) }, onToggleFavorite = { onToggleFavorite(ch) }, onEditIcon = { onEditIcon(ch) }, + onToggleAutomation = { onToggleAutomation(ch) }, onDelete = { onDelete(ch) }, ) } @@ -1448,6 +1529,7 @@ private fun InspectorPane( InfoRow("Name", currentChannel?.name ?: "—") InfoRow("ID", currentChannel?.chatId ?: "—") InfoRow("Default", if (currentChannel?.isDefault == true) "yes" else "no") + InfoRow("Automation", if (currentChannel?.automation == true) "yes" else "no") InfoRow("Threads", threads.size.toString()) Spacer(modifier = Modifier.height(8.dp)) Text("Last reply", style = MaterialTheme.typography.titleSmall, modifier = Modifier.padding(bottom = 4.dp)) diff --git a/docs/06-channels-cron-search.md b/docs/06-channels-cron-search.md index 76890e5..284cd48 100644 --- a/docs/06-channels-cron-search.md +++ b/docs/06-channels-cron-search.md @@ -44,6 +44,10 @@ gateway identity concepts**. target a specific thread. - The gateway's `create_handoff_thread` is used where hermes wants to open a named thread (e.g. continuable cron). +- **Threading is only active on the default channel.** The topic switcher, + the "new topic" affordance (Ctrl+T), and auto-threading all apply to the + default channel only; user channels stay flat (the gateway also ignores + `auto_thread` for non-default channels). ### 6.3.1 Auto-threading (Telegram topic-mode workflow) @@ -83,6 +87,15 @@ lane (nothing to title / session-scoped, not conversation starters). - **`channel.rename` / `channel.set_default` / `channel.delete`** manage the directory (rename broadcasts `channel.renamed`; delete is soft — marks archived, keeps history for search). +- **Automation channels:** a channel can be marked *automation* + (`channel.set_automation {on}`, long-press / right-click menu; the default + channel cannot be marked). Automation channels are **read-only for the + user**: they exist only to receive gateway-originated output (cron job + output, webhook output). The app replaces the composer with a read-only + notice, and the gateway rejects `message.send` into them + (`error unsupported`). The flag is server-authoritative (synced to all + devices via the `channel.renamed` full-entry response) and shown as a gear + badge in the channel list. - **App affordance for threads:** long-press (touch) / right-click (desktop) a topic chip → "Rename" (`channel.rename`) or "Delete" (`channel.delete`). Deleting the open thread falls the app back to the channel's flat lane. diff --git a/docs/protocol/frames.schema.json b/docs/protocol/frames.schema.json index 10d4133..12c0124 100644 --- a/docs/protocol/frames.schema.json +++ b/docs/protocol/frames.schema.json @@ -51,7 +51,7 @@ "notification": { "payload": { "kind": { "type": "string", "enum": ["channel_renamed", "channel_created", "channel_deleted", "cron", "approval", "clarify", "generic"] }, "title": { "type": "string" }, "body": { "type": "string" }, "ts": { "type": "integer" } } }, "channel.list": { "description": "Full channel directory (response to a channel.list request).", "payload": { "channels": { "type": "array", "items": { "$ref": "#/definitions/channel" } } } }, "channel.created": { "description": "May carry auto:true for a thread the gateway minted itself for an incoming message (auto-threading); the name is a derived title, upgraded by a follow-up channel.renamed.", "payload": { "$ref": "#/definitions/channel" } }, - "channel.renamed": { "description": "Also the response to channel.set_default / channel.favorite / channel.icon (carries the full entry incl. is_default, favorite, icon, color).", "payload": { "$ref": "#/definitions/channel" } }, + "channel.renamed": { "description": "Also the response to channel.set_default / channel.favorite / channel.icon / channel.set_automation (carries the full entry incl. is_default, favorite, icon, color, automation).", "payload": { "$ref": "#/definitions/channel" } }, "channel.deleted": { "payload": { "chat_id": { "type": "string" } } }, "search.results": { "payload": { "query": { "type": "string" }, "scope": { "type": "string", "enum": ["all", "chat"] }, "hits": { "type": "array", "items": { "type": "object", "properties": { "message_id": {"type":"string"}, "chat_id": {"type":"string"}, "thread_id": {"type":["string","null"]}, "role": {"type":"string"}, "snippet": {"type":"string"}, "ts": {"type":"integer"} } } } } }, "media.offer": { "description": "Agent-sent media available; app pulls bytes.", "payload": { "$ref": "#/definitions/media_ref" } }, @@ -60,13 +60,13 @@ "error": { "payload": { "code": { "type": "string", "enum": ["auth", "not_found", "rate_limited", "media_too_large", "unsupported", "internal"] }, "message": { "type": "string" } } }, "pong": { "payload": { "ts": { "type": "integer" } } }, "sync.done": { "payload": { "cursor": { "type": "integer" } } }, - "history": { "description": "Paged full message history for a chat/thread (response to a history request). Reconstructed from the outbox log; used to populate the view on first open / after a process death, since sync only replays the outbox delta.", "payload": { "messages": { "type": "array", "items": { "type": "object", "properties": { "message_id": {"type":"string"}, "role": {"type":"string","enum":["user","assistant"]}, "text": {"type":"string"}, "reasoning": {"type":"string"}, "model": {"type":"string"}, "tokens": {"type":"integer"}, "ts": {"type":"integer"}, "media": {"type":"array","items":{"$ref":"#/definitions/media_ref"}} } } }, "has_more": { "type": "boolean", "description": "True when older pages exist." }, "oldest_message_id": { "type": "string", "description": "before_message_id for the next (older) page." } } } }, + "history": { "description": "Paged full message history for a chat/thread (response to a history request). Reconstructed from the outbox log; used to populate the view on first open / after a process death, since sync only replays the outbox delta.", "payload": { "messages": { "type": "array", "items": { "type": "object", "properties": { "message_id": {"type":"string"}, "role": {"type":"string","enum":["user","assistant"]}, "text": {"type":"string"}, "reasoning": {"type":"string"}, "model": {"type":"string"}, "tokens": {"type":"integer"}, "ts": {"type":"integer"}, "media": {"type":"array","items":{"$ref":"#/definitions/media_ref"}} } } }, "has_more": { "type": "boolean", "description": "True when older pages exist." }, "oldest_message_id": { "type": "string", "description": "before_message_id for the next (older) page." } } }, "media.pull.end": { "payload": { "ok": { "type": "boolean" } } }, "media.upload.ack": { "description": "Response to media.upload.end; ref is cached and usable in message.send media_refs.", "payload": { "ok": { "type": "boolean" }, "media_ref": { "type": "string" } } } }, "app_to_server": { "hello": { "description": "First frame; auth + caps.", "payload": { "token": { "type": "string" }, "device_id": { "type": "string" }, "device_name": { "type": "string" }, "caps": { "type": "object", "properties": { "min_protocol": {"type":"integer"}, "media": {"type":"boolean"}, "push": {"type":"string"} } }, "fcm_token": { "type": "string" }, "ntfy_topic": { "type": "string" } } }, - "message.send": { "payload": { "text": { "type": "string" }, "reply_to": { "type": "string" }, "media_refs": { "type": "array", "items": { "type": "string" } }, "auto_thread": { "type": "boolean", "description": "Optional, default false. Ask the gateway to mint a fresh thread for this message (auto-threading). Honored only in a channel's flat lane (thread_id null) with non-empty non-slash text; the gateway broadcasts channel.created {auto:true} and the echo/turn carry the new thread_id." } } }, + "message.send": { "payload": { "text": { "type": "string" }, "reply_to": { "type": "string" }, "media_refs": { "type": "array", "items": { "type": "string" } }, "auto_thread": { "type": "boolean", "description": "Optional, default false. Ask the gateway to mint a fresh thread for this message (auto-threading). Honored only in the DEFAULT channel's flat lane (thread_id null) with non-empty non-slash text — threading is not active on other channels; the gateway broadcasts channel.created {auto:true} and the echo/turn carry the new thread_id." } } }, "media.upload.start": { "payload": { "media_ref": { "type": "string" }, "kind": { "$ref": "#/definitions/kind" }, "mime": { "type": "string" }, "size": { "type": "integer" }, "filename": { "type": "string" } } }, "media.upload.end": { "payload": { "media_ref": { "type": "string" }, "sha256": { "type": "string" } } }, "media.pull": { "payload": { "media_id": { "type": "string" } } }, @@ -74,8 +74,9 @@ "channel.rename": { "payload": { "name": { "type": "string" } } }, "channel.set_default": { "payload": {} }, "channel.favorite": { "description": "Toggle the cosmetic favorite flag (sorts the channel to the top of the list). Answered by a channel.renamed carrying the full entry.", "payload": { "on": { "type": "boolean" } } }, - "channel.icon": { "description": "Set the channel's cosmetic icon (base64 image) and/or avatar color. Omit / null a field to clear it. Answered by a channel.renamed carrying the full entry.", "payload": { "icon": { "type": ["string", "null"], "description": "base64-encoded image (PNG/JPEG), max ~512 KiB." }, "color": { "type": ["string", "null"], "description": "#RRGGBB avatar color override." } } }, - "channel.delete": { "payload": {} }, +"channel.icon": { "description": "Set the channel's cosmetic icon (base64 image) and/or avatar color. Omit / null a field to clear it. Answered by a channel.renamed carrying the full entry.", "payload": { "icon": { "type": ["string", "null"], "description": "base64-encoded image (PNG/JPEG), max ~512 KiB." }, "color": { "type": ["string", "null"], "description": "#RRGGBB avatar color override." } } }, + "channel.set_automation": { "description": "Mark a channel as automation (read-only for the user; it only receives gateway-originated output such as cron jobs and webhooks). The app hides the composer and the gateway rejects message.send into it. The default channel cannot be marked (error not_found). Answered by a channel.renamed carrying the full entry.", "payload": { "on": { "type": "boolean" } } }, + "channel.delete": { "payload": {} }, "channel.list": { "description": "Request the full channel directory; answered by the server_to_app channel.list frame.", "payload": {} }, "search": { "payload": { "query": { "type": "string" }, "scope": { "type": "string", "enum": ["all", "chat"] }, "chat_id": { "type": "string" }, "thread_id": { "type": "string" }, "limit": { "type": "integer", "description": "Optional; server default 20." } } }, "sync": { "description": "Reconnect catch-up; replays undelivered outbox frames only (not full history).", "payload": { "cursor": { "type": "integer" } } }, @@ -87,7 +88,7 @@ }, "definitions": { "kind": { "type": "string", "enum": ["image", "audio", "video", "document", "voice"] }, - "channel": { "type": "object", "properties": { "chat_id": {"type":"string"}, "name": {"type":"string"}, "kind": {"type":"string","enum":["default","channel","thread"]}, "parent_chat_id": {"type":["string","null"]}, "is_default": {"type":"boolean"}, "archived": {"type":"boolean"}, "auto": {"type":"boolean","description":"Optional; true on channel.created for a gateway-minted auto-thread."}, "favorite": {"type":"boolean","description":"Optional; cosmetic favorite flag (sorts to the top of the list)."}, "icon": {"type":["string","null"],"description":"Optional; cosmetic icon, a base64-encoded image (PNG/JPEG). Absent/null = auto-generated letter avatar."}, "color": {"type":["string","null"],"description":"Optional; cosmetic avatar color override (#RRGGBB). Absent/null = auto-generated name-hash color."} } }, + "channel": { "type": "object", "properties": { "chat_id": {"type":"string"}, "name": {"type":"string"}, "kind": {"type":"string","enum":["default","channel","thread"]}, "parent_chat_id": {"type":["string","null"]}, "is_default": {"type":"boolean"}, "archived": {"type":"boolean"}, "auto": {"type":"boolean","description":"Optional; true on channel.created for a gateway-minted auto-thread."}, "favorite": {"type":"boolean","description":"Optional; cosmetic favorite flag (sorts to the top of the list)."}, "icon": {"type":["string","null"],"description":"Optional; cosmetic icon, a base64-encoded image (PNG/JPEG). Absent/null = auto-generated letter avatar."}, "color": {"type":["string","null"],"description":"Optional; cosmetic avatar color override (#RRGGBB). Absent/null = auto-generated name-hash color."}, "automation": {"type":"boolean","description":"Optional; true when the channel is an automation channel (read-only for the user; only receives gateway-originated output such as cron jobs and webhooks). The app hides the composer and the gateway rejects message.send into it. Never set on the default channel."} } }, "media_ref": { "type": "object", "properties": { "media_id": {"type":"string"}, "kind": { "$ref": "#/definitions/kind" }, "mime": {"type":"string"}, "size": {"type":"integer"}, "filename": {"type":"string"}, "message_id": {"type":"string","description":"Optional; set on media.offer to associate the offer with the assistant message it belongs to."} } } }, "x-planned-frames": [ diff --git a/gateway-plugin/adapter.py b/gateway-plugin/adapter.py index 22b259e..23a219b 100644 --- a/gateway-plugin/adapter.py +++ b/gateway-plugin/adapter.py @@ -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(): diff --git a/gateway-plugin/channels.py b/gateway-plugin/channels.py index 452a0a1..9a2a776 100644 --- a/gateway-plugin/channels.py +++ b/gateway-plugin/channels.py @@ -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"]), } diff --git a/gateway-plugin/protocol.py b/gateway-plugin/protocol.py index b9507c9..8d94242 100644 --- a/gateway-plugin/protocol.py +++ b/gateway-plugin/protocol.py @@ -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 diff --git a/gateway-plugin/ws_server.py b/gateway-plugin/ws_server.py index 826d90a..f67fbe5 100644 --- a/gateway-plugin/ws_server.py +++ b/gateway-plugin/ws_server.py @@ -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: