feat(telegram): add yield mention mode for multi-bot group support (#471)

* 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 <viettranx@gmail.com>
This commit is contained in:
Thieu Nguyenandviettranx authored and GitHub committed 2026-03-26 18:08:36 +07:00
1 parent 731a98221a
commit 0dab087fa3
14 files changed
+190 -15

No files matched your search

+11
View File
@@ -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
}
+2
View File
@@ -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,
+49 -1
View File
@@ -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),
)
@@ -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.
@@ -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 {
+3
View File
@@ -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")
+6 -1
View File
@@ -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",
+6 -1
View File
@@ -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",
+6 -1
View File
@@ -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": "最小",
@@ -20,7 +20,7 @@ interface ChannelAdvancedDialogProps {
onUpdate: (updates: Record<string, unknown>) => Promise<void>;
}
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"]);
@@ -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<string, string[]> = {
_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<string, unknown>;
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}
/>
</section>
)}
+25 -5
View File
@@ -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 (
<FieldRenderer
key={field.key}
@@ -44,6 +54,8 @@ export function ChannelFields({ fields, values, onChange, idPrefix, isEdit, cont
onChange={(v) => 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.<key>.label" / "fieldConfig.<key>.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 (
<div className="flex items-center gap-2">
<div className={`flex items-center gap-2${disabled ? " opacity-50" : ""}`}>
<Switch
id={id}
checked={(value as boolean) ?? (field.defaultValue as boolean) ?? false}
onCheckedChange={(v) => onChange(v)}
disabled={disabled}
/>
<Label htmlFor={id}>{label}</Label>
{help && <span className="text-xs text-muted-foreground ml-1">— {help}</span>}
{resolvedHint && <span className="text-xs text-muted-foreground ml-1">— {resolvedHint}</span>}
{!resolvedHint && help && <span className="text-xs text-muted-foreground ml-1">— {help}</span>}
</div>
);
@@ -192,9 +211,9 @@ function FieldRenderer({
};
return (
<div className="grid gap-1.5">
<div className={`grid gap-1.5${disabled ? " opacity-50" : ""}`}>
<Label>{label}</Label>
<Select value={boolToStr(value)} onValueChange={(v) => onChange(strToBool(v))}>
<Select value={boolToStr(value)} onValueChange={(v) => onChange(strToBool(v))} disabled={disabled}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
@@ -204,7 +223,8 @@ function FieldRenderer({
))}
</SelectContent>
</Select>
{help && <p className="text-xs text-muted-foreground">{help}</p>}
{resolvedHint && <p className="text-xs text-muted-foreground">{resolvedHint}</p>}
{!resolvedHint && help && <p className="text-xs text-muted-foreground">{help}</p>}
</div>
);
}
+11 -2
View File
@@ -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<string, FieldDef[]> = {
@@ -74,7 +81,8 @@ export const configSchema: Record<string, FieldDef[]> = {
{ 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<string, FieldDef[]> = {
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" },
@@ -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<string, unknown>}
onChange={(key, value) => onChange({ ...config, [key]: value })}
idPrefix={idPrefix}
contextValues={config as Record<string, unknown>}
/>
);
}