diff --git a/internal/modules/dispatcher.go b/internal/modules/dispatcher.go index e881581..c2f74a9 100644 --- a/internal/modules/dispatcher.go +++ b/internal/modules/dispatcher.go @@ -61,7 +61,8 @@ func (a Auth) Permits(v Visibility, update *models.Update) bool { // bot.MatchTypeCommand because the library compares the full bot_command // entity bytes for equality. In groups, Telegram clients send /cmd@botname, // so the entity bytes are "cmd@botname" — never equal to the registered -// command name "cmd". The matcher below strips the @suffix before comparing. +// command name "cmd". The matcher below strips the @suffix before comparing, +// and ignores a command whose suffix names another bot. // // auth gates Protected/Private commands; pass a zero-value Auth to deny all // Protected/Private commands (the right answer for a misconfigured deploy). @@ -71,7 +72,7 @@ func Install(b *bot.Bot, reg *Registry, auth Auth) { nameCopy := name b.RegisterHandlerMatchFunc( func(update *models.Update) bool { - return matchCommand(nameCopy, update) + return matchCommand(nameCopy, reg.botUsername, update) }, func(ctx context.Context, b *bot.Bot, update *models.Update) { defer recoverHandler("command", cmdCopy.Name, nil) @@ -130,9 +131,9 @@ func Install(b *bot.Bot, reg *Registry, auth Auth) { if fb := reg.Fallback(); fb != nil { fbCopy := *fb b.RegisterHandlerMatchFunc( - func(update *models.Update) bool { return commandName(update) != "" }, + func(update *models.Update) bool { return commandName(reg.botUsername, update) != "" }, func(ctx context.Context, b *bot.Bot, update *models.Update) { - name := commandName(update) + name := commandName(reg.botUsername, update) defer recoverHandler("fallback", name, nil) if !auth.Permits(fbCopy.Visibility, update) { return // silent — same rule the command path follows @@ -170,10 +171,9 @@ func Install(b *bot.Bot, reg *Registry, auth Auth) { // commandName returns the normalised bot_command in update, or "" when the // update carries none. // -// Shares matchCommand's parsing rules — leading slash dropped, @botname suffix -// stripped, case folded — so a fallback sees exactly the name a registered -// command would have matched on. -func commandName(update *models.Update) string { +// Shares matchCommand's parsing rules (see commandToken) so a fallback sees +// exactly the name a registered command would have matched on. +func commandName(botUsername string, update *models.Update) string { if update == nil || update.Message == nil { return "" } @@ -186,15 +186,32 @@ func commandName(update *models.Update) string { if e.Offset < 0 || end > len(text) || e.Length < 1 { continue } - tok := text[e.Offset+1 : end] - if i := strings.IndexByte(tok, '@'); i >= 0 { - tok = tok[:i] + if tok, ok := commandToken(text[e.Offset+1:end], botUsername); ok { + return strings.ToLower(tok) } - return strings.ToLower(tok) } return "" } +// commandToken strips the @botname suffix from a bot_command entity body (the +// text after the leading '/'). ok is false when the suffix names another bot. +// +// Telegram delivers /cmd@otherbot to every bot that sees all group messages — +// privacy mode off, or admin rights — so the suffix must be checked against +// our own username. Usernames are case-insensitive. An empty botUsername +// (startup getMe failed) accepts any suffix: refusing every /cmd@botname would +// break the bot in groups, which is worse than answering a stray command. +func commandToken(tok, botUsername string) (string, bool) { + i := strings.IndexByte(tok, '@') + if i < 0 { + return tok, true + } + if botUsername != "" && !strings.EqualFold(tok[i+1:], botUsername) { + return "", false + } + return tok[:i], true +} + // recoverHandler contains a panic raised by a module handler. The bot runs with // bot.WithNotAsyncHandlers() and a single worker, so the handler executes inline // on the polling goroutine: without this barrier one panicking handler ends the @@ -258,15 +275,14 @@ func logCommand(name string, update *models.Update, err error) { // library's HandlerTypeMessageText + MatchTypeCommand semantics but tolerates // the group-form /cmd@botname that the library rejects. // -// Telegram routes /cmd@otherbot only to otherbot, so an @suffix present in -// the entity addresses *this* bot — no need to verify against our username. +// A /cmd@otherbot addressed to another bot never matches (see commandToken). // // Matching is case-insensitive so /PING and /Ping reach the same handler as // /ping — mobile keyboards autocapitalize, and a typed command that silently // does nothing reads as the bot being broken. Registered names are lowercase by // validateCommand, so folding case can never make two commands collide, and the // canonical Command.Name still drives stats, metrics, hooks, and logs. -func matchCommand(name string, update *models.Update) bool { +func matchCommand(name, botUsername string, update *models.Update) bool { if update == nil || update.Message == nil { return false } @@ -283,11 +299,8 @@ func matchCommand(name string, update *models.Update) bool { if e.Offset < 0 || end > len(text) || e.Length < 1 { continue } - tok := text[e.Offset+1 : end] // drop leading '/' - if i := strings.IndexByte(tok, '@'); i >= 0 { - tok = tok[:i] - } - if strings.EqualFold(tok, name) { + tok, ok := commandToken(text[e.Offset+1:end], botUsername) // drop leading '/' + if ok && strings.EqualFold(tok, name) { return true } } diff --git a/internal/modules/dispatcher_test.go b/internal/modules/dispatcher_test.go index 298eaf9..e4dc7e0 100644 --- a/internal/modules/dispatcher_test.go +++ b/internal/modules/dispatcher_test.go @@ -75,6 +75,7 @@ func TestMatchCommand(t *testing.T) { cases := []struct { name string want string + bot string // own username; "" defaults to tiennm99bot, "-" means unknown update *models.Update expect bool }{ @@ -89,13 +90,13 @@ func TestMatchCommand(t *testing.T) { // the entity. The upstream library's MatchTypeCommand misses this. name: "group slash-help-at-botname", want: "help", - update: mkUpdate("/help@tiennm99bot", cmd(0, 15)), + update: mkUpdate("/help@tiennm99bot", cmd(0, 17)), expect: true, }, { name: "group slash-help-at-botname with trailing arg", want: "help", - update: mkUpdate("/help@tiennm99bot arg", cmd(0, 15)), + update: mkUpdate("/help@tiennm99bot arg", cmd(0, 17)), expect: true, }, { @@ -107,7 +108,7 @@ func TestMatchCommand(t *testing.T) { { name: "different command with botname no match", want: "help", - update: mkUpdate("/info@tiennm99bot", cmd(0, 15)), + update: mkUpdate("/info@tiennm99bot", cmd(0, 17)), expect: false, }, { @@ -137,7 +138,7 @@ func TestMatchCommand(t *testing.T) { { name: "uppercase command with botname matches", want: "help", - update: mkUpdate("/HELP@tiennm99bot", cmd(0, 15)), + update: mkUpdate("/HELP@tiennm99bot", cmd(0, 17)), expect: true, }, { @@ -146,6 +147,25 @@ func TestMatchCommand(t *testing.T) { update: mkUpdate("/INFO", cmd(0, 5)), expect: false, }, + { + name: "command addressed to another bot ignored", + want: "help", + update: mkUpdate("/help@otherbot", cmd(0, 14)), + expect: false, + }, + { + name: "botname suffix is case-insensitive", + want: "help", + update: mkUpdate("/help@TienNM99Bot", cmd(0, 17)), + expect: true, + }, + { + name: "unknown own username accepts any suffix", + want: "help", + bot: "-", + update: mkUpdate("/help@otherbot", cmd(0, 14)), + expect: true, + }, { name: "nil update", want: "help", @@ -179,7 +199,14 @@ func TestMatchCommand(t *testing.T) { } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - got := matchCommand(tc.want, tc.update) + botUsername := tc.bot + switch botUsername { + case "": + botUsername = "tiennm99bot" + case "-": + botUsername = "" + } + got := matchCommand(tc.want, botUsername, tc.update) if got != tc.expect { t.Errorf("matchCommand(%q, ...) = %v, want %v", tc.want, got, tc.expect) } diff --git a/internal/modules/registry.go b/internal/modules/registry.go index 9243fd1..b1ad50a 100644 --- a/internal/modules/registry.go +++ b/internal/modules/registry.go @@ -41,6 +41,7 @@ type Registry struct { commandHooks []func(ctx context.Context, name string, update *models.Update) fallback *CommandFallback // at most one; owner tracked in Build inline *InlineQuery // at most one; owner tracked in Build + botUsername string // without "@"; empty when startup could not learn it } // Fallback returns the single command fallback, or nil when no module declares @@ -196,6 +197,7 @@ func Build(enabled []string, factories map[string]Factory, provider storage.Prov crons: map[string]Cron{}, cronDeps: map[string]Deps{}, callbacks: map[string]Callback{}, + botUsername: opts.BotUsername, } owners := map[string]string{} // command name → module that registered it