fix: match bot commands addressed with @botusername in groups

In group chats Telegram appends the bot addressee to the bot_command
entity (/sync@hs_dyzur_bot), and the library's MatchTypeCommand compares
the whole token verbatim, so commands never matched. Replace it with
custom MatchFuncs that read the bot_command entity span and strip any
@suffix before comparing the command name.
This commit is contained in:
Kamil 'Kamaś' Bruchal 2026-06-19 02:48:29 +02:00
parent 816aa45c86
commit 825331dce5
2 changed files with 84 additions and 8 deletions

View File

@ -14,9 +14,8 @@ import (
// Command names, the single source of truth shared by the handler registration
// and the published command list. Underscore, not hyphen: Telegram command
// names are [a-zA-Z0-9_] only, and MatchTypeCommand matches the parsed
// bot_command entity, which would stop at a hyphen (/kto-sprzata -> entity
// "/kto") and never match.
// names are [a-zA-Z0-9_] only, and the bot_command entity we match against stops
// at a hyphen (/kto-sprzata -> entity "/kto"), so a hyphen would never match.
const (
cmdKtoSprzata = "kto_sprzata"
cmdSync = "sync"
@ -35,12 +34,48 @@ var botCommands = []models.BotCommand{
// than silently 401-ing inside the poll loop. Long polling starts on Start(ctx).
func NewBot(token string, svc *Service) (*bot.Bot, error) {
h := &botHandlers{svc: svc, now: time.Now}
opts := []bot.Option{
bot.WithDefaultHandler(h.unknown),
bot.WithMessageTextHandler(cmdKtoSprzata, bot.MatchTypeCommand, h.ktoSprzata),
bot.WithMessageTextHandler(cmdSync, bot.MatchTypeCommand, h.sync),
b, err := bot.New(token, bot.WithDefaultHandler(h.unknown))
if err != nil {
return nil, err
}
return bot.New(token, opts...)
// Custom match funcs instead of bot.MatchTypeCommand: in group chats Telegram
// appends the addressee (/sync@hs_dyzur_bot) inside the bot_command entity,
// and the library's MatchTypeCommand compares the whole token verbatim, so it
// never matches. matchCommandEntity strips the @suffix first.
b.RegisterHandlerMatchFunc(h.matchCommand(cmdKtoSprzata), h.ktoSprzata)
b.RegisterHandlerMatchFunc(h.matchCommand(cmdSync), h.sync)
return b, nil
}
// matchCommand builds a MatchFunc that fires when the message leads with the
// given bot command, tolerant of the @botusername suffix Telegram adds in groups.
func (h *botHandlers) matchCommand(cmd string) bot.MatchFunc {
return func(update *models.Update) bool {
return update.Message != nil &&
matchCommandEntity(update.Message.Text, update.Message.Entities, cmd)
}
}
// matchCommandEntity reports whether text carries a bot_command entity whose
// name equals cmd. The name is taken from the entity span (dropping the leading
// '/') with any @botusername suffix removed — Telegram includes that suffix in
// the entity for group messages, but a bot still owns the bare command. Note: a
// command explicitly addressed to another bot (/sync@otherbot) also matches;
// acceptable for a single-bot group, the only place this bot runs.
func matchCommandEntity(text string, entities []models.MessageEntity, cmd string) bool {
for _, e := range entities {
if e.Type != models.MessageEntityTypeBotCommand {
continue
}
name := text[e.Offset+1 : e.Offset+e.Length]
if at := strings.IndexByte(name, '@'); at >= 0 {
name = name[:at]
}
if name == cmd {
return true
}
}
return false
}
// BotSendFunc builds a SendFunc that posts to a fixed chat via the bot, so the

View File

@ -3,8 +3,49 @@ package dyzurbot
import (
"testing"
"time"
"github.com/go-telegram/bot/models"
)
// cmdEntity builds the bot_command entity Telegram attaches to a leading
// command, spanning the whole token (slash, name, and any @addressee).
func cmdEntity(text string) []models.MessageEntity {
return []models.MessageEntity{{
Type: models.MessageEntityTypeBotCommand,
Offset: 0,
Length: len(text),
}}
}
func TestMatchCommandEntity(t *testing.T) {
tests := []struct {
name string
text string
cmd string
want bool
}{
{"bare command (private chat)", "/sync", cmdSync, true},
{"command with @botusername (group)", "/sync@hs_dyzur_bot", cmdSync, true},
{"kto_sprzata with @botusername", "/kto_sprzata@hs_dyzur_bot", cmdKtoSprzata, true},
{"underscore name survives @suffix", "/kto_sprzata@hs_dyzur_bot", cmdSync, false},
{"wrong command", "/sync", cmdKtoSprzata, false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := matchCommandEntity(tc.text, cmdEntity(tc.text), tc.cmd); got != tc.want {
t.Errorf("matchCommandEntity(%q, %q) = %v, want %v", tc.text, tc.cmd, got, tc.want)
}
})
}
}
// A plain message carrying no bot_command entity must not match any command.
func TestMatchCommandEntityNoEntity(t *testing.T) {
if matchCommandEntity("just chatting about /sync", nil, cmdSync) {
t.Error("text without a bot_command entity should not match")
}
}
// week is a small helper to build a DutyWeek from a YYYY-MM-DD start.
func week(t *testing.T, start, p1, p2 string) DutyWeek {
t.Helper()