From 4e5d760bd2bce0feb6d86b9754a483c490140e7f Mon Sep 17 00:00:00 2001 From: Rayquaza Date: Sun, 8 Mar 2026 01:12:57 +0800 Subject: [PATCH] feat(telegram): add bot command menu registration - add BotCommandInfo and CommandRegistrar interface for platform abstraction - implement GetAllCommands to collect builtin/custom commands and skills - add i18n descriptions for all 30 builtin commands (en/zh/zh-TW/ja/es) - implement RegisterCommands for Telegram setMyCommands API integration --- core/engine.go | 78 +++++++++++ core/i18n.go | 240 +++++++++++++++++++++++++++++++++- core/interfaces.go | 12 ++ platform/telegram/telegram.go | 106 ++++++++++++--- 4 files changed, 414 insertions(+), 22 deletions(-) diff --git a/core/engine.go b/core/engine.go index bb67701..dc1b133 100644 --- a/core/engine.go +++ b/core/engine.go @@ -446,6 +446,16 @@ func (e *Engine) Start() error { continue } slog.Info("platform started", "project", e.name, "platform", p.Name()) + + // Register commands on platforms that support it (e.g. Telegram setMyCommands) + if registrar, ok := p.(CommandRegistrar); ok { + commands := e.GetAllCommands() + if err := registrar.RegisterCommands(commands); err != nil { + slog.Error("platform command registration failed", "project", e.name, "platform", p.Name(), "error", err) + } else { + slog.Debug("platform commands registered", "project", e.name, "platform", p.Name(), "count", len(commands)) + } + } } // Log summary @@ -1874,6 +1884,74 @@ func (e *Engine) cmdHelp(p Platform, msg *Message) { e.reply(p, msg.ReplyCtx, e.i18n.T(MsgHelp)) } +// GetAllCommands returns all available commands for bot menu registration. +// It includes built-in commands (with localized descriptions) and custom commands. +func (e *Engine) GetAllCommands() []BotCommandInfo { + var commands []BotCommandInfo + + // Collect built-in commands (use primary name, first in names list) + seenCmds := make(map[string]bool) + for _, c := range builtinCommands { + if len(c.names) == 0 { + continue + } + // Use id as primary + primaryName := c.id + if seenCmds[primaryName] { + continue + } + seenCmds[primaryName] = true + + // Skip disabled commands + if e.disabledCmds[c.id] { + continue + } + + commands = append(commands, BotCommandInfo{ + Command: primaryName, + Description: e.i18n.T(MsgKey(primaryName)), + }) + } + + // Collect custom commands from CommandRegistry + for _, c := range e.commands.ListAll() { + if seenCmds[strings.ToLower(c.Name)] { + continue + } + seenCmds[strings.ToLower(c.Name)] = true + + desc := c.Description + if desc == "" { + desc = "Custom command" + } + + commands = append(commands, BotCommandInfo{ + Command: c.Name, + Description: desc, + }) + } + + // Collect skills + for _, s := range e.skills.ListAll() { + if seenCmds[strings.ToLower(s.Name)] { + continue + } + seenCmds[strings.ToLower(s.Name)] = true + + desc := s.Description + if desc == "" { + desc = "Skill" + } + + commands = append(commands, BotCommandInfo{ + Command: s.Name, + Description: desc, + }) + } + + return commands +} + func (e *Engine) cmdModel(p Platform, msg *Message, args []string) { switcher, ok := e.agent.(ModelSwitcher) if !ok { diff --git a/core/i18n.go b/core/i18n.go index 291c7b5..f3fbff1 100644 --- a/core/i18n.go +++ b/core/i18n.go @@ -148,7 +148,7 @@ const ( MsgLangInvalid MsgKey = "lang_invalid" MsgLangCurrent MsgKey = "lang_current" MsgUnknownCommand MsgKey = "unknown_command" - MsgHelp MsgKey = "help" + MsgHelp MsgKey = "message_help" // change from "help", which is used now for builtin command help MsgListTitle MsgKey = "list_title" MsgListTitlePaged MsgKey = "list_title_paged" MsgListEmpty MsgKey = "list_empty" @@ -235,8 +235,8 @@ const ( MsgPermBtnDeny MsgKey = "perm_btn_deny" MsgPermBtnAllowAll MsgKey = "perm_btn_allow_all" - MsgCommandsTitle MsgKey = "commands_title" - MsgCommandsEmpty MsgKey = "commands_empty" + MsgCommandsTitle MsgKey = "commands_title" + MsgCommandsEmpty MsgKey = "commands_empty" MsgCommandsHint MsgKey = "commands_hint" MsgCommandsUsage MsgKey = "commands_usage" MsgCommandsAddUsage MsgKey = "commands_add_usage" @@ -314,6 +314,36 @@ const ( MsgSearchNoResult MsgKey = "search_no_result" MsgSearchResult MsgKey = "search_result" MsgSearchHint MsgKey = "search_hint" + + MsgBuiltinCmdNew MsgKey = "new" + MsgBuiltinCmdList MsgKey = "list" + MsgBuiltinCmdSearch MsgKey = "search" + MsgBuiltinCmdSwitch MsgKey = "switch" + MsgBuiltinCmdDelete MsgKey = "delete" + MsgBuiltinCmdName MsgKey = "name" + MsgBuiltinCmdCurrent MsgKey = "current" + MsgBuiltinCmdHistory MsgKey = "history" + MsgBuiltinCmdProvider MsgKey = "provider" + MsgBuiltinCmdMemory MsgKey = "memory" + MsgBuiltinCmdAllow MsgKey = "allow" + MsgBuiltinCmdModel MsgKey = "model" + MsgBuiltinCmdMode MsgKey = "mode" + MsgBuiltinCmdLang MsgKey = "lang" + MsgBuiltinCmdQuiet MsgKey = "quiet" + MsgBuiltinCmdCompress MsgKey = "compress" + MsgBuiltinCmdStop MsgKey = "stop" + MsgBuiltinCmdCron MsgKey = "cron" + MsgBuiltinCmdCommands MsgKey = "commands" + MsgBuiltinCmdAlias MsgKey = "alias" + MsgBuiltinCmdSkills MsgKey = "skills" + MsgBuiltinCmdConfig MsgKey = "config" + MsgBuiltinCmdDoctor MsgKey = "doctor" + MsgBuiltinCmdUpgrade MsgKey = "upgrade" + MsgBuiltinCmdRestart MsgKey = "restart" + MsgBuiltinCmdStatus MsgKey = "status" + MsgBuiltinCmdVersion MsgKey = "version" + MsgBuiltinCmdHelp MsgKey = "help" + MsgBuiltinCmdBind MsgKey = "bind" ) var messages = map[MsgKey]map[Language]string{ @@ -1741,6 +1771,210 @@ var messages = map[MsgKey]map[Language]string{ LangJapanese: "/switch でセッションを切り替え。", LangSpanish: "Usa /switch para cambiar a una sesión.", }, + // Builtin command descriptions + MsgBuiltinCmdNew: { + LangEnglish: "Start a new session, arg: [name]", + LangChinese: "创建新会话,参数: [名称]", + LangTraditionalChinese: "建立新會話,參數: [名稱]", + LangJapanese: "新しいセッションを開始、引数: [名前]", + LangSpanish: "Iniciar una nueva sesión, arg: [nombre]", + }, + MsgBuiltinCmdList: { + LangEnglish: "List agent sessions", + LangChinese: "列出 Agent 会话列表", + LangTraditionalChinese: "列出 Agent 會話列表", + LangJapanese: "エージェントセッション一覧", + LangSpanish: "Listar sesiones del agente", + }, + MsgBuiltinCmdSearch: { + LangEnglish: "Search sessions by name or ID, arg: ", + LangChinese: "搜索会话名称或 ID,参数: <关键词>", + LangTraditionalChinese: "搜尋會話名稱或 ID,參數: <關鍵詞>", + LangJapanese: "セッションを名前またはIDで検索、引数: <キーワード>", + LangSpanish: "Buscar sesiones por nombre o ID, arg: ", + }, + MsgBuiltinCmdSwitch: { + LangEnglish: "Resume a session by its list number, arg: ", + LangChinese: "按列表序号切换会话,参数: <序号>", + LangTraditionalChinese: "按列表序號切換會話,參數: <序號>", + LangJapanese: "リスト番号でセッションを切り替え、引数: <番号>", + LangSpanish: "Reanudar sesión por su número en la lista, arg: ", + }, + MsgBuiltinCmdDelete: { + LangEnglish: "Delete a session by its list number, arg: ", + LangChinese: "按列表序号删除会话,参数: <序号>", + LangTraditionalChinese: "按列表序號刪除會話,參數: <序號>", + LangJapanese: "リスト番号でセッションを削除、引数: <番号>", + LangSpanish: "Eliminar sesión por su número en la lista, arg: ", + }, + MsgBuiltinCmdName: { + LangEnglish: "Name a session for easy identification, arg: [number] ", + LangChinese: "给会话命名,方便识别,参数: [序号] <名称>", + LangTraditionalChinese: "為會話命名,方便辨識,參數: [序號] <名稱>", + LangJapanese: "セッションに名前を付ける、引数: [番号] <名前>", + LangSpanish: "Nombrar una sesión para fácil identificación, arg: [número] ", + }, + MsgBuiltinCmdCurrent: { + LangEnglish: "Show current active session", + LangChinese: "查看当前活跃会话", + LangTraditionalChinese: "查看當前活躍會話", + LangJapanese: "現在のアクティブセッションを表示", + LangSpanish: "Mostrar sesión activa actual", + }, + MsgBuiltinCmdHistory: { + LangEnglish: "Show last n messages, arg: [n] (default 10)", + LangChinese: "查看最近 n 条消息,参数: [n](默认 10)", + LangTraditionalChinese: "查看最近 n 條訊息,參數: [n](預設 10)", + LangJapanese: "直近 n 件のメッセージを表示、引数: [n](デフォルト 10)", + LangSpanish: "Mostrar últimos n mensajes, arg: [n] (por defecto 10)", + }, + MsgBuiltinCmdProvider: { + LangEnglish: "Manage API providers, arg: [list|add|remove|switch|clear]", + LangChinese: "管理 API Provider,参数: [list|add|remove|switch|clear]", + LangTraditionalChinese: "管理 API Provider,參數: [list|add|remove|switch|clear]", + LangJapanese: "API プロバイダ管理、引数: [list|add|remove|switch|clear]", + LangSpanish: "Gestionar proveedores API, arg: [list|add|remove|switch|clear]", + }, + MsgBuiltinCmdMemory: { + LangEnglish: "View/edit agent memory files, arg: [add|global|global add]", + LangChinese: "查看/编辑 Agent 记忆文件,参数: [add|global|global add]", + LangTraditionalChinese: "查看/編輯 Agent 記憶檔案,參數: [add|global|global add]", + LangJapanese: "エージェントメモリの表示/編集、引数: [add|global|global add]", + LangSpanish: "Ver/editar archivos de memoria del agente, arg: [add|global|global add]", + }, + MsgBuiltinCmdAllow: { + LangEnglish: "Pre-allow a tool (next session), arg: ", + LangChinese: "预授权工具(下次会话生效),参数: <工具名>", + LangTraditionalChinese: "預授權工具(下次會話生效),參數: <工具名>", + LangJapanese: "ツールを事前許可(次のセッションで有効)、引数: <ツール>", + LangSpanish: "Pre-autorizar herramienta (próxima sesión), arg: ", + }, + MsgBuiltinCmdModel: { + LangEnglish: "View/switch model, arg: [name]", + LangChinese: "查看/切换模型,参数: [名称]", + LangTraditionalChinese: "查看/切換模型,參數: [名稱]", + LangJapanese: "モデルの表示/切り替え、引数: [名前]", + LangSpanish: "Ver/cambiar modelo, arg: [nombre]", + }, + MsgBuiltinCmdMode: { + LangEnglish: "View/switch permission mode, arg: [name]", + LangChinese: "查看/切换权限模式,参数: [名称]", + LangTraditionalChinese: "查看/切換權限模式,參數: [名稱]", + LangJapanese: "権限モードの表示/切り替え、引数: [名前]", + LangSpanish: "Ver/cambiar modo de permisos, arg: [nombre]", + }, + MsgBuiltinCmdLang: { + LangEnglish: "View/switch language, arg: [en|zh|zh-TW|ja|es|auto]", + LangChinese: "查看/切换语言,参数: [en|zh|zh-TW|ja|es|auto]", + LangTraditionalChinese: "查看/切換語言,參數: [en|zh|zh-TW|ja|es|auto]", + LangJapanese: "言語の表示/切り替え、引数: [en|zh|zh-TW|ja|es|auto]", + LangSpanish: "Ver/cambiar idioma, arg: [en|zh|zh-TW|ja|es|auto]", + }, + MsgBuiltinCmdQuiet: { + LangEnglish: "Toggle thinking/tool progress", + LangChinese: "开关思考和工具进度消息", + LangTraditionalChinese: "開關思考和工具進度訊息", + LangJapanese: "思考/ツール進捗メッセージの表示切替", + LangSpanish: "Alternar mensajes de progreso", + }, + MsgBuiltinCmdCompress: { + LangEnglish: "Compress conversation context", + LangChinese: "压缩会话上下文", + LangTraditionalChinese: "壓縮會話上下文", + LangJapanese: "会話コンテキストを圧縮", + LangSpanish: "Comprimir contexto de conversación", + }, + MsgBuiltinCmdStop: { + LangEnglish: "Stop current execution", + LangChinese: "停止当前执行", + LangTraditionalChinese: "停止當前執行", + LangJapanese: "現在の実行を停止", + LangSpanish: "Detener ejecución actual", + }, + MsgBuiltinCmdCron: { + LangEnglish: "Manage scheduled tasks, arg: [add|list|del|enable|disable]", + LangChinese: "管理定时任务,参数: [add|list|del|enable|disable]", + LangTraditionalChinese: "管理定時任務,參數: [add|list|del|enable|disable]", + LangJapanese: "スケジュールタスク管理、引数: [add|list|del|enable|disable]", + LangSpanish: "Gestionar tareas programadas, arg: [add|list|del|enable|disable]", + }, + MsgBuiltinCmdCommands: { + LangEnglish: "Manage custom slash commands, arg: [add|del]", + LangChinese: "管理自定义命令,参数: [add|del]", + LangTraditionalChinese: "管理自訂命令,參數: [add|del]", + LangJapanese: "カスタムコマンド管理、引数: [add|del]", + LangSpanish: "Gestionar comandos personalizados, arg: [add|del]", + }, + MsgBuiltinCmdAlias: { + LangEnglish: "Manage command aliases, arg: [add|del]", + LangChinese: "管理命令别名,参数: [add|del]", + LangTraditionalChinese: "管理命令別名,參數: [add|del]", + LangJapanese: "コマンドエイリアス管理、引数: [add|del]", + LangSpanish: "Gestionar alias de comandos, arg: [add|del]", + }, + MsgBuiltinCmdSkills: { + LangEnglish: "List agent skills (from SKILL.md)", + LangChinese: "列出 Agent Skills(来自 SKILL.md)", + LangTraditionalChinese: "列出 Agent Skills(來自 SKILL.md)", + LangJapanese: "エージェントスキル一覧(SKILL.md から)", + LangSpanish: "Listar skills del agente (desde SKILL.md)", + }, + MsgBuiltinCmdConfig: { + LangEnglish: "View/update runtime configuration, arg: [get|set|reload] [key] [value]", + LangChinese: "查看/修改运行时配置,参数: [get|set|reload] [键] [值]", + LangTraditionalChinese: "查看/修改執行階段配置,參數: [get|set|reload] [鍵] [值]", + LangJapanese: "ランタイム設定の表示/変更、引数: [get|set|reload] [キー] [値]", + LangSpanish: "Ver/actualizar configuración en tiempo de ejecución, arg: [get|set|reload] [clave] [valor]", + }, + MsgBuiltinCmdDoctor: { + LangEnglish: "Run system diagnostics", + LangChinese: "运行系统诊断", + LangTraditionalChinese: "執行系統診斷", + LangJapanese: "システム診断を実行", + LangSpanish: "Ejecutar diagnósticos del sistema", + }, + MsgBuiltinCmdUpgrade: { + LangEnglish: "Check for updates and self-update", + LangChinese: "检查更新并自动升级", + LangTraditionalChinese: "檢查更新並自動升級", + LangJapanese: "アップデートを確認して自動更新", + LangSpanish: "Buscar actualizaciones y auto-actualizar", + }, + MsgBuiltinCmdRestart: { + LangEnglish: "Restart cc-connect service", + LangChinese: "重启 cc-connect 服务", + LangTraditionalChinese: "重啟 cc-connect 服務", + LangJapanese: "cc-connect サービスを再起動", + LangSpanish: "Reiniciar el servicio cc-connect", + }, + MsgBuiltinCmdStatus: { + LangEnglish: "Show system status", + LangChinese: "查看系统状态", + LangTraditionalChinese: "查看系統狀態", + LangJapanese: "システム状態を表示", + LangSpanish: "Mostrar estado del sistema", + }, + MsgBuiltinCmdVersion: { + LangEnglish: "Show cc-connect version", + LangChinese: "查看 cc-connect 版本", + LangTraditionalChinese: "查看 cc-connect 版本", + LangJapanese: "cc-connect のバージョンを表示", + LangSpanish: "Mostrar versión de cc-connect", + }, + MsgBuiltinCmdHelp: { + LangEnglish: "Show this help", + LangChinese: "显示此帮助", + LangTraditionalChinese: "顯示此說明", + LangJapanese: "このヘルプを表示", + LangSpanish: "Mostrar esta ayuda", + }, + MsgBuiltinCmdBind: { + LangEnglish: "Bind current session to a target, arg: ", + LangChinese: "绑定当前会话到目标,参数: <目标>", + LangTraditionalChinese: "綁定當前會話到目標,參數: <目標>", + LangJapanese: "現在のセッションをターゲットにバインド、引数: <ターゲット>", + LangSpanish: "Vincular sesión actual a un objetivo, arg: ", + }, } func (i *I18n) T(key MsgKey) string { diff --git a/core/interfaces.go b/core/interfaces.go index 051c5a2..3edc059 100644 --- a/core/interfaces.go +++ b/core/interfaces.go @@ -229,3 +229,15 @@ type PermissionModeInfo struct { Desc string DescZh string } + +// BotCommandInfo represents a command for bot menu registration (e.g. Telegram setMyCommands). +type BotCommandInfo struct { + Command string // command name without leading "/" + Description string // short description for the menu +} + +// CommandRegistrar is an optional interface for platforms that support +// registering commands to the platform's native menu (e.g. Telegram's setMyCommands). +type CommandRegistrar interface { + RegisterCommands(commands []BotCommandInfo) error +} diff --git a/platform/telegram/telegram.go b/platform/telegram/telegram.go index 5742fdc..faa5c56 100644 --- a/platform/telegram/telegram.go +++ b/platform/telegram/telegram.go @@ -95,21 +95,21 @@ func (p *Platform) Start(handler core.MessageHandler) error { select { case <-ctx.Done(): return - case update, ok := <-updates: - if !ok { - return - } - // Handle inline keyboard button clicks - if update.CallbackQuery != nil { - p.handleCallbackQuery(update.CallbackQuery) - continue - } + case update, ok := <-updates: + if !ok { + return + } + // Handle inline keyboard button clicks + if update.CallbackQuery != nil { + p.handleCallbackQuery(update.CallbackQuery) + continue + } - if update.Message == nil { - continue - } + if update.Message == nil { + continue + } - msg := update.Message + msg := update.Message msgTime := time.Unix(int64(msg.Date), 0) if core.IsOldMessage(msgTime) { slog.Debug("telegram: ignoring old message after restart", "date", msgTime) @@ -128,13 +128,13 @@ func (p *Platform) Start(handler core.MessageHandler) error { isGroup := msg.Chat.Type == "group" || msg.Chat.Type == "supergroup" - // In group chats, filter messages not directed at this bot (unless group_reply_all) - if isGroup && !p.groupReplyAll { - slog.Debug("telegram: checking group message", "bot", p.bot.Self.UserName, "text", msg.Text, "is_command", msg.IsCommand()) - if !p.isDirectedAtBot(msg) { - continue + // In group chats, filter messages not directed at this bot (unless group_reply_all) + if isGroup && !p.groupReplyAll { + slog.Debug("telegram: checking group message", "bot", p.bot.Self.UserName, "text", msg.Text, "is_command", msg.IsCommand()) + if !p.isDirectedAtBot(msg) { + continue + } } - } rctx := replyContext{chatID: msg.Chat.ID, messageID: msg.MessageID} @@ -629,3 +629,71 @@ func (p *Platform) Stop() error { } return nil } + +// RegisterCommands registers bot commands with Telegram for the command menu. +func (p *Platform) RegisterCommands(commands []core.BotCommandInfo) error { + if p.bot == nil { + return fmt.Errorf("telegram: bot not initialized") + } + + // Telegram limits: max 100 commands, description max 256 chars + var tgCommands []tgbotapi.BotCommand + for _, c := range commands { + if !isValidTelegramCommand(c.Command) { + slog.Warn("telegram: invalid command, skipping", + slog.String("command", c.Command), + slog.String("description", c.Description)) + continue + } + desc := c.Description + if len(desc) > 256 { + desc = desc[:253] + "..." + } + tgCommands = append(tgCommands, tgbotapi.BotCommand{ + Command: c.Command, + Description: desc, + }) + } + + // Limit to 100 commands + if len(tgCommands) > 100 { + tgCommands = tgCommands[:100] + } + + if len(tgCommands) == 0 { + slog.Debug("telegram: no commands to register") + return nil + } + + cfg := tgbotapi.NewSetMyCommands(tgCommands...) + _, err := p.bot.Request(cfg) + if err != nil { + return fmt.Errorf("telegram: setMyCommands failed: %w", err) + } + + slog.Info("telegram: registered bot commands", "count", len(tgCommands)) + return nil +} + +// isValidTelegramCommand validates if a command string meets Telegram's requirements. +// Telegram command rules: +// - 1-32 characters long +// - Only lowercase letters, digits, and underscores +// - Must start with a letter +func isValidTelegramCommand(cmd string) bool { + if len(cmd) == 0 || len(cmd) > 32 { + return false + } + // Must start with a letter + if cmd[0] < 'a' || cmd[0] > 'z' { + return false + } + // Rest can be letters, digits, or underscores + for i := 1; i < len(cmd); i++ { + c := cmd[i] + if !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_') { + return false + } + } + return true +}