diff --git a/telegram.go b/telegram.go index 459d0b9..9a5a290 100644 --- a/telegram.go +++ b/telegram.go @@ -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 diff --git a/telegram_test.go b/telegram_test.go index 6dd26ac..bd3a42a 100644 --- a/telegram_test.go +++ b/telegram_test.go @@ -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()