mirror of
https://github.com/tiennm99/tiennm99bot.git
synced 2026-10-11 03:13:46 +00:00
Serve the BotFather game noitu from the bot's HTTP server under /games/noitu when GAME_BASE_URL is set. /noitu sends the game, Play answers with a signed, expiring link, and the server validates every word against the embedded noitu dictionary (CC BY-SA 4.0), runs the bot opponent and turn timer, and reports the score with setGameScore. Modules can now register game-short-name callbacks and HTTP routes.
354 lines
13 KiB
Go
354 lines
13 KiB
Go
package modules
|
|
|
|
import (
|
|
"context"
|
|
"runtime/debug"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-telegram/bot"
|
|
"github.com/go-telegram/bot/models"
|
|
|
|
"github.com/tiennm99/tiennm99bot/internal/log"
|
|
"github.com/tiennm99/tiennm99bot/internal/metrics"
|
|
)
|
|
|
|
// Auth gates Protected/Private commands by sender Telegram user ID. Public
|
|
// commands are always allowed. A zero BotOwnerID + empty AdminUserIDs means
|
|
// every Protected/Private command is denied — the safe default for an
|
|
// unconfigured deployment.
|
|
type Auth struct {
|
|
BotOwnerID int64 // owner is implicitly an admin; receives Private + Protected
|
|
AdminUserIDs map[int64]bool // additional users allowed to run Protected commands
|
|
}
|
|
|
|
// Permits reports whether the sender of update may run a command of visibility v.
|
|
// Denies are silent — callers must NOT reply to denied requests, otherwise the
|
|
// existence of a Protected/Private command is leaked to unprivileged users.
|
|
func (a Auth) Permits(v Visibility, update *models.Update) bool {
|
|
if v == VisibilityPublic || v == VisibilityUnlisted {
|
|
return true
|
|
}
|
|
if update == nil {
|
|
return false
|
|
}
|
|
var senderID int64
|
|
if update.Message != nil && update.Message.From != nil {
|
|
senderID = update.Message.From.ID
|
|
} else if update.CallbackQuery != nil {
|
|
senderID = update.CallbackQuery.From.ID
|
|
} else if update.InlineQuery != nil {
|
|
senderID = update.InlineQuery.From.ID
|
|
}
|
|
if senderID == 0 {
|
|
return false
|
|
}
|
|
switch v {
|
|
case VisibilityPrivate:
|
|
return a.BotOwnerID != 0 && senderID == a.BotOwnerID
|
|
case VisibilityProtected:
|
|
if a.BotOwnerID != 0 && senderID == a.BotOwnerID {
|
|
return true
|
|
}
|
|
return a.AdminUserIDs[senderID]
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Install registers every command in the registry with the Telegram bot.
|
|
//
|
|
// Uses RegisterHandlerMatchFunc with a local matcher rather than the library's
|
|
// 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,
|
|
// 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).
|
|
func Install(b *bot.Bot, reg *Registry, auth Auth) {
|
|
for name, cmd := range reg.AllCommands {
|
|
cmdCopy := cmd // capture by value for the closure
|
|
nameCopy := name
|
|
b.RegisterHandlerMatchFunc(
|
|
func(update *models.Update) bool {
|
|
return matchCommand(nameCopy, reg.botUsername, update)
|
|
},
|
|
func(ctx context.Context, b *bot.Bot, update *models.Update) {
|
|
defer recoverHandler("command", cmdCopy.Name, nil)
|
|
if !auth.Permits(cmdCopy.Visibility, update) {
|
|
return // silent — do not leak existence of gated commands
|
|
}
|
|
metrics.IncCommand(cmdCopy.Name)
|
|
// context.Background is intentional: the hook must outlive the request
|
|
// context so stats writes complete even after the handler returns.
|
|
go func() { //nolint:gosec // G118: goroutine intentionally detached from request context
|
|
// This goroutine is outside the handler's barrier above, so
|
|
// it needs its own: a panicking hook on its own goroutine
|
|
// still terminates the process.
|
|
defer recoverHandler("command hook", cmdCopy.Name, nil)
|
|
hookCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
reg.RunCommandHooks(hookCtx, cmdCopy.Name, update)
|
|
}()
|
|
err := cmdCopy.Handler(ctx, b, update)
|
|
if err != nil {
|
|
metrics.IncError("handler-error")
|
|
}
|
|
logCommand(cmdCopy.Name, update, err)
|
|
},
|
|
)
|
|
}
|
|
for prefix, callback := range reg.callbacks {
|
|
callbackCopy := callback
|
|
prefixCopy := prefix
|
|
b.RegisterHandler(bot.HandlerTypeCallbackQueryData, prefixCopy, bot.MatchTypePrefix,
|
|
func(ctx context.Context, b *bot.Bot, update *models.Update) {
|
|
defer recoverHandler("callback", prefixCopy, func() {
|
|
if update != nil && update.CallbackQuery != nil {
|
|
_, _ = b.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{CallbackQueryID: update.CallbackQuery.ID})
|
|
}
|
|
})
|
|
if !auth.Permits(callbackCopy.Visibility, update) {
|
|
if update != nil && update.CallbackQuery != nil {
|
|
_, _ = b.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{CallbackQueryID: update.CallbackQuery.ID})
|
|
}
|
|
return
|
|
}
|
|
err := callbackCopy.Handler(ctx, b, update)
|
|
if err != nil {
|
|
metrics.IncError("callback-handler-error")
|
|
log.Error("callback", "prefix", prefixCopy, "err", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
installGames(b, reg, auth)
|
|
// Registered LAST, and that placement is load-bearing: the bot library
|
|
// returns the first handler whose matcher accepts an update, so every
|
|
// Command above out-ranks the fallback. A name defined in code therefore
|
|
// always beats one resolved at runtime — including an alias that shadows a
|
|
// command added in a later build.
|
|
if fb := reg.Fallback(); fb != nil {
|
|
fbCopy := *fb
|
|
b.RegisterHandlerMatchFunc(
|
|
func(update *models.Update) bool { return commandName(reg.botUsername, update) != "" },
|
|
func(ctx context.Context, b *bot.Bot, update *models.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
|
|
}
|
|
// Deliberately not counted in metrics.IncCommand or the command
|
|
// hooks: an un-registered name is not a command, and feeding
|
|
// arbitrary user text to the stats module would let anyone mint
|
|
// unbounded metric labels.
|
|
if err := fbCopy.Handler(ctx, b, name, update); err != nil {
|
|
metrics.IncError("fallback-handler-error")
|
|
log.Error("fallback", "name", name, "err", err)
|
|
}
|
|
},
|
|
)
|
|
}
|
|
|
|
if inline := reg.Inline(); inline != nil {
|
|
inlineCopy := *inline
|
|
b.RegisterHandlerMatchFunc(
|
|
func(update *models.Update) bool { return update != nil && update.InlineQuery != nil },
|
|
func(ctx context.Context, b *bot.Bot, update *models.Update) {
|
|
defer recoverHandler("inline", "inline_query", nil)
|
|
if !auth.Permits(inlineCopy.Visibility, update) {
|
|
return
|
|
}
|
|
if err := inlineCopy.Handler(ctx, b, update); err != nil {
|
|
metrics.IncError("inline-handler-error")
|
|
log.Error("inline", "err", err)
|
|
}
|
|
},
|
|
)
|
|
}
|
|
}
|
|
|
|
// commandName returns the normalised bot_command in update, or "" when the
|
|
// update carries none.
|
|
//
|
|
// 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 ""
|
|
}
|
|
text := update.Message.Text
|
|
for _, e := range update.Message.Entities {
|
|
if e.Type != models.MessageEntityTypeBotCommand {
|
|
continue
|
|
}
|
|
end := e.Offset + e.Length
|
|
if e.Offset < 0 || end > len(text) || e.Length < 1 {
|
|
continue
|
|
}
|
|
if tok, ok := commandToken(text[e.Offset+1:end], botUsername); ok {
|
|
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
|
|
// process for every user. Mirrors the barrier the cron scheduler already puts
|
|
// around its handlers (internal/cron/scheduler.go).
|
|
//
|
|
// The panic is logged at ERROR with a full stack and counted under a distinct
|
|
// handler-panic metric, so a handler that panics on every call is loud rather
|
|
// than quietly failing per-request.
|
|
//
|
|
// onPanic, when non-nil, runs after logging — the callback path uses it to
|
|
// answer the query so the caller's client stops showing a spinner. It is itself
|
|
// guarded, because a panic raised inside the recovery path would have no
|
|
// remaining barrier.
|
|
func recoverHandler(kind, name string, onPanic func()) {
|
|
rec := recover()
|
|
if rec == nil {
|
|
return
|
|
}
|
|
metrics.IncError("handler-panic")
|
|
log.Error(kind+" panic", kind, name, "panic", rec, "stack", string(debug.Stack()))
|
|
if onPanic == nil {
|
|
return
|
|
}
|
|
defer func() { _ = recover() }()
|
|
onPanic()
|
|
}
|
|
|
|
// logCommand emits one structured line per authorized command invocation: what
|
|
// was typed (input), who sent it (user id + @username), where (DM vs group, with
|
|
// chat id and — for groups — the title), and the outcome. The result is kept
|
|
// simple — "ok" via INFO, or the handler error via ERROR — because handler
|
|
// return values can be arbitrarily complex; the error plus context is the useful
|
|
// part. msg is non-nil here: matchCommand only matches updates with a Message.
|
|
func logCommand(name string, update *models.Update, err error) {
|
|
msg := update.Message
|
|
fields := []any{
|
|
"command", name,
|
|
"input", msg.Text,
|
|
"chat_type", string(msg.Chat.Type),
|
|
"chat_id", msg.Chat.ID,
|
|
}
|
|
if msg.Chat.Title != "" {
|
|
fields = append(fields, "chat_title", msg.Chat.Title)
|
|
}
|
|
if from := msg.From; from != nil {
|
|
fields = append(fields, "user_id", from.ID)
|
|
if from.Username != "" {
|
|
fields = append(fields, "username", from.Username)
|
|
}
|
|
}
|
|
if err != nil {
|
|
log.Error("command", append(fields, "err", err)...)
|
|
return
|
|
}
|
|
log.Info("command", fields...)
|
|
}
|
|
|
|
// matchCommand reports whether update is a text message whose bot_command
|
|
// entity (after stripping any @botname suffix) equals name. Mirrors the
|
|
// library's HandlerTypeMessageText + MatchTypeCommand semantics but tolerates
|
|
// the group-form /cmd@botname that the library rejects.
|
|
//
|
|
// 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, botUsername string, update *models.Update) bool {
|
|
if update == nil || update.Message == nil {
|
|
return false
|
|
}
|
|
text := update.Message.Text
|
|
for _, e := range update.Message.Entities {
|
|
if e.Type != models.MessageEntityTypeBotCommand {
|
|
continue
|
|
}
|
|
// Bounds check: defensive against malformed entities from a future
|
|
// API revision. The library's match func omits it, and a matcher runs
|
|
// outside Install's panic barrier, so a bad entity would panic the
|
|
// polling goroutine with nothing to catch it.
|
|
end := e.Offset + e.Length
|
|
if e.Offset < 0 || end > len(text) || e.Length < 1 {
|
|
continue
|
|
}
|
|
tok, ok := commandToken(text[e.Offset+1:end], botUsername) // drop leading '/'
|
|
if ok && strings.EqualFold(tok, name) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// installGames registers one handler per BotFather game short name, then a
|
|
// catch-all for any other game callback.
|
|
//
|
|
// A Play press carries game_short_name and no callback data, so the Data-prefix
|
|
// handlers never see it. The same panic barrier, auth gate and metrics as the
|
|
// Data callbacks apply. The catch-all answers a game that is not loaded (a
|
|
// short name created in BotFather whose module is missing from MODULES) with an
|
|
// empty answer, so the client stops spinning instead of waiting forever.
|
|
func installGames(b *bot.Bot, reg *Registry, auth Auth) {
|
|
for short, game := range reg.games {
|
|
gameCopy := game
|
|
shortCopy := short
|
|
b.RegisterHandler(bot.HandlerTypeCallbackQueryGameShortName, shortCopy, bot.MatchTypeExact,
|
|
func(ctx context.Context, b *bot.Bot, update *models.Update) {
|
|
defer recoverHandler("game", shortCopy, func() { answerEmptyCallback(ctx, b, update) })
|
|
if !auth.Permits(gameCopy.Visibility, update) {
|
|
answerEmptyCallback(ctx, b, update)
|
|
return
|
|
}
|
|
if err := gameCopy.Handler(ctx, b, update); err != nil {
|
|
metrics.IncError("game-handler-error")
|
|
log.Error("game", "short_name", shortCopy, "err", err)
|
|
}
|
|
})
|
|
}
|
|
b.RegisterHandlerMatchFunc(
|
|
func(update *models.Update) bool {
|
|
return update != nil && update.CallbackQuery != nil && update.CallbackQuery.GameShortName != ""
|
|
},
|
|
func(ctx context.Context, b *bot.Bot, update *models.Update) {
|
|
defer recoverHandler("game", "unknown", nil)
|
|
answerEmptyCallback(ctx, b, update)
|
|
},
|
|
)
|
|
}
|
|
|
|
// answerEmptyCallback stops the client's spinner without showing anything.
|
|
func answerEmptyCallback(ctx context.Context, b *bot.Bot, update *models.Update) {
|
|
if update == nil || update.CallbackQuery == nil {
|
|
return
|
|
}
|
|
_, _ = b.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{CallbackQueryID: update.CallbackQuery.ID})
|
|
}
|