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
+251 -32

No files matched your search

@@ -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,
@@ -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),
)
}
@@ -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:<chat_id>"`)
* 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,14 +1052,18 @@ private fun TitlePill(channel: ChannelInfo?, modifier: Modifier = Modifier) {
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (isAutomation && chatId != null) {
Text(
"Bot",
chatId,
style = MaterialTheme.typography.bodySmall,
color = IrisColors.textDim,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
}
/** M7: compact 36dp header icon button (smaller than the 48dp default). */
@@ -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))
+13
View File
@@ -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.
+6 -5
View File
@@ -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,7 +74,8 @@
"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.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." } } },
@@ -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": [
+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: