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
+250
-31
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_SET_DEFAULT = "channel.set_default"
|
||||||
const val TYPE_CHANNEL_FAVORITE = "channel.favorite"
|
const val TYPE_CHANNEL_FAVORITE = "channel.favorite"
|
||||||
const val TYPE_CHANNEL_ICON = "channel.icon"
|
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_DELETE = "channel.delete"
|
||||||
const val TYPE_CHANNEL_CREATED = "channel.created"
|
const val TYPE_CHANNEL_CREATED = "channel.created"
|
||||||
const val TYPE_CHANNEL_RENAMED = "channel.renamed"
|
const val TYPE_CHANNEL_RENAMED = "channel.renamed"
|
||||||
@@ -168,6 +169,10 @@ data class ChannelInfo(
|
|||||||
/** Cosmetic avatar color override ("#RRGGBB"), or null for the
|
/** Cosmetic avatar color override ("#RRGGBB"), or null for the
|
||||||
* auto-generated name-hash color. */
|
* auto-generated name-hash color. */
|
||||||
val color: String? = null,
|
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
|
@Serializable
|
||||||
@@ -339,6 +344,9 @@ data class ChannelRenamePayload(val name: String)
|
|||||||
@Serializable
|
@Serializable
|
||||||
data class ChannelFavoritePayload(val on: Boolean)
|
data class ChannelFavoritePayload(val on: Boolean)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ChannelSetAutomationPayload(val on: Boolean)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class ChannelIconPayload(
|
data class ChannelIconPayload(
|
||||||
/** base64-encoded image, or null to clear the icon. */
|
/** 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 =
|
fun channelIconFrame(id: Int, chatId: String, icon: String?, color: String?): Frame =
|
||||||
Frame(
|
Frame(
|
||||||
id = id,
|
id = id,
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ import iris.protocol.channelFavoriteFrame
|
|||||||
import iris.protocol.channelIconFrame
|
import iris.protocol.channelIconFrame
|
||||||
import iris.protocol.channelListFrame
|
import iris.protocol.channelListFrame
|
||||||
import iris.protocol.channelRenameFrame
|
import iris.protocol.channelRenameFrame
|
||||||
|
import iris.protocol.channelSetAutomationFrame
|
||||||
import iris.protocol.channelSetDefaultFrame
|
import iris.protocol.channelSetDefaultFrame
|
||||||
import iris.protocol.historyFrame
|
import iris.protocol.historyFrame
|
||||||
import iris.protocol.messageDeleteFrame
|
import iris.protocol.messageDeleteFrame
|
||||||
@@ -553,6 +554,12 @@ class IrisController(
|
|||||||
client.sendFrame(channelFavoriteFrame(0, chatId, on))
|
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.
|
/** Set the channel's cosmetic icon (base64 image) and/or avatar color.
|
||||||
* Pass null to clear a field. */
|
* Pass null to clear a field. */
|
||||||
fun setChannelIcon(chatId: String, icon: String?, color: String?) {
|
fun setChannelIcon(chatId: String, icon: String?, color: String?) {
|
||||||
@@ -618,16 +625,21 @@ class IrisController(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
chat.addPending(trimmed, lane, media)
|
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()
|
_attachments.value = emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Auto-threading (Settings → "Threads", docs/06 §6.3): a message in a
|
/** Auto-threading (Settings → "Threads", docs/06 §6.3): a message in the
|
||||||
* channel's flat lane gets its own fresh thread, AI-named by the gateway
|
* default channel's flat lane gets its own fresh thread, AI-named by the
|
||||||
* (Telegram topic-mode workflow). Slash commands and media-only sends
|
* gateway (Telegram topic-mode workflow). Threading is only active on
|
||||||
* stay in the flat lane. */
|
* the default channel; other channels stay flat. Slash commands and
|
||||||
private fun wantsAutoThread(text: String, threadId: String?): Boolean =
|
* media-only sends stay in the flat lane. */
|
||||||
_threadsEnabled.value && threadId == null && text.isNotEmpty() && !text.startsWith("/")
|
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). */
|
/** M7: resend a failed user message (tap on the failed bubble). */
|
||||||
fun retrySend(messageId: String) {
|
fun retrySend(messageId: String) {
|
||||||
@@ -641,7 +653,7 @@ class IrisController(
|
|||||||
item.text,
|
item.text,
|
||||||
threadId,
|
threadId,
|
||||||
item.media.map { it.mediaId },
|
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.Icons
|
||||||
import androidx.compose.material.icons.filled.Favorite
|
import androidx.compose.material.icons.filled.Favorite
|
||||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||||
|
import androidx.compose.material.icons.filled.Settings
|
||||||
import androidx.compose.material.icons.filled.Star
|
import androidx.compose.material.icons.filled.Star
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
@@ -264,7 +265,11 @@ fun ChatScreen(controller: IrisController) {
|
|||||||
showSearch || showPicker || showPalette || showNewChannel || showNewThread || showSettings
|
showSearch || showPicker || showPalette || showNewChannel || showNewThread || showSettings
|
||||||
when {
|
when {
|
||||||
ctrlOrMeta && e.key == Key.N -> { showNewChannel = true; true }
|
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.F -> { showSearch = true; true }
|
||||||
ctrlOrMeta && e.key == Key.K -> { showPalette = true; true }
|
ctrlOrMeta && e.key == Key.K -> { showPalette = true; true }
|
||||||
e.key == Key.Escape && overlayOpen -> {
|
e.key == Key.Escape && overlayOpen -> {
|
||||||
@@ -304,6 +309,11 @@ fun ChatScreen(controller: IrisController) {
|
|||||||
selectedIds = setOf(id)
|
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) {
|
fun toggleSelect(id: String) {
|
||||||
selectedIds = if (id in selectedIds) selectedIds - id else selectedIds + id
|
selectedIds = if (id in selectedIds) selectedIds - id else selectedIds + id
|
||||||
if (selectedIds.isEmpty()) selectionMode = false
|
if (selectedIds.isEmpty()) selectionMode = false
|
||||||
@@ -335,7 +345,11 @@ fun ChatScreen(controller: IrisController) {
|
|||||||
Text("☰", fontSize = 18.sp)
|
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))
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
StatusBubble(state) { toastMessage = statusToastText(state) }
|
StatusBubble(state) { toastMessage = statusToastText(state) }
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
@@ -358,8 +372,9 @@ fun ChatScreen(controller: IrisController) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Topic switcher (threads on)
|
// Topic switcher (threads on; the default channel only — other channels
|
||||||
if (threadsEnabled) {
|
// don't use threads).
|
||||||
|
if (threadsEnabled && currentChannel?.isDefault == true) {
|
||||||
TopicSwitcher(
|
TopicSwitcher(
|
||||||
threads = threads,
|
threads = threads,
|
||||||
currentThreadId = currentThreadId,
|
currentThreadId = currentThreadId,
|
||||||
@@ -469,8 +484,8 @@ fun ChatScreen(controller: IrisController) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pending attachments (M4)
|
// Pending attachments (M4; hidden in automation channels — no composer)
|
||||||
if (attachments.isNotEmpty()) {
|
if (attachments.isNotEmpty() && !isAutomation) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -552,7 +567,28 @@ fun ChatScreen(controller: IrisController) {
|
|||||||
isConnected && (input.isNotBlank() || attachments.any { it.mediaRef != null && it.error == null })
|
isConnected && (input.isNotBlank() || attachments.any { it.mediaRef != null && it.error == null })
|
||||||
val layoutDensity = LocalDensity.current.density
|
val layoutDensity = LocalDensity.current.density
|
||||||
var textHeightPx by remember { mutableFloatStateOf(0f) }
|
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
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 12.dp, vertical = 8.dp)
|
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||||
@@ -687,6 +723,7 @@ fun ChatScreen(controller: IrisController) {
|
|||||||
onRename = { ch -> channelToRename = ch },
|
onRename = { ch -> channelToRename = ch },
|
||||||
onToggleFavorite = { ch -> controller.setChannelFavorite(ch.chatId, !ch.favorite) },
|
onToggleFavorite = { ch -> controller.setChannelFavorite(ch.chatId, !ch.favorite) },
|
||||||
onEditIcon = { ch -> channelToEditIcon = ch },
|
onEditIcon = { ch -> channelToEditIcon = ch },
|
||||||
|
onToggleAutomation = { ch -> controller.setChannelAutomation(ch.chatId, !ch.automation) },
|
||||||
onDelete = { ch -> channelToDelete = ch },
|
onDelete = { ch -> channelToDelete = ch },
|
||||||
onOpenSettings = { showSettings = true },
|
onOpenSettings = { showSettings = true },
|
||||||
)
|
)
|
||||||
@@ -734,6 +771,10 @@ fun ChatScreen(controller: IrisController) {
|
|||||||
channelToEditIcon = ch
|
channelToEditIcon = ch
|
||||||
drawerScope.launch { drawerState.close() }
|
drawerScope.launch { drawerState.close() }
|
||||||
},
|
},
|
||||||
|
onToggleAutomation = { ch ->
|
||||||
|
controller.setChannelAutomation(ch.chatId, !ch.automation)
|
||||||
|
drawerScope.launch { drawerState.close() }
|
||||||
|
},
|
||||||
onDelete = { ch ->
|
onDelete = { ch ->
|
||||||
channelToDelete = ch
|
channelToDelete = ch
|
||||||
drawerScope.launch { drawerState.close() }
|
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
|
@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 channelName = channel?.name ?: "Iris"
|
||||||
|
val chatId = channel?.chatId
|
||||||
|
val isAutomation = channel?.automation == true
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.clip(RoundedCornerShape(16.dp))
|
.clip(RoundedCornerShape(16.dp))
|
||||||
.background(IrisColors.surface)
|
.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),
|
.padding(start = 8.dp, end = 12.dp, top = 4.dp, bottom = 4.dp),
|
||||||
) {
|
) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
@@ -995,15 +1052,19 @@ private fun TitlePill(channel: ChannelInfo?, modifier: Modifier = Modifier) {
|
|||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
|
if (isAutomation && chatId != null) {
|
||||||
Text(
|
Text(
|
||||||
"Bot",
|
chatId,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = IrisColors.textDim,
|
color = IrisColors.textDim,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** M7: compact 36dp header icon button (smaller than the 48dp default). */
|
/** M7: compact 36dp header icon button (smaller than the 48dp default). */
|
||||||
@Composable
|
@Composable
|
||||||
@@ -1087,7 +1148,8 @@ private fun IconImage(base64: String, size: Dp) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Trailing badges for a channel row: a star marks the default (home) channel,
|
* 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
|
@Composable
|
||||||
private fun ChannelBadges(ch: ChannelInfo) {
|
private fun ChannelBadges(ch: ChannelInfo) {
|
||||||
@@ -1109,12 +1171,22 @@ private fun ChannelBadges(ch: ChannelInfo) {
|
|||||||
tint = IrisColors.textMuted,
|
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
|
* Long-press / right-click context menu for a channel row: rename, favorite
|
||||||
* (state-aware), icon (state-aware), delete. The default channel cannot be
|
* (state-aware), icon (state-aware), automation (state-aware), delete. The
|
||||||
* deleted (the server enforces this too), so its "Delete" item is hidden.
|
* default channel cannot be deleted or marked automation (the server enforces
|
||||||
|
* both), so those items are hidden for it.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun ChannelContextMenu(
|
private fun ChannelContextMenu(
|
||||||
@@ -1124,6 +1196,7 @@ private fun ChannelContextMenu(
|
|||||||
onRename: () -> Unit,
|
onRename: () -> Unit,
|
||||||
onToggleFavorite: () -> Unit,
|
onToggleFavorite: () -> Unit,
|
||||||
onEditIcon: () -> Unit,
|
onEditIcon: () -> Unit,
|
||||||
|
onToggleAutomation: () -> Unit,
|
||||||
onDelete: () -> Unit,
|
onDelete: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val hasIcon = ch.icon != null || ch.color != null
|
val hasIcon = ch.icon != null || ch.color != null
|
||||||
@@ -1138,6 +1211,10 @@ private fun ChannelContextMenu(
|
|||||||
onClick = { onDismiss(); onEditIcon() },
|
onClick = { onDismiss(); onEditIcon() },
|
||||||
)
|
)
|
||||||
if (!ch.isDefault) {
|
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() })
|
DropdownMenuItem(text = { Text("Delete") }, onClick = { onDismiss(); onDelete() })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1236,6 +1313,7 @@ private fun ChannelDrawer(
|
|||||||
onRename: (ChannelInfo) -> Unit,
|
onRename: (ChannelInfo) -> Unit,
|
||||||
onToggleFavorite: (ChannelInfo) -> Unit,
|
onToggleFavorite: (ChannelInfo) -> Unit,
|
||||||
onEditIcon: (ChannelInfo) -> Unit,
|
onEditIcon: (ChannelInfo) -> Unit,
|
||||||
|
onToggleAutomation: (ChannelInfo) -> Unit,
|
||||||
onDelete: (ChannelInfo) -> Unit,
|
onDelete: (ChannelInfo) -> Unit,
|
||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
) {
|
) {
|
||||||
@@ -1280,6 +1358,7 @@ private fun ChannelDrawer(
|
|||||||
onRename = { onRename(ch) },
|
onRename = { onRename(ch) },
|
||||||
onToggleFavorite = { onToggleFavorite(ch) },
|
onToggleFavorite = { onToggleFavorite(ch) },
|
||||||
onEditIcon = { onEditIcon(ch) },
|
onEditIcon = { onEditIcon(ch) },
|
||||||
|
onToggleAutomation = { onToggleAutomation(ch) },
|
||||||
onDelete = { onDelete(ch) },
|
onDelete = { onDelete(ch) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1313,6 +1392,7 @@ private fun ChannelRail(
|
|||||||
onRename: (ChannelInfo) -> Unit,
|
onRename: (ChannelInfo) -> Unit,
|
||||||
onToggleFavorite: (ChannelInfo) -> Unit,
|
onToggleFavorite: (ChannelInfo) -> Unit,
|
||||||
onEditIcon: (ChannelInfo) -> Unit,
|
onEditIcon: (ChannelInfo) -> Unit,
|
||||||
|
onToggleAutomation: (ChannelInfo) -> Unit,
|
||||||
onDelete: (ChannelInfo) -> Unit,
|
onDelete: (ChannelInfo) -> Unit,
|
||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
) {
|
) {
|
||||||
@@ -1401,6 +1481,7 @@ private fun ChannelRail(
|
|||||||
onRename = { onRename(ch) },
|
onRename = { onRename(ch) },
|
||||||
onToggleFavorite = { onToggleFavorite(ch) },
|
onToggleFavorite = { onToggleFavorite(ch) },
|
||||||
onEditIcon = { onEditIcon(ch) },
|
onEditIcon = { onEditIcon(ch) },
|
||||||
|
onToggleAutomation = { onToggleAutomation(ch) },
|
||||||
onDelete = { onDelete(ch) },
|
onDelete = { onDelete(ch) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1448,6 +1529,7 @@ private fun InspectorPane(
|
|||||||
InfoRow("Name", currentChannel?.name ?: "—")
|
InfoRow("Name", currentChannel?.name ?: "—")
|
||||||
InfoRow("ID", currentChannel?.chatId ?: "—")
|
InfoRow("ID", currentChannel?.chatId ?: "—")
|
||||||
InfoRow("Default", if (currentChannel?.isDefault == true) "yes" else "no")
|
InfoRow("Default", if (currentChannel?.isDefault == true) "yes" else "no")
|
||||||
|
InfoRow("Automation", if (currentChannel?.automation == true) "yes" else "no")
|
||||||
InfoRow("Threads", threads.size.toString())
|
InfoRow("Threads", threads.size.toString())
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
Text("Last reply", style = MaterialTheme.typography.titleSmall, modifier = Modifier.padding(bottom = 4.dp))
|
Text("Last reply", style = MaterialTheme.typography.titleSmall, modifier = Modifier.padding(bottom = 4.dp))
|
||||||
|
|||||||
@@ -44,6 +44,10 @@ gateway identity concepts**.
|
|||||||
target a specific thread.
|
target a specific thread.
|
||||||
- The gateway's `create_handoff_thread` is used where hermes wants to open a
|
- The gateway's `create_handoff_thread` is used where hermes wants to open a
|
||||||
named thread (e.g. continuable cron).
|
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)
|
### 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
|
- **`channel.rename` / `channel.set_default` / `channel.delete`** manage the
|
||||||
directory (rename broadcasts `channel.renamed`; delete is soft — marks
|
directory (rename broadcasts `channel.renamed`; delete is soft — marks
|
||||||
archived, keeps history for search).
|
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
|
- **App affordance for threads:** long-press (touch) / right-click (desktop) a
|
||||||
topic chip → "Rename" (`channel.rename`) or "Delete" (`channel.delete`).
|
topic chip → "Rename" (`channel.rename`) or "Delete" (`channel.delete`).
|
||||||
Deleting the open thread falls the app back to the channel's flat lane.
|
Deleting the open thread falls the app back to the channel's flat lane.
|
||||||
|
|||||||
@@ -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" } } },
|
"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.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.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" } } },
|
"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"} } } } } },
|
"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" } },
|
"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" } } },
|
"error": { "payload": { "code": { "type": "string", "enum": ["auth", "not_found", "rate_limited", "media_too_large", "unsupported", "internal"] }, "message": { "type": "string" } } },
|
||||||
"pong": { "payload": { "ts": { "type": "integer" } } },
|
"pong": { "payload": { "ts": { "type": "integer" } } },
|
||||||
"sync.done": { "payload": { "cursor": { "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.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" } } }
|
"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": {
|
"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" } } },
|
"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.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.upload.end": { "payload": { "media_ref": { "type": "string" }, "sha256": { "type": "string" } } },
|
||||||
"media.pull": { "payload": { "media_id": { "type": "string" } } },
|
"media.pull": { "payload": { "media_id": { "type": "string" } } },
|
||||||
@@ -75,6 +75,7 @@
|
|||||||
"channel.set_default": { "payload": {} },
|
"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.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.delete": { "payload": {} },
|
||||||
"channel.list": { "description": "Request the full channel directory; answered by the server_to_app channel.list frame.", "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." } } },
|
"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": {
|
"definitions": {
|
||||||
"kind": { "type": "string", "enum": ["image", "audio", "video", "document", "voice"] },
|
"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."} } }
|
"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": [
|
"x-planned-frames": [
|
||||||
|
|||||||
@@ -1702,6 +1702,23 @@ class AndroidAdapter(BasePlatformAdapter):
|
|||||||
chat_id = self.home_channel
|
chat_id = self.home_channel
|
||||||
chat_id = chat_id.strip()
|
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")
|
thread_id = frame.thread_id or payload.get("thread_id")
|
||||||
if not isinstance(thread_id, str) or not thread_id.strip():
|
if not isinstance(thread_id, str) or not thread_id.strip():
|
||||||
thread_id = None
|
thread_id = None
|
||||||
@@ -1717,14 +1734,18 @@ class AndroidAdapter(BasePlatformAdapter):
|
|||||||
# LLM upgrades the name in the background. The user echo, the agent
|
# LLM upgrades the name in the background. The user echo, the agent
|
||||||
# turn, and all streaming frames then carry the new thread_id.
|
# turn, and all streaming frames then carry the new thread_id.
|
||||||
# Skipped for slash commands (session-scoped, not conversation
|
# 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
|
auto_thread = payload.get("auto_thread") is True
|
||||||
|
default_entry = self._channels.default()
|
||||||
if (
|
if (
|
||||||
auto_thread
|
auto_thread
|
||||||
and thread_id is None
|
and thread_id is None
|
||||||
and text.strip()
|
and text.strip()
|
||||||
and not text.lstrip().startswith("/")
|
and not text.lstrip().startswith("/")
|
||||||
and reply_to is None
|
and reply_to is None
|
||||||
|
and default_entry is not None
|
||||||
|
and chat_id == default_entry["chat_id"]
|
||||||
):
|
):
|
||||||
entry = self._channels.create(
|
entry = self._channels.create(
|
||||||
name=_derive_thread_name(text),
|
name=_derive_thread_name(text),
|
||||||
@@ -2160,6 +2181,34 @@ class AndroidAdapter(BasePlatformAdapter):
|
|||||||
resp.id = frame.id
|
resp.id = frame.id
|
||||||
await self._ws_server.broadcast(resp)
|
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:
|
async def on_channel_delete(self, frame: protocol.Frame, device_id: str) -> None:
|
||||||
chat_id = frame.chat_id or frame.payload.get("chat_id")
|
chat_id = frame.chat_id or frame.payload.get("chat_id")
|
||||||
if not isinstance(chat_id, str) or not chat_id.strip():
|
if not isinstance(chat_id, str) or not chat_id.strip():
|
||||||
|
|||||||
@@ -67,7 +67,8 @@ class ChannelDirectory:
|
|||||||
created REAL NOT NULL DEFAULT 0,
|
created REAL NOT NULL DEFAULT 0,
|
||||||
favorite INTEGER NOT NULL DEFAULT 0,
|
favorite INTEGER NOT NULL DEFAULT 0,
|
||||||
icon TEXT,
|
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")
|
self._conn.execute("ALTER TABLE channels ADD COLUMN icon TEXT")
|
||||||
if "color" not in existing:
|
if "color" not in existing:
|
||||||
self._conn.execute("ALTER TABLE channels ADD COLUMN color TEXT")
|
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(
|
self._conn.execute(
|
||||||
"""
|
"""
|
||||||
CREATE TABLE IF NOT EXISTS counters (
|
CREATE TABLE IF NOT EXISTS counters (
|
||||||
@@ -138,9 +143,12 @@ class ChannelDirectory:
|
|||||||
else:
|
else:
|
||||||
# Refresh the name only if it was never renamed by the user
|
# Refresh the name only if it was never renamed by the user
|
||||||
# (heuristic: still equals the previous default name is not
|
# (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(
|
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),
|
(KIND_DEFAULT, chat_id),
|
||||||
)
|
)
|
||||||
# Exactly one default: clear any other default flag.
|
# Exactly one default: clear any other default flag.
|
||||||
@@ -206,7 +214,11 @@ class ChannelDirectory:
|
|||||||
return self.get(chat_id)
|
return self.get(chat_id)
|
||||||
|
|
||||||
def set_default(self, chat_id: str) -> Optional[Dict[str, Any]]:
|
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:
|
with self._lock:
|
||||||
row = self._conn.execute(
|
row = self._conn.execute(
|
||||||
"SELECT 1 FROM channels WHERE chat_id = ? AND archived = 0",
|
"SELECT 1 FROM channels WHERE chat_id = ? AND archived = 0",
|
||||||
@@ -216,7 +228,9 @@ class ChannelDirectory:
|
|||||||
return None
|
return None
|
||||||
self._conn.execute("UPDATE channels SET is_default = 0")
|
self._conn.execute("UPDATE channels SET is_default = 0")
|
||||||
self._conn.execute(
|
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()
|
self._conn.commit()
|
||||||
return self.get(chat_id)
|
return self.get(chat_id)
|
||||||
@@ -250,6 +264,29 @@ class ChannelDirectory:
|
|||||||
return None
|
return None
|
||||||
return self.get(chat_id)
|
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]]:
|
def delete(self, chat_id: str) -> Optional[Dict[str, Any]]:
|
||||||
"""Soft-delete (archive) a channel. History stays for search.
|
"""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"]),
|
"favorite": bool(row["favorite"]),
|
||||||
"icon": row["icon"],
|
"icon": row["icon"],
|
||||||
"color": row["color"],
|
"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_SET_DEFAULT = "channel.set_default"
|
||||||
TYPE_CHANNEL_FAVORITE = "channel.favorite"
|
TYPE_CHANNEL_FAVORITE = "channel.favorite"
|
||||||
TYPE_CHANNEL_ICON = "channel.icon"
|
TYPE_CHANNEL_ICON = "channel.icon"
|
||||||
|
TYPE_CHANNEL_SET_AUTOMATION = "channel.set_automation"
|
||||||
TYPE_CHANNEL_DELETE = "channel.delete"
|
TYPE_CHANNEL_DELETE = "channel.delete"
|
||||||
TYPE_CHANNEL_CREATED = "channel.created"
|
TYPE_CHANNEL_CREATED = "channel.created"
|
||||||
TYPE_CHANNEL_RENAMED = "channel.renamed"
|
TYPE_CHANNEL_RENAMED = "channel.renamed"
|
||||||
@@ -442,6 +443,8 @@ def _channel_payload(entry: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
payload["icon"] = entry["icon"]
|
payload["icon"] = entry["icon"]
|
||||||
if entry.get("color"):
|
if entry.get("color"):
|
||||||
payload["color"] = entry["color"]
|
payload["color"] = entry["color"]
|
||||||
|
if entry.get("automation"):
|
||||||
|
payload["automation"] = True
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -374,6 +374,8 @@ class WsServer:
|
|||||||
await self._adapter.on_channel_favorite(frame, device_id)
|
await self._adapter.on_channel_favorite(frame, device_id)
|
||||||
elif frame.type == protocol.TYPE_CHANNEL_ICON:
|
elif frame.type == protocol.TYPE_CHANNEL_ICON:
|
||||||
await self._adapter.on_channel_icon(frame, device_id)
|
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:
|
elif frame.type == protocol.TYPE_CHANNEL_DELETE:
|
||||||
await self._adapter.on_channel_delete(frame, device_id)
|
await self._adapter.on_channel_delete(frame, device_id)
|
||||||
elif frame.type == protocol.TYPE_CHANNEL_LIST:
|
elif frame.type == protocol.TYPE_CHANNEL_LIST:
|
||||||
|
|||||||
Reference in new issue
Block a user