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
This commit is contained in:
Rayquaza committed 2026-03-08 01:12:57 +08:00
1 parent 1e61269629
commit 4e5d760bd2
4 files changed
+414 -22

No files matched your search

+78
View File
@@ -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 {
+237 -3
View File
@@ -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 <id> でセッションを切り替え。",
LangSpanish: "Usa /switch <id> 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: <keyword>",
LangChinese: "搜索会话名称或 ID,参数: <关键词>",
LangTraditionalChinese: "搜尋會話名稱或 ID,參數: <關鍵詞>",
LangJapanese: "セッションを名前またはIDで検索、引数: <キーワード>",
LangSpanish: "Buscar sesiones por nombre o ID, arg: <palabra_clave>",
},
MsgBuiltinCmdSwitch: {
LangEnglish: "Resume a session by its list number, arg: <number>",
LangChinese: "按列表序号切换会话,参数: <序号>",
LangTraditionalChinese: "按列表序號切換會話,參數: <序號>",
LangJapanese: "リスト番号でセッションを切り替え、引数: <番号>",
LangSpanish: "Reanudar sesión por su número en la lista, arg: <número>",
},
MsgBuiltinCmdDelete: {
LangEnglish: "Delete a session by its list number, arg: <number>",
LangChinese: "按列表序号删除会话,参数: <序号>",
LangTraditionalChinese: "按列表序號刪除會話,參數: <序號>",
LangJapanese: "リスト番号でセッションを削除、引数: <番号>",
LangSpanish: "Eliminar sesión por su número en la lista, arg: <número>",
},
MsgBuiltinCmdName: {
LangEnglish: "Name a session for easy identification, arg: [number] <text>",
LangChinese: "给会话命名,方便识别,参数: [序号] <名称>",
LangTraditionalChinese: "為會話命名,方便辨識,參數: [序號] <名稱>",
LangJapanese: "セッションに名前を付ける、引数: [番号] <名前>",
LangSpanish: "Nombrar una sesión para fácil identificación, arg: [número] <texto>",
},
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: <tool>",
LangChinese: "预授权工具(下次会话生效),参数: <工具名>",
LangTraditionalChinese: "預授權工具(下次會話生效),參數: <工具名>",
LangJapanese: "ツールを事前許可(次のセッションで有効)、引数: <ツール>",
LangSpanish: "Pre-autorizar herramienta (próxima sesión), arg: <herramienta>",
},
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: <target>",
LangChinese: "绑定当前会话到目标,参数: <目标>",
LangTraditionalChinese: "綁定當前會話到目標,參數: <目標>",
LangJapanese: "現在のセッションをターゲットにバインド、引数: <ターゲット>",
LangSpanish: "Vincular sesión actual a un objetivo, arg: <objetivo>",
},
}
func (i *I18n) T(key MsgKey) string {
+12
View File
@@ -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
}
+87 -19
View File
@@ -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
}