diff --git a/README.md b/README.md index 8e754f3..870f0c3 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ internal/modules/ Module framework, registry, dispatchers, modules internal/storage/ typed DocStore[T] (Provider + Typed); mongodb runtime + memory (tests). Values persist as flattened native BSON root documents internal/ai/ Gemini client (used by twentyq) compose.yml Coolify self-host stack (single bot service) -telegram-commands.json Telegram command menu source +telegram-commands.json Manual Telegram command menu source docs/deploy-coolify-selfhosted.md Self-host deploy and operations guide ``` diff --git a/cmd/server/command_menu.go b/cmd/server/command_menu.go new file mode 100644 index 0000000..008707b --- /dev/null +++ b/cmd/server/command_menu.go @@ -0,0 +1,53 @@ +package main + +import ( + "context" + "errors" + "time" + + "github.com/go-telegram/bot" + "github.com/go-telegram/bot/models" + + "github.com/tiennm99/miti99bot/internal/modules" +) + +const commandMenuTimeout = 8 * time.Second + +// botCommandMenu builds the Telegram command menu from the loaded public +// commands. It intentionally follows registry module order so MODULES controls +// both enabled commands and their menu grouping. +func botCommandMenu(reg *modules.Registry) []models.BotCommand { + if reg == nil { + return nil + } + var out []models.BotCommand + for _, mod := range reg.Modules { + for _, cmd := range mod.Commands { + if cmd.Visibility != modules.VisibilityPublic { + continue + } + out = append(out, models.BotCommand{ + Command: cmd.Name, + Description: cmd.Description, + }) + } + } + return out +} + +// registerCommandMenu publishes the menu to Telegram. It is best-effort at +// startup: callers should log failures and keep the bot running. +func registerCommandMenu(ctx context.Context, b *bot.Bot, reg *modules.Registry) (int, error) { + cmds := botCommandMenu(reg) + menuCtx, cancel := context.WithTimeout(ctx, commandMenuTimeout) + defer cancel() + + ok, err := b.SetMyCommands(menuCtx, &bot.SetMyCommandsParams{Commands: cmds}) + if err != nil { + return len(cmds), err + } + if !ok { + return len(cmds), errors.New("telegram setMyCommands returned false") + } + return len(cmds), nil +} diff --git a/cmd/server/command_menu_test.go b/cmd/server/command_menu_test.go new file mode 100644 index 0000000..0bac296 --- /dev/null +++ b/cmd/server/command_menu_test.go @@ -0,0 +1,81 @@ +package main + +import ( + "context" + "encoding/json" + "testing" + + "github.com/go-telegram/bot/models" + + "github.com/tiennm99/miti99bot/internal/modules" + "github.com/tiennm99/miti99bot/internal/testutil" +) + +func TestBotCommandMenu_UsesLoadedPublicCommandsInModuleOrder(t *testing.T) { + reg := &modules.Registry{ + Modules: []modules.Module{ + { + Name: "beta", + Commands: []modules.Command{ + {Name: "beta_public", Description: "Beta public", Visibility: modules.VisibilityPublic}, + {Name: "beta_private", Description: "Beta private", Visibility: modules.VisibilityPrivate}, + }, + }, + { + Name: "alpha", + Commands: []modules.Command{ + {Name: "alpha_public", Description: "Alpha public", Visibility: modules.VisibilityPublic}, + {Name: "alpha_protected", Description: "Alpha protected", Visibility: modules.VisibilityProtected}, + }, + }, + }, + } + + got := botCommandMenu(reg) + want := []models.BotCommand{ + {Command: "beta_public", Description: "Beta public"}, + {Command: "alpha_public", Description: "Alpha public"}, + } + if len(got) != len(want) { + t.Fatalf("commands = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("commands[%d] = %+v, want %+v", i, got[i], want[i]) + } + } +} + +func TestRegisterCommandMenu_CallsTelegramSetMyCommands(t *testing.T) { + reg := &modules.Registry{ + Modules: []modules.Module{{ + Name: "demo", + Commands: []modules.Command{{ + Name: "demo", + Description: "Demo command", + Visibility: modules.VisibilityPublic, + }}, + }}, + } + rb := testutil.NewRecordingBot(t) + + n, err := registerCommandMenu(context.Background(), rb.Bot, reg) + if err != nil { + t.Fatalf("registerCommandMenu: %v", err) + } + if n != 1 { + t.Fatalf("registered count = %d, want 1", n) + } + + call := rb.LastSent() + if call.Method != "setMyCommands" { + t.Fatalf("method = %q, want setMyCommands", call.Method) + } + var cmds []models.BotCommand + if err := json.Unmarshal([]byte(call.Form["commands"]), &cmds); err != nil { + t.Fatalf("decode commands form field: %v; raw=%q", err, call.Form["commands"]) + } + if len(cmds) != 1 || cmds[0].Command != "demo" || cmds[0].Description != "Demo command" { + t.Fatalf("commands payload = %+v, want demo command", cmds) + } +} diff --git a/cmd/server/main.go b/cmd/server/main.go index 31f1d73..74d2197 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -135,6 +135,11 @@ func main() { "modules", len(reg.Modules), "commands", len(reg.AllCommands), "crons", len(reg.Crons())) + if n, err := registerCommandMenu(rootCtx, b, reg); err != nil { + log.Warn("telegram command menu registration failed", "commands", n, "err", err) + } else { + log.Info("telegram command menu registered", "commands", n) + } // In-process cron scheduler runs unconditionally so the long-lived container // fires module crons (e.g. the lolschedule daily push) on their Schedule. diff --git a/docs/deploy-coolify-selfhosted.md b/docs/deploy-coolify-selfhosted.md index 21a335c..b307b32 100644 --- a/docs/deploy-coolify-selfhosted.md +++ b/docs/deploy-coolify-selfhosted.md @@ -97,9 +97,10 @@ Copy [`.env.example`](../.env.example) → `.env` (gitignored) and fill in. auto-reconnects on the next op); a DB outage will not auto-restart the container — accepted trade-off. -## 3. Register the command menu +## 3. Command menu -Long polling needs no webhook registration — only the command menu: +The bot registers its Telegram command menu from loaded public modules on +startup. The manual target remains useful for repairs or local experiments: ```sh TELEGRAM_BOT_TOKEN=… make telegram-commands