mirror of
https://github.com/tiennm99/tiennm99bot.git
synced 2026-10-11 03:13:46 +00:00
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:
1 parent
cc9080c72e
commit
5344111787
3 files changed
+67
-25
No files matched your search
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user