mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 08:18:19 +00:00
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:
1 parent
731a98221a
commit
0dab087fa3
14 files changed
+190
-15
No files matched your search
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user