From 0dab087fa3b78efc5414d52bf3997a5fea42808b Mon Sep 17 00:00:00 2001 From: Thieu Nguyen <79964592+thieung@users.noreply.github.com> Date: Thu, 26 Mar 2026 18:08:36 +0700 Subject: [PATCH] feat(telegram): add yield mention mode for multi-bot group support (#471) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(telegram): add yield mention mode for multi-bot group support - Add "yield" mention_mode: bot responds to all messages unless another bot is explicitly @mentioned, enabling shared groups where bots coexist - Skip messages from other bots (user.IsBot) in yield mode to prevent infinite cross-bot loops - Enter mention gate for yield mode even when require_mention is false - Add display_name to channel instance update allowlist - Change default dev postgres port to 5444 to avoid conflicts * fix(telegram): address yield mode review — pairing guard, mention check, naming - Add pairing guard to bot-skip path in yield mode (prevent history recording in unpaired groups — matches existing security pattern) - Check detectMention before skipping other bots' messages (allow cross-bot @commands like "@my_bot help" from another bot) - Rename hasOtherBotMention → hasOtherMention (function yields on any @mention, not just bots — name now matches behavior) - Remove dead MentionMode field from SlackConfig (no Slack handler uses it) - Add mention_mode validation with warning log for unknown values * fix(ui): swap mention_mode and require_mention field order * fix(i18n): add translations for require_mention disabled hint * fix(ui): apply mention_mode disabled hint to group override tab * fix(i18n): improve mention_mode UI labels for clarity Replace developer jargon (Strict/Yield) with user-friendly labels: - "Mention Mode" → "Group Response Behavior" - "Strict" → "Default (follow @mention setting)" - "Yield" → "Multi-bot (respond unless another bot is @mentioned)" --------- Co-authored-by: viettranx --- internal/channels/telegram/channel.go | 11 ++++ internal/channels/telegram/factory.go | 2 + internal/channels/telegram/handlers.go | 50 ++++++++++++++++++- internal/channels/telegram/handlers_utils.go | 43 ++++++++++++++++ internal/channels/telegram/topic_config.go | 17 +++++++ internal/config/config_channels.go | 3 ++ ui/web/src/i18n/locales/en/channels.json | 7 ++- ui/web/src/i18n/locales/vi/channels.json | 7 ++- ui/web/src/i18n/locales/zh/channels.json | 7 ++- .../channel-advanced-dialog.tsx | 2 +- .../channel-detail/channel-general-tab.tsx | 11 ++-- ui/web/src/pages/channels/channel-fields.tsx | 30 +++++++++-- ui/web/src/pages/channels/channel-schemas.ts | 13 ++++- .../pages/channels/telegram-group-fields.tsx | 2 + 14 files changed, 190 insertions(+), 15 deletions(-) diff --git a/internal/channels/telegram/channel.go b/internal/channels/telegram/channel.go index d97f6d13..d6fa135e 100644 --- a/internal/channels/telegram/channel.go +++ b/internal/channels/telegram/channel.go @@ -42,6 +42,7 @@ type Channel struct { groupHistory *channels.PendingHistory historyLimit int requireMention bool + mentionMode string // "strict" (default) or "yield" pollCancel context.CancelFunc // cancels the long polling context pollDone chan struct{} // closed when polling goroutine exits handlerWg sync.WaitGroup // tracks in-flight handler goroutines for graceful shutdown @@ -114,6 +115,15 @@ func New(cfg config.TelegramConfig, msgBus *bus.MessageBus, pairingSvc store.Pai historyLimit = channels.DefaultGroupHistoryLimit } + mentionMode := cfg.MentionMode + if mentionMode == "" { + mentionMode = "strict" + } + if mentionMode != "strict" && mentionMode != "yield" { + slog.Warn("telegram: unknown mention_mode, defaulting to strict", "value", mentionMode) + mentionMode = "strict" + } + return &Channel{ BaseChannel: base, bot: bot, @@ -127,6 +137,7 @@ func New(cfg config.TelegramConfig, msgBus *bus.MessageBus, pairingSvc store.Pai groupHistory: channels.MakeHistory(channels.TypeTelegram, pendingStore, base.TenantID()), historyLimit: historyLimit, requireMention: requireMention, + mentionMode: mentionMode, }, nil } diff --git a/internal/channels/telegram/factory.go b/internal/channels/telegram/factory.go index e379606f..786e2ac0 100644 --- a/internal/channels/telegram/factory.go +++ b/internal/channels/telegram/factory.go @@ -24,6 +24,7 @@ type telegramInstanceConfig struct { DMPolicy string `json:"dm_policy,omitempty"` GroupPolicy string `json:"group_policy,omitempty"` RequireMention *bool `json:"require_mention,omitempty"` + MentionMode string `json:"mention_mode,omitempty"` HistoryLimit int `json:"history_limit,omitempty"` DMStream *bool `json:"dm_stream,omitempty"` GroupStream *bool `json:"group_stream,omitempty"` @@ -91,6 +92,7 @@ func buildChannel(name string, creds json.RawMessage, cfg json.RawMessage, DMPolicy: ic.DMPolicy, GroupPolicy: ic.GroupPolicy, RequireMention: ic.RequireMention, + MentionMode: ic.MentionMode, HistoryLimit: ic.HistoryLimit, DMStream: ic.DMStream, GroupStream: ic.GroupStream, diff --git a/internal/channels/telegram/handlers.go b/internal/channels/telegram/handlers.go index 4e796635..1b95648f 100644 --- a/internal/channels/telegram/handlers.go +++ b/internal/channels/telegram/handlers.go @@ -233,8 +233,46 @@ func (c *Channel) handleMessage(ctx context.Context, update telego.Update) { // --- Group mention gating (matching TS mentionGate logic) --- // Also check implicit mention via reply-to-bot - if isGroup && topicCfg.effectiveRequireMention(c.requireMention) { + // + // mention_mode controls multi-bot behavior in groups: + // "strict" (default): only respond when explicitly @mentioned (require_mention=true) + // "yield": respond to all messages UNLESS another bot/user is @mentioned (and not us) + // — enables "shared group" where all bots listen, but yield when someone is called by name + mentionMode := topicCfg.effectiveMentionMode(c.mentionMode) + if isGroup && (topicCfg.effectiveRequireMention(c.requireMention) || mentionMode == "yield") { botUsername := c.bot.Username() + + // In yield mode, skip messages from other bots to prevent infinite loops. + // Bot A responds → Bot B sees it as "no specific mention" → responds → loop. + // Only skip when our bot is NOT explicitly mentioned — allow cross-bot @commands. + if mentionMode == "yield" && user.IsBot && user.Username != botUsername && !c.detectMention(message, botUsername) { + // Respect pairing guard — don't record history in unpaired groups. + if topicCfg.groupPolicy == "pairing" && c.pairingService != nil { + if _, cached := c.approvedGroups.Load(chatIDStr); !cached { + groupSenderID := fmt.Sprintf("group:%d", chatID) + paired, pairErr := c.pairingService.IsPaired(ctx, groupSenderID, c.Name()) + if pairErr != nil { + slog.Warn("security.pairing_check_failed, assuming paired (fail-open)", + "group_sender", groupSenderID, "channel", c.Name(), "error", pairErr) + paired = true + } + if paired { + c.approvedGroups.Store(chatIDStr, true) + } else { + return + } + } + } + c.groupHistory.Record(localKey, channels.HistoryEntry{ + Sender: senderLabel, + SenderID: senderID, + Body: content, + Timestamp: time.Unix(int64(message.Date), 0), + MessageID: fmt.Sprintf("%d", message.MessageID), + }, c.historyLimit) + return + } + wasMentioned := c.detectMention(message, botUsername) // Reply to bot's message counts as implicit mention @@ -242,10 +280,20 @@ func (c *Channel) handleMessage(ctx context.Context, update telego.Update) { wasMentioned = true } + // Yield mode: skip only if another bot/user is explicitly mentioned (not us). + // If nobody is mentioned → respond. If we are mentioned → respond. + if mentionMode == "yield" && !wasMentioned { + // In yield mode, respond unless someone else was specifically called out + if !c.hasOtherMention(message, botUsername) { + wasMentioned = true // treat as mentioned — no specific target, all bots respond + } + } + slog.Debug("telegram group mention gate", "chat_id", chatID, "bot_username", botUsername, "require_mention", c.requireMention, + "mention_mode", mentionMode, "was_mentioned", wasMentioned, "text_preview", channels.Truncate(content, 60), ) diff --git a/internal/channels/telegram/handlers_utils.go b/internal/channels/telegram/handlers_utils.go index d169b4b0..bb30507c 100644 --- a/internal/channels/telegram/handlers_utils.go +++ b/internal/channels/telegram/handlers_utils.go @@ -59,6 +59,49 @@ func (c *Channel) detectMention(msg *telego.Message, botUsername string) bool { return false } +// hasOtherMention checks if the message mentions any user/bot other than ours. +// Used by "yield" mention mode: if another entity is explicitly @mentioned, this bot yields. +// Checks both "mention" entities (@username) and "bot_command" entities (/cmd@bot). +func (c *Channel) hasOtherMention(msg *telego.Message, myUsername string) bool { + lowerMy := strings.ToLower(myUsername) + + for _, pair := range []struct { + entities []telego.MessageEntity + text string + }{ + {msg.Entities, msg.Text}, + {msg.CaptionEntities, msg.Caption}, + } { + if pair.text == "" { + continue + } + for _, entity := range pair.entities { + if entity.Type == "mention" { + mentioned := pair.text[entity.Offset : entity.Offset+entity.Length] + mentionedLower := strings.ToLower(mentioned) + // Skip our own mention + if mentionedLower == "@"+lowerMy { + continue + } + // Any other @mention in the message → another bot/user was called + return true + } + if entity.Type == "bot_command" { + cmdText := pair.text[entity.Offset : entity.Offset+entity.Length] + cmdLower := strings.ToLower(cmdText) + // Commands addressed to another bot: /cmd@other_bot + if atIdx := strings.Index(cmdLower, "@"); atIdx > 0 { + target := cmdLower[atIdx+1:] + if target != lowerMy { + return true + } + } + } + } + } + return false +} + // isServiceMessage returns true if the Telegram message is a service/system message // (member added/removed, title changed, pinned, etc.) rather than a user-sent message. // Service messages have no text, caption, or media content. diff --git a/internal/channels/telegram/topic_config.go b/internal/channels/telegram/topic_config.go index 93ac9608..5b5c721b 100644 --- a/internal/channels/telegram/topic_config.go +++ b/internal/channels/telegram/topic_config.go @@ -13,6 +13,7 @@ import ( type resolvedTopicConfig struct { groupPolicy string requireMention *bool + mentionMode string // "strict" (default) or "yield" allowFrom []string enabled *bool skills []string // nil = inherit, non-nil = override (empty = no skills) @@ -27,6 +28,7 @@ func resolveTopicConfig(cfg config.TelegramConfig, chatIDStr string, topicID int result := resolvedTopicConfig{ groupPolicy: cfg.GroupPolicy, requireMention: cfg.RequireMention, + mentionMode: cfg.MentionMode, allowFrom: cfg.AllowFrom, } @@ -65,6 +67,9 @@ func mergeGroupInto(dst *resolvedTopicConfig, src *config.TelegramGroupConfig) { if src.RequireMention != nil { dst.requireMention = src.RequireMention } + if src.MentionMode != "" { + dst.mentionMode = src.MentionMode + } if len(src.AllowFrom) > 0 { dst.allowFrom = src.AllowFrom } @@ -91,6 +96,9 @@ func mergeTopicInto(dst *resolvedTopicConfig, src *config.TelegramTopicConfig, g if src.RequireMention != nil { dst.requireMention = src.RequireMention } + if src.MentionMode != "" { + dst.mentionMode = src.MentionMode + } if len(src.AllowFrom) > 0 { dst.allowFrom = src.AllowFrom } @@ -122,6 +130,15 @@ func (r *resolvedTopicConfig) isEnabled() bool { return r.enabled == nil || *r.enabled } +// effectiveMentionMode returns the resolved mention_mode value. +// Falls back to the channel default if not overridden at group/topic level. +func (r *resolvedTopicConfig) effectiveMentionMode(defaultVal string) string { + if r.mentionMode != "" { + return r.mentionMode + } + return defaultVal +} + // effectiveRequireMention returns the resolved require_mention value. // Falls back to the provided default if not overridden. func (r *resolvedTopicConfig) effectiveRequireMention(defaultVal bool) bool { diff --git a/internal/config/config_channels.go b/internal/config/config_channels.go index f247a848..b29112f9 100644 --- a/internal/config/config_channels.go +++ b/internal/config/config_channels.go @@ -32,6 +32,7 @@ type TelegramConfig struct { DMPolicy string `json:"dm_policy,omitempty"` // "pairing" (default), "allowlist", "open", "disabled" GroupPolicy string `json:"group_policy,omitempty"` // "open" (default), "allowlist", "disabled" RequireMention *bool `json:"require_mention,omitempty"` // require @bot mention in groups (default true) + MentionMode string `json:"mention_mode,omitempty"` // "strict" (default) = only respond when mentioned; "yield" = respond unless another bot is mentioned HistoryLimit int `json:"history_limit,omitempty"` // max pending group messages for context (default 50, 0=disabled) DMStream *bool `json:"dm_stream,omitempty"` // enable streaming for DMs (default false) — edits placeholder progressively GroupStream *bool `json:"group_stream,omitempty"` // enable streaming for groups (default false) — sends new message, edits progressively @@ -70,6 +71,7 @@ type TelegramConfig struct { type TelegramGroupConfig struct { GroupPolicy string `json:"group_policy,omitempty"` // override group policy for this group RequireMention *bool `json:"require_mention,omitempty"` // override require_mention for this group + MentionMode string `json:"mention_mode,omitempty"` // override mention_mode for this group AllowFrom FlexibleStringSlice `json:"allow_from,omitempty"` // override allow_from for this group Enabled *bool `json:"enabled,omitempty"` // disable bot for this group (default: true) Skills []string `json:"skills,omitempty"` // skill whitelist (nil = all, [] = none) @@ -83,6 +85,7 @@ type TelegramGroupConfig struct { // Matching TS TelegramTopicConfig in src/config/types.telegram.ts. type TelegramTopicConfig struct { RequireMention *bool `json:"require_mention,omitempty"` + MentionMode string `json:"mention_mode,omitempty"` GroupPolicy string `json:"group_policy,omitempty"` Skills []string `json:"skills,omitempty"` Tools []string `json:"tools,omitempty"` // tool allow list (nil = inherit, supports "group:xxx") diff --git a/ui/web/src/i18n/locales/en/channels.json b/ui/web/src/i18n/locales/en/channels.json index 18bbad69..6934165b 100644 --- a/ui/web/src/i18n/locales/en/channels.json +++ b/ui/web/src/i18n/locales/en/channels.json @@ -183,7 +183,8 @@ "proxy": { "label": "HTTP Proxy", "help": "Route bot traffic through an HTTP proxy" }, "dm_policy": { "label": "DM Policy" }, "group_policy": { "label": "Group Policy" }, - "require_mention": { "label": "Require @mention in groups" }, + "require_mention": { "label": "Require @mention in groups", "disabledHint": "Multi-bot mode always listens to all messages" }, + "mention_mode": { "label": "Group Response Behavior", "help": "How the bot decides when to respond in groups with multiple bots." }, "history_limit": { "label": "Group History Limit", "help": "Max pending group messages for context (0 = disabled)" }, "dm_stream": { "label": "DM Streaming", "help": "Stream response progressively in DMs" }, "group_stream": { "label": "Group Streaming", "help": "Stream response progressively in groups" }, @@ -228,6 +229,10 @@ "allowlist": "Allowlist only", "disabled": "Disabled" }, + "mention_mode": { + "strict": "Default (follow @mention setting)", + "yield": "Multi-bot (respond unless another bot is @mentioned)" + }, "reaction_level": { "off": "Off", "minimal": "Minimal", diff --git a/ui/web/src/i18n/locales/vi/channels.json b/ui/web/src/i18n/locales/vi/channels.json index c11a362e..950be918 100644 --- a/ui/web/src/i18n/locales/vi/channels.json +++ b/ui/web/src/i18n/locales/vi/channels.json @@ -183,7 +183,8 @@ "proxy": { "label": "Proxy HTTP", "help": "Định tuyến lưu lượng bot qua proxy HTTP" }, "dm_policy": { "label": "Chính sách DM" }, "group_policy": { "label": "Chính sách nhóm" }, - "require_mention": { "label": "Yêu cầu @đề cập trong nhóm" }, + "require_mention": { "label": "Yêu cầu @đề cập trong nhóm", "disabledHint": "Chế độ đa bot luôn lắng nghe tất cả tin nhắn" }, + "mention_mode": { "label": "Hành vi phản hồi nhóm", "help": "Cách bot quyết định khi nào phản hồi trong nhóm có nhiều bot." }, "history_limit": { "label": "Giới hạn lịch sử nhóm", "help": "Số tin nhắn nhóm chờ tối đa cho ngữ cảnh (0 = tắt)" }, "dm_stream": { "label": "Stream DM", "help": "Truyền phản hồi liên tục trong DM" }, "group_stream": { "label": "Stream nhóm", "help": "Truyền phản hồi liên tục trong nhóm" }, @@ -228,6 +229,10 @@ "allowlist": "Chỉ danh sách cho phép", "disabled": "Tắt" }, + "mention_mode": { + "strict": "Mặc định (theo cài đặt @đề cập)", + "yield": "Đa bot (phản hồi trừ khi bot khác được @đề cập)" + }, "reaction_level": { "off": "Tắt", "minimal": "Tối thiểu", diff --git a/ui/web/src/i18n/locales/zh/channels.json b/ui/web/src/i18n/locales/zh/channels.json index 80ed29ef..2fbf7245 100644 --- a/ui/web/src/i18n/locales/zh/channels.json +++ b/ui/web/src/i18n/locales/zh/channels.json @@ -183,7 +183,8 @@ "proxy": { "label": "HTTP 代理", "help": "通过 HTTP 代理路由Bot流量" }, "dm_policy": { "label": "私聊策略" }, "group_policy": { "label": "群组策略" }, - "require_mention": { "label": "群组中需要 @提及" }, + "require_mention": { "label": "群组中需要 @提及", "disabledHint": "多机器人模式始终监听所有消息" }, + "mention_mode": { "label": "群组响应行为", "help": "机器人在多机器人群组中如何决定何时响应。" }, "history_limit": { "label": "群组历史限制", "help": "上下文中最大待处理群组消息数(0 = 禁用)" }, "dm_stream": { "label": "私聊流式传输", "help": "在私聊中逐步传输回复" }, "group_stream": { "label": "群聊流式传输", "help": "在群聊中逐步传输回复" }, @@ -228,6 +229,10 @@ "allowlist": "仅白名单", "disabled": "禁用" }, + "mention_mode": { + "strict": "默认(遵循 @提及设置)", + "yield": "多机器人(除非其他机器人被 @提及否则响应)" + }, "reaction_level": { "off": "关闭", "minimal": "最小", diff --git a/ui/web/src/pages/channels/channel-detail/channel-advanced-dialog.tsx b/ui/web/src/pages/channels/channel-detail/channel-advanced-dialog.tsx index d124ca5d..1a9a11c8 100644 --- a/ui/web/src/pages/channels/channel-detail/channel-advanced-dialog.tsx +++ b/ui/web/src/pages/channels/channel-detail/channel-advanced-dialog.tsx @@ -20,7 +20,7 @@ interface ChannelAdvancedDialogProps { onUpdate: (updates: Record) => Promise; } -const ESSENTIAL_CONFIG_KEYS = new Set(["dm_policy", "group_policy", "require_mention"]); +const ESSENTIAL_CONFIG_KEYS = new Set(["dm_policy", "group_policy", "require_mention", "mention_mode"]); const NETWORK_KEYS = new Set(["api_server", "proxy", "domain", "connection_mode", "webhook_port", "webhook_path", "webhook_url"]); const LIMITS_KEYS = new Set(["history_limit", "media_max_mb", "text_chunk_limit"]); diff --git a/ui/web/src/pages/channels/channel-detail/channel-general-tab.tsx b/ui/web/src/pages/channels/channel-detail/channel-general-tab.tsx index faf7a8cb..cc97aa0e 100644 --- a/ui/web/src/pages/channels/channel-detail/channel-general-tab.tsx +++ b/ui/web/src/pages/channels/channel-detail/channel-general-tab.tsx @@ -17,7 +17,10 @@ import type { ChannelInstanceData } from "@/types/channel"; import type { AgentData } from "@/types/agent"; import { channelTypeLabels } from "../channels-status-view"; -const ESSENTIAL_CONFIG_KEYS = ["dm_policy", "group_policy", "require_mention"]; +const ESSENTIAL_CONFIG_KEYS: Record = { + _default: ["dm_policy", "group_policy", "require_mention"], + telegram: ["dm_policy", "group_policy", "mention_mode", "require_mention"], +}; interface ChannelGeneralTabProps { instance: ChannelInstanceData; @@ -34,10 +37,11 @@ export function ChannelGeneralTab({ instance, agents, onUpdate }: ChannelGeneral // Essential config fields (policies) const allConfigFields = configSchema[instance.channel_type] ?? []; - const essentialFields = allConfigFields.filter((f) => ESSENTIAL_CONFIG_KEYS.includes(f.key)); + const essentialKeys = ESSENTIAL_CONFIG_KEYS[instance.channel_type] ?? ESSENTIAL_CONFIG_KEYS._default ?? []; + const essentialFields = allConfigFields.filter((f) => essentialKeys.includes(f.key)); const existingConfig = (instance.config ?? {}) as Record; const initialPolicyValues = Object.fromEntries( - ESSENTIAL_CONFIG_KEYS + essentialKeys .filter((k) => existingConfig[k] !== undefined) .map((k) => [k, existingConfig[k]]), ); @@ -133,6 +137,7 @@ export function ChannelGeneralTab({ instance, agents, onUpdate }: ChannelGeneral values={policyValues} onChange={handlePolicyChange} idPrefix="cd-pol" + contextValues={policyValues} /> )} diff --git a/ui/web/src/pages/channels/channel-fields.tsx b/ui/web/src/pages/channels/channel-fields.tsx index ed98d0c0..27bb06a9 100644 --- a/ui/web/src/pages/channels/channel-fields.tsx +++ b/ui/web/src/pages/channels/channel-fields.tsx @@ -36,6 +36,16 @@ export function ChannelFields({ fields, values, onChange, idPrefix, isEdit, cont const depValue = allValues[field.showWhen.key] ?? fields.find((f) => f.key === field.showWhen!.key)?.defaultValue; if (String(depValue) !== field.showWhen.value) return null; } + // Check disabledWhen condition + let disabled = false; + let disabledHint: string | undefined; + if (field.disabledWhen) { + const depValue = allValues[field.disabledWhen.key] ?? fields.find((f) => f.key === field.disabledWhen!.key)?.defaultValue; + if (String(depValue) === field.disabledWhen.value) { + disabled = true; + disabledHint = field.disabledWhen.hint; + } + } return ( onChange(field.key, v)} id={`${idPrefix}-${field.key}`} isEdit={isEdit} + disabled={disabled} + disabledHint={disabledHint} /> ); })} @@ -57,17 +69,22 @@ function FieldRenderer({ onChange, id, isEdit, + disabled, + disabledHint, }: { field: FieldDef; value: unknown; onChange: (v: unknown) => void; id: string; isEdit?: boolean; + disabled?: boolean; + disabledHint?: string; }) { const { t } = useTranslation("channels"); // i18n: try "fieldConfig..label" / "fieldConfig..help", fall back to hardcoded schema string const label = t(`fieldConfig.${field.key}.label`, { defaultValue: field.label }); const help = field.help ? t(`fieldConfig.${field.key}.help`, { defaultValue: field.help }) : ""; + const resolvedHint = disabledHint ? t(disabledHint, { defaultValue: disabledHint }) : undefined; const labelSuffix = field.required && !isEdit ? " *" : ""; const editHint = isEdit && field.type === "password" ? ` ${t("form.credentialsHint")}` : ""; @@ -107,14 +124,16 @@ function FieldRenderer({ case "boolean": return ( -
+
onChange(v)} + disabled={disabled} /> - {help && — {help}} + {resolvedHint && — {resolvedHint}} + {!resolvedHint && help && — {help}}
); @@ -192,9 +211,9 @@ function FieldRenderer({ }; return ( -
+
- onChange(strToBool(v))} disabled={disabled}> @@ -204,7 +223,8 @@ function FieldRenderer({ ))} - {help &&

{help}

} + {resolvedHint &&

{resolvedHint}

} + {!resolvedHint && help &&

{help}

}
); } diff --git a/ui/web/src/pages/channels/channel-schemas.ts b/ui/web/src/pages/channels/channel-schemas.ts index f6d543b9..ed27d368 100644 --- a/ui/web/src/pages/channels/channel-schemas.ts +++ b/ui/web/src/pages/channels/channel-schemas.ts @@ -12,6 +12,8 @@ export interface FieldDef { help?: string; /** Only show this field when another field has a specific value */ showWhen?: { key: string; value: string }; + /** Disable this field when another field has a specific value */ + disabledWhen?: { key: string; value: string; hint?: string }; } // --- Shared option lists --- @@ -36,6 +38,11 @@ export const groupPolicyOptions = [ { value: "disabled", label: "Disabled" }, ]; +const mentionModeOptions = [ + { value: "strict", label: "Default (follow @mention setting)" }, + { value: "yield", label: "Multi-bot (respond unless another bot is @mentioned)" }, +]; + // --- Credentials schemas --- export const credentialsSchema: Record = { @@ -74,7 +81,8 @@ export const configSchema: Record = { { key: "proxy", label: "HTTP Proxy", type: "text", placeholder: "http://proxy:8080", help: "Route bot traffic through an HTTP proxy" }, { key: "dm_policy", label: "DM Policy", type: "select", options: dmPolicyOptions, defaultValue: "pairing" }, { key: "group_policy", label: "Group Policy", type: "select", options: groupPolicyOptions, defaultValue: "pairing" }, - { key: "require_mention", label: "Require @mention in groups", type: "boolean", defaultValue: true }, + { key: "mention_mode", label: "Group Response Behavior", type: "select", options: mentionModeOptions, defaultValue: "strict", help: "How the bot decides when to respond in groups with multiple bots." }, + { key: "require_mention", label: "Require @mention in groups", type: "boolean", defaultValue: true, disabledWhen: { key: "mention_mode", value: "yield", hint: "fieldConfig.require_mention.disabledHint" } }, { key: "history_limit", label: "Group History Limit", type: "number", defaultValue: 50, help: "Max pending group messages for context (0 = disabled)" }, { key: "dm_stream", label: "DM Streaming", type: "boolean", defaultValue: true, help: "Stream response progressively in DMs" }, { key: "group_stream", label: "Group Streaming", type: "boolean", defaultValue: false, help: "Stream response progressively in groups" }, @@ -155,7 +163,8 @@ export const configSchema: Record = { export const groupOverrideSchema: FieldDef[] = [ { key: "group_policy", label: "Group Policy", type: "tristate", options: groupPolicyOptions }, - { key: "require_mention", label: "Require @mention", type: "tristate" }, + { key: "mention_mode", label: "Mention Mode", type: "tristate", options: mentionModeOptions }, + { key: "require_mention", label: "Require @mention", type: "tristate", disabledWhen: { key: "mention_mode", value: "yield", hint: "fieldConfig.require_mention.disabledHint" } }, { key: "enabled", label: "Enabled", type: "tristate" }, { key: "allow_from", label: "Allowed Users", type: "tags", placeholder: "User IDs, one per line", help: "Restrict which users can interact in this group" }, { key: "skills", label: "Skills Filter", type: "skill-select", help: "Limit available skills for this group" }, diff --git a/ui/web/src/pages/channels/telegram-group-fields.tsx b/ui/web/src/pages/channels/telegram-group-fields.tsx index e87e7d9a..6e5deb37 100644 --- a/ui/web/src/pages/channels/telegram-group-fields.tsx +++ b/ui/web/src/pages/channels/telegram-group-fields.tsx @@ -4,6 +4,7 @@ import { groupOverrideSchema } from "./channel-schemas"; export interface TelegramGroupConfigValues { group_policy?: string; require_mention?: boolean; + mention_mode?: string; enabled?: boolean; allow_from?: string[]; skills?: string[]; @@ -24,6 +25,7 @@ export function TelegramGroupFields({ config, onChange, idPrefix }: Props) { values={config as Record} onChange={(key, value) => onChange({ ...config, [key]: value })} idPrefix={idPrefix} + contextValues={config as Record} /> ); }