fix(dispatcher): ignore commands addressed to another bot

The matcher stripped any @suffix without checking it, so in groups where
the bot sees every message (privacy mode off, or admin rights) it answered
/cmd@otherbot as if it were /cmd. The suffix must now name this bot,
compared case-insensitively. When the username is unknown because startup
getMe failed, any suffix is still accepted so group commands keep working.
This commit is contained in:
tiennm99 committed 2026-10-07 16:58:47 +07:00
1 parent cc9080c72e
commit 5344111787
3 files changed
+67 -25

No files matched your search

+33 -20
View File
@@ -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
}
}
+32 -5
View File
@@ -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)
}
+2
View File
@@ -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