feat(server): register command menu on startup

This commit is contained in:
2026-06-29 01:08:51 +07:00
parent 5077ff0181
commit c3bbbd01fe
5 changed files with 143 additions and 3 deletions
+53
View File
@@ -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
}
+81
View File
@@ -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)
}
}
+5
View File
@@ -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.