diff --git a/.gitignore b/.gitignore index 848b64a..89ab47d 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ test/ .codex/ .codex +.agents/ +.claude/ diff --git a/AGENTS.md b/AGENTS.md index 2d4ac66..e57a7cd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,7 @@ # AGENTS.md ## Purpose -This repository uses Codex for full-project Go code review, not diff-only review. +This repository uses AI coding agents for full-project Go code review, not diff-only review. When asked to review code, inspect the entire repository and use repository-wide context. Do not limit analysis to the latest commit, pull request diff, or recently changed files. diff --git a/CHANGELOG.md b/CHANGELOG.md index cc94fc7..ede2df1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,28 +3,47 @@ ## v1.0.0 ### Breaking Changes +- Renamed `MsgContext` to `MessageContext` across the public API, including handler signatures (`CommandExecutor`, `MiddlewareExecutor`, scene handler types), all reply/edit/scene helpers, embedded fields on `SceneContext`, and documentation. +- Removed the `NewPayload(...)` constructor. `NewCommand(...)` builds the underlying `Command[T]` for both `/-`commands and callback payloads; registration via `Plugin.AddPayload`/`Plugin.Payload` decides routing. +- `MessageContext.Error(...)` no longer sends unclassified errors to the user. Only errors marked with `AsUserError(...)` are surfaced through the centralized reply path; everything else stays internal-only and is logged. +- `Plugin.Close()` no longer closes a logger supplied through `Plugin.SetLogger(...)`. Only loggers created by the bot during `AddPlugins` registration are owned and closed; caller-supplied loggers remain the caller's responsibility. - Renamed final public APIs to idiomatic names before the stable release: `RunWebhookWithContext(...)`, `RunWebhook(...)`, `CloseWebhook()`, `BotWebhookOpts`, `NewBotWebhookOpts()`, `SetWebhookLogger(...)`, and `GetWebhookLogger()`. -- Renamed plugin builder helpers from `NewCommand(...)`, `NewPayload(...)`, and `NewScene(...)` to `Command(...)`, `Payload(...)`, and `Scene(...)`; `NewCommand(...)` and `NewPayload(...)` now take the command string before the executor. +- Renamed plugin builder helpers from `NewCommand(...)` and `NewScene(...)` to `Command(...)` and `Scene(...)`; the surviving `NewCommand(...)` takes the command string before the executor. - Renamed command argument value constants to `CommandValueString`, `CommandValueInt`, `CommandValueBool`, and `CommandValueAny`; `NewCommandArg(...)` now defaults to unvalidated `CommandValueAny`. - Renamed runner builders from `Onetime(...)` and `Timeout(...)` to `Once(...)` and `Every(...)`. - Renamed remaining public acronym/casing outliers including `AnswerCallback...`, `ParseMarkdownV2`, `ParseMarkdown`, `GetChatMemberCount`, `DropRateLimitOverflow`, `SetDropRateLimitOverflow`, and inline keyboard builder APIs. ### Added -- Added `MsgContext.IsCallback()` and `MsgContext.HasPhoto()` helpers for callback-aware handler code. -- Added `MsgContext.UpsertKeyboard(...)` and `MsgContext.UpsertKeyboardMarkdown(...)` helpers that edit callback messages, replace photo callback messages with a fresh chat message, and send a new chat message outside callback flow. +- Added `MessageContext.IsCallback()` and `MessageContext.HasPhoto()` helpers for callback-aware handler code. +- Added `MessageContext.UpsertKeyboard(...)` and `MessageContext.UpsertKeyboardMarkdown(...)` helpers that edit callback messages, replace photo callback messages with a fresh chat message, and send a new chat message outside callback flow. - Added `CommandGroup`, `NewCommandGroup(...)`, `Plugin.CommandGroup(...)`, and `Plugin.AddCommandGroup(...)` helpers for registering prefixed command groups with shared middleware. - Added the `tgfmt` package with typed MarkdownV2, HTML, legacy Markdown formatting helpers, and a message entity builder. -- Added `InlineKeyboardButtonBuilder.SetPayloadType(...)`, `InlineKeyboardButtonBuilder.SetCallbackData(...)`, and `MsgContext.NewInlineKeyboardButton(...)` helpers for payload-aware button building. +- Added `InlineKeyboardButtonBuilder.SetPayloadType(...)`, `InlineKeyboardButtonBuilder.SetCallbackData(...)`, and `MessageContext.NewInlineKeyboardButton(...)` helpers for payload-aware button building. - Added compact callback payload encoding through `BotPayloadCompact`, `BotPayloadCompactBase64`, compact inline keyboard builders, and matching `CallbackData` helpers. +- Added `BotOpts.PollTimeout`, `BotOpts.SetPollTimeout(...)`, and the `POLL_TIMEOUT` environment variable to configure the long-polling `getUpdates` timeout (default 30 seconds). +- Added `RateLimiter.Cleanup(idleThreshold)` to evict per-chat limiter state and expired chat cooldowns; the limiter now tracks per-chat last-seen time so long-running bots can bound memory through a periodic runner. +- Added cached bot identity (`Bot.userID`) populated at `NewBot` so chat-admin policies and similar lookups reuse it instead of issuing a fresh `GetMe` request. - Added `tgapi.ResponseError` so Telegram API error codes, descriptions, and response parameters remain inspectable through returned errors. ### Changed - Version metadata now reports the stable `v1.0.0` release instead of `v1.0.0-rc.16`. +- Compact callback payload encoding now escapes `,`, `|`, and `\` in command and arg bytes so payloads containing those bytes round-trip without ambiguity. Note: the format coalesces "no args" with "single empty arg" — both encode as `cmd|` and decode to nil args. +- `CallbackData.ToJSON()`, `ToBase64()`, `ToCompact()`, and `ToCompactBase64()` now all return an empty string on serialization failure; the previous `ToJSON()` fallback `{"cmd":""}` has been removed so encoder bugs surface visibly instead of routing to no handler. - Bot-level middleware blocks now emit a final `UpdateHandledEvent` with `Handled=false`, keeping observer update lifecycles balanced. +- Plugin registration now warns when `AddCommand`, `AddPayload`, or `AddScene` overwrites an existing entry with the same name instead of silently replacing it. - `BotOpts`, `tgapi.APIOpts`, logger utilities, README, and wiki pages now document the final stable API names and configuration options consistently. - CI now checks formatting, tests, vet, and lint on both pushes and pull requests. ### Fixed +- Fixed the update worker pool returning before in-flight handlers completed. `startUpdateWorkers` now calls `pool.StopAndWait()` so the bot waits for already-submitted tasks before runtime exit. +- Fixed `RateLimiter.getChatLimiter` upgrading a held read lock to a write lock, which could deadlock under contention. The lookup now releases the read lock before acquiring the write lock and re-checks the map. +- Fixed `RateLimiter` per-chat limiter and lock maps growing unbounded for the lifetime of long-running bots that serve many distinct chats. +- Fixed `Draft.Push` mutating `Message` before validating the candidate length, leaving the draft in a half-mutated state when the candidate would exceed Telegram's limit. The candidate is now validated first; on failure the draft remains unchanged. +- Fixed background runners running one extra iteration after context cancellation when both `ctx.Done()` and the ticker were ready in the same `select`. +- Fixed `Plugin.Close()` double-closing a logger supplied by the caller through `SetLogger(...)`. +- Fixed compact callback payload corruption for arguments containing `,` or `|` bytes. +- Fixed `LoadOptsFromEnv` calling `os.Getenv("MAX_WORKERS")` twice when parsing the worker count. +- Fixed `sceneRuntime` interface carrying a delegating `buildSceneKey` method that just forwarded to a package-level helper; `MessageContext` scene helpers now call the helper directly. - Fixed webhook startup so empty-secret warnings are logged only after the webhook logger is initialized. - Fixed webhook startup so a logger configured through `SetWebhookLogger(...)` is preserved. - Fixed long-polling 429 handling so `getUpdates` retries use Telegram `retry_after` directly and do not inflate later transient-error backoff. @@ -38,6 +57,10 @@ - Added regression coverage for context-aware inline keyboard button payload encoding. - Added regression coverage for compact and Base64-encoded compact callback payload decoding. - Added regression coverage for long-polling `retry_after` handling on Telegram 429 responses. +- Added regression coverage for compact callback payload round-tripping through `,`, `|`, and `\` separator bytes and a missing-separator decode error. +- Added regression coverage for `Draft.Push` preserving the existing message when validation rejects the candidate. +- Added regression coverage for `RateLimiter.Cleanup` evicting idle chat limiters and expired chat locks while leaving active state in place. +- Updated `MessageContext.Error` tests so unclassified errors stay internal-only and only `AsUserError` reaches the user. ## v1.0.0-rc.16 diff --git a/bot.go b/bot.go index 87d00d1..6ee6e16 100644 --- a/bot.go +++ b/bot.go @@ -36,7 +36,7 @@ type AppData any // data. // // Use Bot[NoData] to indicate no shared dependency injection is required. -type NoData struct{ AppData } +type NoData struct{} // AppDataLogger builds a sneklog.LoggerWriter from injected application data. // @@ -89,10 +89,12 @@ type Bot[T AppData] struct { token string debug bool errorTemplate string + userID int64 username string payloadType BotPayloadType strictPayloadType bool maxWorkers int + pollTimeout int // Long-polling timeout in seconds for getUpdates logFormat utils.LogFormat logFormatter *sneklog.Formatter @@ -184,12 +186,18 @@ func NewBot[T any](opts *BotOpts) (*Bot[T], error) { workers = opts.MaxWorkers } + pollTimeout := 30 + if opts.PollTimeout > 0 { + pollTimeout = opts.PollTimeout + } + bot := &Bot[T]{ updateOffset: 0, errorTemplate: "%s", payloadType: BotPayloadBase64, strictPayloadType: opts.StrictPayloadType, maxWorkers: workers, + pollTimeout: pollTimeout, updateQueue: updateQueue, api: api, uploader: uploader, @@ -239,6 +247,7 @@ func NewBot[T any](opts *BotOpts) (*Bot[T], error) { return nil, err } bot.username = Val(u.Username, "") + bot.userID = u.ID if bot.username == "" { bot.logger.Warn("Can't get bot username. Named command handlers won't work!") } diff --git a/bot_config.go b/bot_config.go index e7752ad..e5c329e 100644 --- a/bot_config.go +++ b/bot_config.go @@ -60,7 +60,7 @@ func (bot *Bot[T]) SetSessionStore(store SessionStore) *Bot[T] { return bot } if store == nil { - bot.logger.Warn("SetSessionStore called with nil store; using default MemorySessionStore") + bot.logger.Warn("SetSessionStore called with nil store; nothing changed") return bot } bot.sessionStore = store @@ -216,7 +216,7 @@ func (bot *Bot[T]) SetL10n(l *L10n) *Bot[T] { return bot } if l == nil { - bot.logger.Warn("SetL10n called with nil L10n; localization will be disabled") + bot.logger.Warn("SetL10n called with nil L10n; localization will not change") return bot } bot.l10n = l diff --git a/bot_opts.go b/bot_opts.go index 7e0c793..57676c8 100644 --- a/bot_opts.go +++ b/bot_opts.go @@ -65,6 +65,11 @@ type BotOpts struct { // MaxWorkers is the maximum number of update handlers that may run concurrently. MaxWorkers int + // PollTimeout is the long-polling timeout in seconds for getUpdates. + // Defaults to 30. Telegram allows 0..50; values outside that range are accepted + // by the bot but rejected by Telegram at runtime. + PollTimeout int + // FileConfigVersion stores the version declared by the config file used to // load these options. // @@ -94,6 +99,7 @@ type BotOpts struct { // - DROP_RL_OVERFLOW: "true" to drop updates on rate limit overflow // - STRICT_PAYLOAD_TYPE: "true" to reject callback payloads encoded in a different format // - MAX_WORKERS: maximum number of concurrent update handlers (default: 32) +// - POLL_TIMEOUT: long-polling timeout in seconds for getUpdates (default: 30) // - LOG_FORMAT: logger output format, "text" or "json" (default: "text") // // Returns a populated BotOpts. @@ -101,6 +107,7 @@ type BotOpts struct { func LoadOptsFromEnv() *BotOpts { rateLimit := 30 maxWorkers := 32 + pollTimeout := 30 stringUpdateTypes := splitEnvList(os.Getenv("UPDATE_TYPES")) updateTypes := make([]tgapi.UpdateType, 0, len(stringUpdateTypes)) @@ -115,11 +122,17 @@ func LoadOptsFromEnv() *BotOpts { } if mw := os.Getenv("MAX_WORKERS"); mw != "" { - if n, err := strconv.Atoi(os.Getenv("MAX_WORKERS")); err == nil { + if n, err := strconv.Atoi(mw); err == nil { maxWorkers = n } } + if pt := os.Getenv("POLL_TIMEOUT"); pt != "" { + if n, err := strconv.Atoi(pt); err == nil { + pollTimeout = n + } + } + return &BotOpts{ Token: os.Getenv("TG_TOKEN"), UpdateTypes: updateTypes, @@ -140,6 +153,7 @@ func LoadOptsFromEnv() *BotOpts { StrictPayloadType: os.Getenv("STRICT_PAYLOAD_TYPE") == "true", MaxWorkers: maxWorkers, + PollTimeout: pollTimeout, FileConfigVersion: 0, LogFormat: utils.LogFormat(os.Getenv("LOG_FORMAT")), } @@ -256,6 +270,13 @@ func (opts *BotOpts) SetMaxWorkers(workers int) *BotOpts { return opts } +// SetPollTimeout sets the long-polling timeout in seconds for getUpdates. +// Defaults to 30. Telegram accepts 0..50. +func (opts *BotOpts) SetPollTimeout(seconds int) *BotOpts { + opts.PollTimeout = seconds + return opts +} + // SetLogFormat sets the output format used by bot-managed loggers. func (opts *BotOpts) SetLogFormat(format utils.LogFormat) *BotOpts { opts.LogFormat = format diff --git a/bot_opts_loader.go b/bot_opts_loader.go index 4e86993..608a5a3 100644 --- a/bot_opts_loader.go +++ b/bot_opts_loader.go @@ -155,7 +155,7 @@ func SaveBotOptsFile(codec BotOptsFileCodec, filename string, opts *BotOpts) err if err != nil { return err } - err = os.WriteFile(filename, data, 0644) + err = os.WriteFile(filename, data, 0600) if err != nil { return err } diff --git a/bot_register.go b/bot_register.go index 2791d3f..6f22aa8 100644 --- a/bot_register.go +++ b/bot_register.go @@ -29,6 +29,7 @@ func (bot *Bot[T]) AddPlugins(plugin ...*Plugin[T]) *Bot[T] { cloned := clonePlugin(p) if cloned.logger == nil { cloned.logger = utils.CreateLogger(cloned.name, level, bot.logFormat, bot.logFormatter) + cloned.loggerOwned = true } bot.addTokenReplacer(cloned.logger) bot.plugins = append(bot.plugins, cloned) diff --git a/bot_scene.go b/bot_scene.go index 2fff340..db71fad 100644 --- a/bot_scene.go +++ b/bot_scene.go @@ -33,7 +33,7 @@ func (bot *Bot[T]) findScene(name string) (*sceneMeta, bool) { return nil, false } -func (bot *Bot[T]) findSceneSession(ctx *MsgContext) (string, SceneSession, error) { +func (bot *Bot[T]) findSceneSession(ctx *MessageContext) (string, SceneSession, error) { var zero SceneSession for _, scope := range bot.sceneScopePriority { @@ -53,7 +53,3 @@ func (bot *Bot[T]) findSceneSession(ctx *MsgContext) (string, SceneSession, erro return "", zero, ErrCantFindSession } - -func (bot *Bot[T]) buildSceneKey(scope SceneScope, ctx *MsgContext) (string, bool) { - return buildSceneKey(scope, ctx) -} diff --git a/bot_test.go b/bot_test.go index 7456fe4..ade1071 100644 --- a/bot_test.go +++ b/bot_test.go @@ -63,14 +63,14 @@ func TestAddPluginsSnapshotsConfiguration(t *testing.T) { bot := &Bot[NoData]{logger: sneklog.NewLogger()} plugin := NewPlugin[NoData]("demo") - cmd := plugin.Command("start", func(ctx *MsgContext, db NoData) error { return nil }) - plugin.AddMiddleware(NewMiddleware("base", func(ctx *MsgContext, db NoData) bool { return true })) + cmd := plugin.Command("start", func(ctx *MessageContext, db NoData) error { return nil }) + plugin.AddMiddleware(NewMiddleware("base", func(ctx *MessageContext, db NoData) bool { return true })) bot.AddPlugins(plugin) cmd.SetDescription("mutated after registration") - plugin.Command("late", func(ctx *MsgContext, db NoData) error { return nil }) - plugin.AddMiddleware(NewMiddleware("late", func(ctx *MsgContext, db NoData) bool { return true })) + plugin.Command("late", func(ctx *MessageContext, db NoData) error { return nil }) + plugin.AddMiddleware(NewMiddleware("late", func(ctx *MessageContext, db NoData) bool { return true })) registered := bot.plugins[0] if _, exists := registered.commands["late"]; exists { @@ -812,7 +812,7 @@ func TestAddPluginsAndRuntimeRegistrationsNoOpAfterRunStarts(t *testing.T) { bot := &Bot[NoData]{ logger: sneklog.NewLogger(), prefixes: []string{"/"}, - middlewares: []Middleware[NoData]{NewMiddleware("base", func(ctx *MsgContext, db NoData) bool { return true })}, + middlewares: []Middleware[NoData]{NewMiddleware("base", func(ctx *MessageContext, db NoData) bool { return true })}, runners: []Runner[NoData]{NewRunner("base", func(bot *Bot[NoData]) error { return nil })}, } plugin := NewPlugin[NoData]("late") @@ -823,7 +823,7 @@ func TestAddPluginsAndRuntimeRegistrationsNoOpAfterRunStarts(t *testing.T) { defer bot.finishRun() bot.AddPlugins(plugin) - bot.AddMiddleware(NewMiddleware("late", func(ctx *MsgContext, db NoData) bool { return true })) + bot.AddMiddleware(NewMiddleware("late", func(ctx *MessageContext, db NoData) bool { return true })) bot.AddRunner(NewRunner("late", func(bot *Bot[NoData]) error { return nil })) if len(bot.plugins) != 0 { diff --git a/bot_utils.go b/bot_utils.go index 02c51ba..793f55f 100644 --- a/bot_utils.go +++ b/bot_utils.go @@ -68,7 +68,7 @@ func (bot *Bot[T]) startUpdateWorkers(ctx context.Context) { bot.handle(ctx, u) }) } - pool.Stop() // Wait for all tasks to complete and stop the pool + pool.StopAndWait() // Wait for all tasks to complete and stop the pool } func (bot *Bot[T]) initLoggers(opts *BotOpts) { @@ -183,6 +183,7 @@ func clonePlugin[T AppData](p *Plugin[T]) Plugin[T] { middlewares: append(extypes.Slice[Middleware[T]](nil), p.middlewares...), skipAutoCmd: p.skipAutoCmd, logger: p.logger, + loggerOwned: false, // user-supplied loggers stay caller-owned; bot may take ownership during registration messageFallback: p.messageFallback, handlers: make(map[tgapi.UpdateType]CommandExecutor[T]), onClose: p.onClose, diff --git a/bot_webhook.go b/bot_webhook.go index 6d26778..1106686 100644 --- a/bot_webhook.go +++ b/bot_webhook.go @@ -370,21 +370,14 @@ func (bot *Bot[T]) newWebhookMux(ctx context.Context, opts *BotWebhookOpts) *htt r.HandleFunc(opts.Path, updateHandler(ctx, bot, opts.SecretToken)) return r } -func (bot *Bot[T]) runWebhook(ctx context.Context, opts *BotWebhookOpts) error { +func (bot *Bot[T]) baseRunWebhook(ctx context.Context, opts *BotWebhookOpts, runFunc func(*http.Server, chan error)) error { srv := &http.Server{ Addr: fmt.Sprintf(":%d", opts.LocalPort), Handler: bot.newWebhookMux(ctx, opts), } errCh := make(chan error, 1) - go func() { - err := srv.ListenAndServe() - if err != nil && !errors.Is(err, http.ErrServerClosed) { - errCh <- err - return - } - errCh <- nil - }() + go runFunc(srv, errCh) bot.webhookLogger.Infoln(fmt.Sprintf("Bot Webhook started at %s; waiting for updates at %s", srv.Addr, opts.URL)) @@ -403,38 +396,26 @@ func (bot *Bot[T]) runWebhook(ctx context.Context, opts *BotWebhookOpts) error { return err } } -func (bot *Bot[T]) runWebhookTLS(ctx context.Context, opts *BotWebhookOpts, key, cert string) error { - srv := &http.Server{ - Addr: fmt.Sprintf(":%d", opts.LocalPort), - Handler: bot.newWebhookMux(ctx, opts), - } - errCh := make(chan error, 1) +func (bot *Bot[T]) runWebhook(ctx context.Context, opts *BotWebhookOpts) error { + return bot.baseRunWebhook(ctx, opts, func(srv *http.Server, errCh chan error) { + err := srv.ListenAndServe() + if err != nil && !errors.Is(err, http.ErrServerClosed) { + errCh <- err + return + } + errCh <- nil + }) - go func() { +} +func (bot *Bot[T]) runWebhookTLS(ctx context.Context, opts *BotWebhookOpts, key, cert string) error { + return bot.baseRunWebhook(ctx, opts, func(srv *http.Server, errCh chan error) { err := srv.ListenAndServeTLS(cert, key) if err != nil && !errors.Is(err, http.ErrServerClosed) { errCh <- err return } errCh <- nil - }() - - bot.webhookLogger.Infoln(fmt.Sprintf("Bot webhook started with TLS(%s, %s) at %s; waiting for updates at %s", key, cert, srv.Addr, opts.URL)) - - select { - case <-ctx.Done(): - shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - if err := srv.Shutdown(shutdownCtx); err != nil { - return err - } - - return <-errCh - - case err := <-errCh: - return err - } + }) } func validateWebhookPath(path string, useStatusPath bool) error { if path == "" { diff --git a/bot_webhook_test.go b/bot_webhook_test.go index 6a00f17..49d2019 100644 --- a/bot_webhook_test.go +++ b/bot_webhook_test.go @@ -135,7 +135,7 @@ func TestRunWebhookRuntimePreservesConfiguredWebhookLogger(t *testing.T) { func TestRunWebhookRuntimeProcessesEnqueuedUpdate(t *testing.T) { var calls atomic.Int32 plugin := NewPlugin[NoData]("demo") - plugin.Command("start", func(ctx *MsgContext, db NoData) error { + plugin.Command("start", func(ctx *MessageContext, db NoData) error { calls.Add(1) return nil }) diff --git a/cmd_generator_test.go b/cmd_generator_test.go index 72a1221..8a4b6d6 100644 --- a/cmd_generator_test.go +++ b/cmd_generator_test.go @@ -44,7 +44,7 @@ func TestAutoGenerateCommandsChecksLimitBeforeDelete(t *testing.T) { }() plugin := NewPlugin[NoData]("overflow") - exec := func(ctx *MsgContext, db NoData) error { return nil } + exec := func(ctx *MessageContext, db NoData) error { return nil } for i := 0; i < 101; i++ { plugin.Command("cmd"+strconv.Itoa(i), exec) } @@ -66,7 +66,7 @@ func TestAutoGenerateCommandsChecksLimitBeforeDelete(t *testing.T) { func TestGatherCommandsForPluginReturnsSortedCommands(t *testing.T) { plugin := NewPlugin[NoData]("sorted") - exec := func(ctx *MsgContext, db NoData) error { return nil } + exec := func(ctx *MessageContext, db NoData) error { return nil } plugin.Command("zeta", exec) plugin.Command("alpha", exec) diff --git a/commands.go b/commands.go index fd801b6..7e86ee2 100644 --- a/commands.go +++ b/commands.go @@ -85,7 +85,7 @@ func (c CommandArg) SetRequired() CommandArg { // CommandExecutor is the function type that executes a command. // It receives the message context and injected application data. // Returning a non-nil error routes it through the bot's error handler. -type CommandExecutor[T AppData] func(ctx *MsgContext, dbContext T) error +type CommandExecutor[T AppData] func(ctx *MessageContext, dbContext T) error // Command represents a bot command with arguments, description, and executor. // Can be registered in a Plugin and optionally skipped from auto-generation. @@ -98,18 +98,22 @@ type Command[T AppData] struct { skipAutoCmd bool // If true, this command won't be auto-added to help menus } -// NewCommand creates a new Command with the given command string, executor, and arguments. -// The command string should not include the leading slash (e.g., "start", not "/start"). +// NewCommand creates a new Command with the given identifier, executor, and arguments. +// +// The identifier is used as the routing key for both /-prefixed commands and +// callback payloads — the difference is registration: pass the result to +// Plugin.AddCommand/Plugin.Command for message routing, or to +// Plugin.AddPayload/Plugin.Payload for callback_data routing. +// +// For /-commands the identifier must not include the leading slash +// (e.g. "start", not "/start") and should match [_a-z0-9]{1,32} to satisfy +// Telegram's BotCommand validation. Payload identifiers may use any bytes +// that fit Telegram's callback_data limit, though the configured payload +// encoding may impose its own restrictions. func NewCommand[T any](command string, exec CommandExecutor[T], args ...CommandArg) *Command[T] { return &Command[T]{command, "", exec, args, make(extypes.Slice[Middleware[T]], 0), false} } -// NewPayload creates a new callback payload handler command. -// The command string can contain any symbols, but it is recommended to use only "_", "-", ".", a-z, A-Z, and 0-9. -func NewPayload[T any](command string, exec CommandExecutor[T], args ...CommandArg) *Command[T] { - return &Command[T]{command, "", exec, args, make(extypes.Slice[Middleware[T]], 0), false} -} - // Use adds a middleware to the command's execution chain. // Middlewares are executed in the order they are added. func (c *Command[T]) Use(m Middleware[T]) *Command[T] { diff --git a/doc.go b/doc.go index f30fb46..ae3738b 100644 --- a/doc.go +++ b/doc.go @@ -5,7 +5,7 @@ Core concepts: - Bot manages Telegram API access, update processing, logging, rate limiting, and dependency injection. - Plugins group commands, payloads, and non-command update handlers behind shared middleware. - - MsgContext provides access to the current update and reply/edit/delete helpers. + - MessageContext provides access to the current update and reply/edit/delete helpers. - InlineKeyboard builds callback-driven keyboards and structured payloads. - DraftProvider accumulates multi-step replies before sending them. - L10n stores key-based translations with fallback behavior. diff --git a/drafts.go b/drafts.go index c8968c4..7c43826 100644 --- a/drafts.go +++ b/drafts.go @@ -14,8 +14,11 @@ type draftIDGenerator interface { Next() uint64 } -// RandomDraftIDGenerator generates draft IDs using cryptographically secure random numbers. -// Suitable for distributed systems or when ID predictability is undesirable. +// RandomDraftIDGenerator generates draft IDs using math/rand/v2. +// +// Suitable for general use thanks to the wide 64-bit value space. Not suitable +// for security-sensitive purposes — use crypto/rand if unpredictability against +// an adversary matters. type RandomDraftIDGenerator struct{} // Next returns a random 64-bit unsigned integer. @@ -29,7 +32,7 @@ type LinearDraftIDGenerator struct { lastID atomic.Uint64 } -// Next returns the next linear ID, atomically incremented.о +// Next returns the next linear ID, atomically incremented. func (g *LinearDraftIDGenerator) Next() uint64 { return g.lastID.Add(1) } @@ -239,14 +242,21 @@ func (d *Draft) Flush() error { } // Internal helper for Push that updates the server-side draft. +// +// The candidate Message (current content + new text) is validated before any +// mutation, so a validation failure leaves the draft unchanged. After the +// validation passes, Message is committed locally regardless of whether the +// API call succeeds (per the Push docs: local state reflects the user's +// intent, network failures can be retried). func (d *Draft) push(text string) error { if d.chatID == 0 { return ErrDraftChatIDZero } - d.Message += text - if err := validateMessageText(d.Message); err != nil { + candidate := d.Message + text + if err := validateMessageText(candidate); err != nil { return err } + d.Message = candidate params := tgapi.SendMessageDraft{ ChatID: d.chatID, DraftID: d.ID, diff --git a/drafts_test.go b/drafts_test.go index 1ad9de0..6c4d59a 100644 --- a/drafts_test.go +++ b/drafts_test.go @@ -19,7 +19,7 @@ func TestDraftFlushRequiresChatID(t *testing.T) { } func TestMsgContextNewDraftWorksWithoutLimiter(t *testing.T) { - ctx := &MsgContext{ + ctx := &MessageContext{ API: &tgapi.API{}, Msg: &tgapi.Message{ Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}, @@ -31,6 +31,7 @@ func TestMsgContextNewDraftWorksWithoutLimiter(t *testing.T) { draft := ctx.NewDraft() if draft == nil { t.Fatal("expected draft") + return } if draft.chatID != 42 { t.Fatalf("unexpected chat id: %d", draft.chatID) @@ -53,3 +54,20 @@ func TestDraftPushRejectsLongMessage(t *testing.T) { t.Fatalf("expected ErrMessageTooLong, got %v", err) } } + +// TestDraftPushLeavesMessageUnchangedOnValidationFailure covers the validation +// order fix: when the candidate Message (current + new text) overflows the +// Telegram limit, the existing Message must remain intact so callers can +// recover and retry with a shorter payload instead of finding the draft in +// a half-mutated state. +func TestDraftPushLeavesMessageUnchangedOnValidationFailure(t *testing.T) { + draft := NewRandomDraftProvider(&tgapi.API{}).NewDraft(tgapi.ParseNone).SetChat(42, 0) + draft.Message = "hello" + + if err := draft.Push(strings.Repeat("a", maxMessageTextLen+1)); !errors.Is(err, ErrMessageTooLong) { + t.Fatalf("expected ErrMessageTooLong, got %v", err) + } + if draft.Message != "hello" { + t.Fatalf("expected draft Message to stay %q, got %q", "hello", draft.Message) + } +} diff --git a/handler.go b/handler.go index b1116ff..7d37a77 100644 --- a/handler.go +++ b/handler.go @@ -26,7 +26,7 @@ func (bot *Bot[T]) handle(parentCtx context.Context, u *tgapi.Update) { ctx, cancel := context.WithCancel(parentCtx) defer cancel() - msgCtx := &MsgContext{ + msgCtx := &MessageContext{ Update: *u, API: bot.api, Logger: bot.logger, errorTemplate: bot.errorTemplate, @@ -35,6 +35,7 @@ func (bot *Bot[T]) handle(parentCtx context.Context, u *tgapi.Update) { sceneRuntime: bot, observer: bot.observer, payloadType: bot.payloadType, + botID: bot.userID, ctx: ctx, } bot.prepareUpdateCtx(u, msgCtx) @@ -114,7 +115,7 @@ func (bot *Bot[T]) handle(parentCtx context.Context, u *tgapi.Update) { }) } -func cloneMsgContext(src *MsgContext) *MsgContext { +func cloneMsgContext(src *MessageContext) *MessageContext { cloned := *src if src.Args != nil { cloned.Args = append([]string(nil), src.Args...) @@ -154,20 +155,92 @@ func decodeBase64Payload(s string) (CallbackData, error) { return decodeJSONPayload(string(b)) } -func encodeCompactPayload(d CallbackData) (string, error) { - args := strings.Join(d.Args, ",") - return d.Command + "|" + args, nil +// Compact payload format: cmd|arg1,arg2,... +// Bytes \, |, and , inside a part are escaped with a leading backslash so the +// payload round-trips without ambiguity. Encoding/decoding operate byte-wise +// because all separators are single-byte ASCII; multi-byte UTF-8 code points +// pass through unchanged. + +func encodeCompactPart(s string) string { + if !strings.ContainsAny(s, `\|,`) { + return s + } + var b strings.Builder + b.Grow(len(s) + 2) + for i := 0; i < len(s); i++ { + switch s[i] { + case '\\', '|', ',': + b.WriteByte('\\') + } + b.WriteByte(s[i]) + } + return b.String() } + +func decodeCompactPart(s string) string { + if !strings.Contains(s, `\`) { + return s + } + var b strings.Builder + b.Grow(len(s)) + for i := 0; i < len(s); i++ { + if s[i] == '\\' && i+1 < len(s) { + b.WriteByte(s[i+1]) + i++ + continue + } + b.WriteByte(s[i]) + } + return b.String() +} + +func encodeCompactPayload(d CallbackData) (string, error) { + var b strings.Builder + b.WriteString(encodeCompactPart(d.Command)) + b.WriteByte('|') + for i, a := range d.Args { + if i > 0 { + b.WriteByte(',') + } + b.WriteString(encodeCompactPart(a)) + } + return b.String(), nil +} + func decodeCompactPayload(s string) (CallbackData, error) { - values := strings.SplitN(s, "|", 2) - if len(values) != 2 { + sepIdx := -1 + for i := 0; i < len(s); i++ { + if s[i] == '\\' && i+1 < len(s) { + i++ + continue + } + if s[i] == '|' { + sepIdx = i + break + } + } + if sepIdx == -1 { return CallbackData{}, errors.New("invalid payload") } - cmd, argsRaw := values[0], values[1] - var args []string - if argsRaw != "" { - args = strings.Split(argsRaw, ",") + cmd := decodeCompactPart(s[:sepIdx]) + argsRaw := s[sepIdx+1:] + if argsRaw == "" { + return CallbackData{Command: cmd}, nil } + + var args []string + start := 0 + for i := 0; i < len(argsRaw); i++ { + if argsRaw[i] == '\\' && i+1 < len(argsRaw) { + i++ + continue + } + if argsRaw[i] == ',' { + args = append(args, decodeCompactPart(argsRaw[start:i])) + start = i + 1 + } + } + args = append(args, decodeCompactPart(argsRaw[start:])) return CallbackData{Command: cmd, Args: args}, nil } func encodeCompactBase64Payload(d CallbackData) (string, error) { diff --git a/handler_test.go b/handler_test.go index 6441097..ae4b963 100644 --- a/handler_test.go +++ b/handler_test.go @@ -64,7 +64,7 @@ func TestBotMiddlewareReceivesLogger(t *testing.T) { bot := &Bot[NoData]{ logger: logger, middlewares: []Middleware[NoData]{ - NewMiddleware("logger-check", func(ctx *MsgContext, db NoData) bool { + NewMiddleware("logger-check", func(ctx *MessageContext, db NoData) bool { called = true if ctx.Logger != logger { t.Fatalf("expected bot logger in middleware context, got %#v", ctx.Logger) @@ -90,7 +90,7 @@ func TestBotMiddlewareReceivesLogger(t *testing.T) { func TestAddUpdateHandlerRejectsReservedUpdateTypes(t *testing.T) { plugin := NewPlugin[NoData]("test") - handler := func(ctx *MsgContext, db NoData) error { return nil } + handler := func(ctx *MessageContext, db NoData) error { return nil } for _, updateType := range []tgapi.UpdateType{ tgapi.UpdateTypeMessage, @@ -376,7 +376,7 @@ func TestPrepareUpdateCtxContract(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { bot := &Bot[NoData]{} - ctx := &MsgContext{} + ctx := &MessageContext{} bot.prepareUpdateCtx(tt.update, ctx) if got := ctx.Msg != nil; got != tt.wantMsg { @@ -450,7 +450,7 @@ func TestHandleUpdateHandlersPopulateFromContext(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { called := false - plugin := NewPlugin[NoData]("test").AddUpdateHandler(tt.update.Type, func(ctx *MsgContext, db NoData) error { + plugin := NewPlugin[NoData]("test").AddUpdateHandler(tt.update.Type, func(ctx *MessageContext, db NoData) error { called = true if ctx.Update.UpdateID != tt.update.UpdateID { t.Fatalf("unexpected update in context: got %d want %d", ctx.Update.UpdateID, tt.update.UpdateID) @@ -488,7 +488,7 @@ func TestHandleUpdateHandlersReceiveIsolatedContexts(t *testing.T) { firstCalled := false secondCalled := false - first := NewPlugin[NoData]("first").AddUpdateHandler(tgapi.UpdateTypeInlineQuery, func(ctx *MsgContext, db NoData) error { + first := NewPlugin[NoData]("first").AddUpdateHandler(tgapi.UpdateTypeInlineQuery, func(ctx *MessageContext, db NoData) error { firstCalled = true if ctx.FromID != 41 { t.Fatalf("unexpected FromID in first handler: got %d want 41", ctx.FromID) @@ -499,7 +499,7 @@ func TestHandleUpdateHandlersReceiveIsolatedContexts(t *testing.T) { ctx.Args = []string{"mutated"} return nil }) - second := NewPlugin[NoData]("second").AddUpdateHandler(tgapi.UpdateTypeInlineQuery, func(ctx *MsgContext, db NoData) error { + second := NewPlugin[NoData]("second").AddUpdateHandler(tgapi.UpdateTypeInlineQuery, func(ctx *MessageContext, db NoData) error { secondCalled = true if ctx.From == nil { t.Fatal("expected ctx.From to remain populated for second handler") @@ -541,7 +541,7 @@ func TestHandleUpdateHandlersReceiveIsolatedContexts(t *testing.T) { func TestHandleUpdateObserverEmitsUpdateErrors(t *testing.T) { observer := &recordingObserver{} - plugin := NewPlugin[NoData]("test").AddUpdateHandler(tgapi.UpdateTypeInlineQuery, func(ctx *MsgContext, db NoData) error { + plugin := NewPlugin[NoData]("test").AddUpdateHandler(tgapi.UpdateTypeInlineQuery, func(ctx *MessageContext, db NoData) error { return AsUserError(errors.New("update failed")) }) @@ -596,7 +596,7 @@ func TestHandleObserverCompletesUpdateWhenBotMiddlewareBlocks(t *testing.T) { logger: sneklog.NewLogger(), observer: observer, middlewares: []Middleware[NoData]{ - NewMiddleware("block", func(ctx *MsgContext, db NoData) bool { + NewMiddleware("block", func(ctx *MessageContext, db NoData) bool { return false }), }, @@ -632,7 +632,7 @@ func TestHandleMessageFallbackRunsAfterCommandMiss(t *testing.T) { observer := &recordingObserver{} called := false plugin := NewPlugin[NoData]("test") - plugin.SetMessageFallback(func(ctx *MsgContext, db NoData) error { + plugin.SetMessageFallback(func(ctx *MessageContext, db NoData) error { called = true if ctx.Text != "/missing hello world" { t.Fatalf("unexpected fallback text: got %q", ctx.Text) @@ -687,7 +687,7 @@ func TestHandleMessageFallbackRunsAfterCommandMiss(t *testing.T) { func TestHandleMessageFallbackRunsForPlainText(t *testing.T) { called := false - plugin := NewPlugin[NoData]("test").SetMessageFallback(func(ctx *MsgContext, db NoData) error { + plugin := NewPlugin[NoData]("test").SetMessageFallback(func(ctx *MessageContext, db NoData) error { called = true if ctx.Text != "hello fallback" { t.Fatalf("unexpected fallback text: got %q", ctx.Text) @@ -723,10 +723,10 @@ func TestHandleMessageFallbackRunsForPlainText(t *testing.T) { func TestHandleMessageFallbackRespectsMiddleware(t *testing.T) { called := false plugin := NewPlugin[NoData]("test") - plugin.AddMiddleware(NewMiddleware("block", func(ctx *MsgContext, db NoData) bool { + plugin.AddMiddleware(NewMiddleware("block", func(ctx *MessageContext, db NoData) bool { return false })) - plugin.SetMessageFallback(func(ctx *MsgContext, db NoData) error { + plugin.SetMessageFallback(func(ctx *MessageContext, db NoData) error { called = true return nil }) @@ -757,11 +757,11 @@ func TestHandleMessageFallbackDoesNotRunWhenCommandMatches(t *testing.T) { commandCalled := false fallbackCalled := false plugin := NewPlugin[NoData]("test") - plugin.Command("start", func(ctx *MsgContext, db NoData) error { + plugin.Command("start", func(ctx *MessageContext, db NoData) error { commandCalled = true return nil }) - plugin.SetMessageFallback(func(ctx *MsgContext, db NoData) error { + plugin.SetMessageFallback(func(ctx *MessageContext, db NoData) error { fallbackCalled = true return nil }) @@ -794,7 +794,7 @@ func TestHandleMessageFallbackDoesNotRunWhenCommandMatches(t *testing.T) { func TestHandleChannelPostCommandWithSenderChat(t *testing.T) { called := false plugin := NewPlugin[NoData]("test") - plugin.Command("ping", func(ctx *MsgContext, db NoData) error { + plugin.Command("ping", func(ctx *MessageContext, db NoData) error { called = true if ctx.Msg == nil { t.Fatal("expected message context") @@ -841,7 +841,7 @@ func TestCommandHandlerBindArgsEndToEnd(t *testing.T) { var got banInput plugin := NewPlugin[NoData]("test") - plugin.Command("ban", func(ctx *MsgContext, db NoData) error { + plugin.Command("ban", func(ctx *MessageContext, db NoData) error { return ctx.BindArgs(&got) }, NewCommandArg("user_id").SetValueType(CommandValueInt).SetRequired(), @@ -878,7 +878,7 @@ func TestPayloadHandlerBindArgsEndToEnd(t *testing.T) { var got payloadInput plugin := NewPlugin[NoData]("test") - plugin.Payload("approve", func(ctx *MsgContext, db NoData) error { + plugin.Payload("approve", func(ctx *MessageContext, db NoData) error { return ctx.BindArgs(&got) }, NewCommandArg("id").SetValueType(CommandValueInt).SetRequired(), @@ -920,11 +920,11 @@ func TestHandleEditedMessageStaysOutOfCommandFlow(t *testing.T) { updateCalled := false plugin := NewPlugin[NoData]("test") - plugin.Command("ping", func(ctx *MsgContext, db NoData) error { + plugin.Command("ping", func(ctx *MessageContext, db NoData) error { commandCalled = true return nil }) - plugin.AddUpdateHandler(tgapi.UpdateTypeEditedMessage, func(ctx *MsgContext, db NoData) error { + plugin.AddUpdateHandler(tgapi.UpdateTypeEditedMessage, func(ctx *MessageContext, db NoData) error { updateCalled = true if ctx.Msg == nil { t.Fatal("expected ctx.Msg in edited message handler") @@ -968,11 +968,11 @@ func TestHandleEditedChannelPostStaysOutOfCommandFlow(t *testing.T) { updateCalled := false plugin := NewPlugin[NoData]("test") - plugin.Command("ping", func(ctx *MsgContext, db NoData) error { + plugin.Command("ping", func(ctx *MessageContext, db NoData) error { commandCalled = true return nil }) - plugin.AddUpdateHandler(tgapi.UpdateTypeEditedChannelPost, func(ctx *MsgContext, db NoData) error { + plugin.AddUpdateHandler(tgapi.UpdateTypeEditedChannelPost, func(ctx *MessageContext, db NoData) error { updateCalled = true if ctx.Msg == nil { t.Fatal("expected ctx.Msg in edited channel post handler") @@ -1007,7 +1007,7 @@ func TestHandleEditedChannelPostStaysOutOfCommandFlow(t *testing.T) { func TestHandleCallbackPopulatesMessageTargets(t *testing.T) { called := false plugin := NewPlugin[NoData]("test") - plugin.Payload("approve", func(ctx *MsgContext, db NoData) error { + plugin.Payload("approve", func(ctx *MessageContext, db NoData) error { called = true if ctx.CallbackQueryID != "cb-msg" { t.Fatalf("unexpected CallbackQueryID: %q", ctx.CallbackQueryID) @@ -1066,7 +1066,7 @@ func TestHandleCallbackPopulatesMessageTargets(t *testing.T) { func TestHandleCallbackPopulatesInlineTargets(t *testing.T) { called := false plugin := NewPlugin[NoData]("test") - plugin.Payload("inline.approve", func(ctx *MsgContext, db NoData) error { + plugin.Payload("inline.approve", func(ctx *MessageContext, db NoData) error { called = true if ctx.CallbackQueryID != "cb-inline" { t.Fatalf("unexpected CallbackQueryID: %q", ctx.CallbackQueryID) @@ -1122,7 +1122,7 @@ func TestHandleCallbackPopulatesInlineTargets(t *testing.T) { func TestHandleCallbackObserverEmitsPayloadEvents(t *testing.T) { observer := &recordingObserver{} plugin := NewPlugin[NoData]("test") - plugin.Payload("approve", func(ctx *MsgContext, db NoData) error { + plugin.Payload("approve", func(ctx *MessageContext, db NoData) error { return nil }) @@ -1173,7 +1173,7 @@ func TestHandleCallbackObserverEmitsPayloadErrors(t *testing.T) { observer := &recordingObserver{} plugin := NewPlugin[NoData]("test") wantErr := AsInternalError(errors.New("boom")) - plugin.Payload("approve", func(ctx *MsgContext, db NoData) error { + plugin.Payload("approve", func(ctx *MessageContext, db NoData) error { return wantErr }) @@ -1236,7 +1236,7 @@ func TestHandleCallbackObserverEmitsDecodeErrors(t *testing.T) { Data: "{not-json", From: tgapi.User{ID: 7}, }, - }, &MsgContext{ + }, &MessageContext{ Update: tgapi.Update{ UpdateID: 34, Type: tgapi.UpdateTypeCallbackQuery, diff --git a/keyboard.go b/keyboard.go index 0027adb..52a03a7 100644 --- a/keyboard.go +++ b/keyboard.go @@ -203,6 +203,8 @@ func (in *InlineKeyboard) SetMaxRow(maxRow int) *InlineKeyboard { return in } +func (in *InlineKeyboard) GetMaxRow() int { return in.maxRow } + // Internal helper that appends a button and auto-flushes a full row. func (in *InlineKeyboard) append(button tgapi.InlineKeyboardButton) *InlineKeyboard { if in.CurrentLine.Len() == in.maxRow { @@ -302,18 +304,18 @@ func NewCallbackData(command string, args ...any) CallbackData { } } +// All To* encoders return an empty string when serialization fails. Telegram +// rejects empty callback_data, so an empty result surfaces a real bug rather +// than masking it with a stub payload that silently routes to no handler. +// Build CallbackData from primitives (string, []string) only — the encoders +// have no failure modes for that input. + // ToJSON serializes the CallbackData to a JSON string. -// -// If serialization fails (e.g., due to unmarshalable fields), returns a fallback -// JSON object: {"cmd":""} to prevent breaking Telegram's API. -// -// This fallback ensures the bot receives a valid JSON payload even if internal -// errors occur — avoiding "invalid callback_data" errors from Telegram. +// Returns an empty string if serialization fails. func (d CallbackData) ToJSON() string { data, err := encodeJSONPayload(d) if err != nil { - // Fallback: return minimal valid JSON to avoid Telegram API rejection - return `{"cmd":""}` + return "" } return data } @@ -323,25 +325,31 @@ func (d CallbackData) ToJSON() string { func (d CallbackData) ToBase64() string { data, err := encodeBase64Payload(d) if err != nil { - return `` + return "" } return data } // ToCompact serializes the CallbackData to a compact delimited string. +// Returns an empty string if serialization fails. +// +// The compact format coalesces "no args" with "single empty arg" — both +// produce "cmd|" and decode back to nil args. Use ToJSON or ToBase64 when +// that distinction must be preserved. func (d CallbackData) ToCompact() string { data, err := encodeCompactPayload(d) if err != nil { - return `` + return "" } return data } // ToCompactBase64 serializes the CallbackData to compact text and then encodes it as Base64. +// Returns an empty string if serialization or encoding fails. func (d CallbackData) ToCompactBase64() string { data, err := encodeCompactBase64Payload(d) if err != nil { - return `` + return "" } return data } diff --git a/keyboard_test.go b/keyboard_test.go index 267642f..ecb1034 100644 --- a/keyboard_test.go +++ b/keyboard_test.go @@ -150,6 +150,60 @@ func TestDecodePayloadAcceptsCompactBase64KeyboardPayloadWhenBotPrefersJSON(t *t } } +// TestCompactPayloadRoundTripsWithSeparatorChars guards the compact-encoding +// escape fix. Args containing the , | or \ separator bytes previously corrupted +// on decode; now they must round-trip exactly. +// +// Note: the compact format coalesces "no args" with "single empty arg" — both +// emit "cmd|" and decode to nil args. Use other encodings if that distinction +// matters. +func TestCompactPayloadRoundTripsWithSeparatorChars(t *testing.T) { + tests := []struct { + name string + data CallbackData + }{ + {name: "plain", data: CallbackData{Command: "cmd", Args: []string{"one", "two"}}}, + {name: "no args", data: CallbackData{Command: "cmd"}}, + {name: "comma in arg", data: CallbackData{Command: "cmd", Args: []string{"a,b", "c"}}}, + {name: "pipe in arg", data: CallbackData{Command: "cmd", Args: []string{"a|b", "c"}}}, + {name: "backslash in arg", data: CallbackData{Command: "cmd", Args: []string{`a\b`, "c"}}}, + {name: "all specials in arg", data: CallbackData{Command: "cmd", Args: []string{`a,b|c\d`}}}, + {name: "specials in command", data: CallbackData{Command: "a|b,c", Args: []string{"x"}}}, + {name: "two empty args", data: CallbackData{Command: "cmd", Args: []string{"", ""}}}, + {name: "utf8 args", data: CallbackData{Command: "cmd", Args: []string{"привет", "мир"}}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + encoded, err := encodeCompactPayload(tt.data) + if err != nil { + t.Fatalf("encodeCompactPayload returned error: %v", err) + } + got, err := decodeCompactPayload(encoded) + if err != nil { + t.Fatalf("decodeCompactPayload returned error: %v", err) + } + if got.Command != tt.data.Command { + t.Fatalf("command mismatch: got %q want %q (encoded=%q)", got.Command, tt.data.Command, encoded) + } + if len(got.Args) != len(tt.data.Args) { + t.Fatalf("args length mismatch: got %v want %v (encoded=%q)", got.Args, tt.data.Args, encoded) + } + for i := range tt.data.Args { + if got.Args[i] != tt.data.Args[i] { + t.Fatalf("arg %d mismatch: got %q want %q (encoded=%q)", i, got.Args[i], tt.data.Args[i], encoded) + } + } + }) + } +} + +func TestCompactPayloadDecodeRejectsMissingSeparator(t *testing.T) { + if _, err := decodeCompactPayload("noseparator"); err == nil { + t.Fatal("expected error decoding payload without separator") + } +} + func TestDecodePayloadStrictRejectsCompactMismatchedType(t *testing.T) { kb := NewInlineKeyboardCompact(1). AddCallbackButton("A", "cmd", 1) diff --git a/methods.go b/methods.go index 50e24e6..e970d8b 100644 --- a/methods.go +++ b/methods.go @@ -43,9 +43,10 @@ import ( // } func (bot *Bot[T]) Updates(ctx context.Context) ([]tgapi.Update, error) { offset := bot.GetUpdateOffset() + timeout := bot.pollTimeout params := tgapi.UpdateParams{ Offset: new(offset), - Timeout: new(30), + Timeout: new(timeout), AllowedUpdates: bot.GetUpdateTypes(), } diff --git a/msg_context.go b/msg_context.go index f5841f9..6a25968 100644 --- a/msg_context.go +++ b/msg_context.go @@ -14,10 +14,10 @@ import ( "git.scuroneko.dev/scuroneko/sneklog/v2" ) -// MsgContext holds the normalized per-update context passed to command, payload, +// MessageContext holds the normalized per-update context passed to command, payload, // scene, middleware, and generic update handlers. // -// MsgContext is populated from the current Telegram update before handler routing. +// MessageContext is populated from the current Telegram update before handler routing. // Not every field is guaranteed for every update kind. In particular: // - Update is always present. // - Msg is populated only for update kinds that carry a Telegram message object. @@ -27,10 +27,10 @@ import ( // - CallbackQueryID, CallbackMsgID, and InlineMsgID are populated only for // callback query handling when the corresponding callback targets exist. // -// Helper methods on MsgContext may require a message-backed context. For example, +// Helper methods on MessageContext may require a message-backed context. For example, // reply helpers need Msg, while inline callback edit helpers can work through // InlineMsgID when there is no chat message. -type MsgContext struct { +type MessageContext struct { API *tgapi.API Update tgapi.Update @@ -81,21 +81,22 @@ type MsgContext struct { payloadType BotPayloadType sceneRuntime sceneRuntime observer Observer + botID int64 ctx context.Context } -// AnswerMessage represents a message sent or edited via MsgContext. +// AnswerMessage represents a message sent or edited via MessageContext. // It holds metadata to allow further editing or deletion. type AnswerMessage struct { MessageID int Text string IsMedia bool - ctx *MsgContext // internal back-reference + ctx *MessageContext // internal back-reference } // Internal helper for text edits with optional keyboard and parse mode. -func (ctx *MsgContext) edit(messageID int, text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage { +func (ctx *MessageContext) edit(messageID int, text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage { if err := validateMessageText(text); err != nil { ctx.Logger.Errorln(err) return nil @@ -146,7 +147,7 @@ func (m *AnswerMessage) EditMarkdown(text string) *AnswerMessage { } // Internal helper for editing callback-linked messages. -func (ctx *MsgContext) editCallback(text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage { +func (ctx *MessageContext) editCallback(text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage { if ctx.CallbackMsgID == 0 && ctx.InlineMsgID == "" { ctx.Logger.Errorln(ErrCallbackMessageMissing) return nil @@ -155,31 +156,31 @@ func (ctx *MsgContext) editCallback(text string, keyboard *InlineKeyboard, parse } // EditCallback edits the callback message using plain text (ParseNone). -func (ctx *MsgContext) EditCallback(text string, keyboard *InlineKeyboard) *AnswerMessage { +func (ctx *MessageContext) EditCallback(text string, keyboard *InlineKeyboard) *AnswerMessage { return ctx.editCallback(text, keyboard, tgapi.ParseNone) } // EditCallbackMarkdown edits the callback message using MarkdownV2. // // ⚠️ WARNING: User input must be escaped with tgfmt.EscapeMarkdownV2() before passing here. -func (ctx *MsgContext) EditCallbackMarkdown(text string, keyboard *InlineKeyboard) *AnswerMessage { +func (ctx *MessageContext) EditCallbackMarkdown(text string, keyboard *InlineKeyboard) *AnswerMessage { return ctx.editCallback(text, keyboard, tgapi.ParseMarkdownV2) } // EditCallbackf formats a string using fmt.Sprintf and edits the callback message with plain text. -func (ctx *MsgContext) EditCallbackf(format string, keyboard *InlineKeyboard, args ...any) *AnswerMessage { +func (ctx *MessageContext) EditCallbackf(format string, keyboard *InlineKeyboard, args ...any) *AnswerMessage { return ctx.editCallback(fmt.Sprintf(format, args...), keyboard, tgapi.ParseNone) } // EditCallbackfMarkdown formats a string using fmt.Sprintf and edits the callback message with MarkdownV2. // // ⚠️ WARNING: User input must be escaped with tgfmt.EscapeMarkdownV2() before passing here. -func (ctx *MsgContext) EditCallbackfMarkdown(format string, keyboard *InlineKeyboard, args ...any) *AnswerMessage { +func (ctx *MessageContext) EditCallbackfMarkdown(format string, keyboard *InlineKeyboard, args ...any) *AnswerMessage { return ctx.editCallback(fmt.Sprintf(format, args...), keyboard, tgapi.ParseMarkdownV2) } // Internal helper for media-caption edits. -func (ctx *MsgContext) editPhotoText(messageID int, text string, kb *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage { +func (ctx *MessageContext) editPhotoText(messageID int, text string, kb *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage { if err := validateCaptionText(text); err != nil { ctx.Logger.Errorln(err) return nil @@ -241,7 +242,7 @@ func (m *AnswerMessage) EditCaptionKeyboardMarkdown(text string, kb *InlineKeybo } // Internal helper for message replies with optional keyboard and parse mode. -func (ctx *MsgContext) answer(text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage { +func (ctx *MessageContext) answer(text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage { if ctx.Msg == nil { ctx.Logger.Errorln(ErrMessageContextNil) return nil @@ -276,7 +277,7 @@ func (ctx *MsgContext) answer(text string, keyboard *InlineKeyboard, parseMode t } // Answer sends a plain text message (ParseNone). -func (ctx *MsgContext) Answer(text string) *AnswerMessage { +func (ctx *MessageContext) Answer(text string) *AnswerMessage { return ctx.answer(text, nil, tgapi.ParseNone) } @@ -284,54 +285,54 @@ func (ctx *MsgContext) Answer(text string) *AnswerMessage { // // The text is split into Telegram-safe chunks. Returned messages preserve send // order. If a chunk fails to send, already-sent messages are returned. -func (ctx *MsgContext) AnswerLong(text string) []*AnswerMessage { +func (ctx *MessageContext) AnswerLong(text string) []*AnswerMessage { return ctx.answerLong(text, nil, tgapi.ParseNone) } // AnswerMarkdown sends a message using MarkdownV2 formatting. // // ⚠️ WARNING: User input must be escaped with tgfmt.EscapeMarkdownV2() before passing here. -func (ctx *MsgContext) AnswerMarkdown(text string) *AnswerMessage { +func (ctx *MessageContext) AnswerMarkdown(text string) *AnswerMessage { return ctx.answer(text, nil, tgapi.ParseMarkdownV2) } // Answerf formats a string using fmt.Sprintf and sends it as a plain text message. -func (ctx *MsgContext) Answerf(template string, args ...any) *AnswerMessage { +func (ctx *MessageContext) Answerf(template string, args ...any) *AnswerMessage { return ctx.answer(fmt.Sprintf(template, args...), nil, tgapi.ParseNone) } // AnswerLongf formats a string using fmt.Sprintf and sends it as one or more plain-text messages. -func (ctx *MsgContext) AnswerLongf(template string, args ...any) []*AnswerMessage { +func (ctx *MessageContext) AnswerLongf(template string, args ...any) []*AnswerMessage { return ctx.answerLong(fmt.Sprintf(template, args...), nil, tgapi.ParseNone) } // AnswerfMarkdown formats a string using fmt.Sprintf and sends it using MarkdownV2. // // ⚠️ WARNING: User input must be escaped with tgfmt.EscapeMarkdownV2() before passing here. -func (ctx *MsgContext) AnswerfMarkdown(template string, args ...any) *AnswerMessage { +func (ctx *MessageContext) AnswerfMarkdown(template string, args ...any) *AnswerMessage { return ctx.answer(fmt.Sprintf(template, args...), nil, tgapi.ParseMarkdownV2) } // Keyboard sends a message with an inline keyboard (plain text). -func (ctx *MsgContext) Keyboard(text string, kb *InlineKeyboard) *AnswerMessage { +func (ctx *MessageContext) Keyboard(text string, kb *InlineKeyboard) *AnswerMessage { return ctx.answer(text, kb, tgapi.ParseNone) } // KeyboardLong sends long plain text split across multiple messages. // // The inline keyboard is attached only to the final chunk. -func (ctx *MsgContext) KeyboardLong(text string, kb *InlineKeyboard) []*AnswerMessage { +func (ctx *MessageContext) KeyboardLong(text string, kb *InlineKeyboard) []*AnswerMessage { return ctx.answerLong(text, kb, tgapi.ParseNone) } // KeyboardMarkdown sends a message with an inline keyboard using MarkdownV2. // // ⚠️ WARNING: User input must be escaped with tgfmt.EscapeMarkdownV2() before passing here. -func (ctx *MsgContext) KeyboardMarkdown(text string, keyboard *InlineKeyboard) *AnswerMessage { +func (ctx *MessageContext) KeyboardMarkdown(text string, keyboard *InlineKeyboard) *AnswerMessage { return ctx.answer(text, keyboard, tgapi.ParseMarkdownV2) } -func (ctx *MsgContext) answerLong(text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) []*AnswerMessage { +func (ctx *MessageContext) answerLong(text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) []*AnswerMessage { if parseMode != tgapi.ParseNone { ctx.Logger.Errorln(ErrMessageSplitImpossible) return nil @@ -371,7 +372,7 @@ func (ctx *MsgContext) answerLong(text string, keyboard *InlineKeyboard, parseMo } // Internal helper for photo replies with optional caption and keyboard. -func (ctx *MsgContext) answerPhoto(photoID, text string, kb *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage { +func (ctx *MessageContext) answerPhoto(photoID, text string, kb *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage { if ctx.Msg == nil { ctx.Logger.Errorln(ErrMessageContextNil) return nil @@ -407,43 +408,43 @@ func (ctx *MsgContext) answerPhoto(photoID, text string, kb *InlineKeyboard, par } // AnswerPhoto sends a photo with plain text caption. -func (ctx *MsgContext) AnswerPhoto(photoID, text string) *AnswerMessage { +func (ctx *MessageContext) AnswerPhoto(photoID, text string) *AnswerMessage { return ctx.answerPhoto(photoID, text, nil, tgapi.ParseNone) } // AnswerPhotoMarkdown sends a photo with MarkdownV2 caption. // // ⚠️ WARNING: User input must be escaped with tgfmt.EscapeMarkdownV2() before passing here. -func (ctx *MsgContext) AnswerPhotoMarkdown(photoID, text string) *AnswerMessage { +func (ctx *MessageContext) AnswerPhotoMarkdown(photoID, text string) *AnswerMessage { return ctx.answerPhoto(photoID, text, nil, tgapi.ParseMarkdownV2) } // AnswerPhotoKeyboard sends a photo with caption and inline keyboard (plain text). -func (ctx *MsgContext) AnswerPhotoKeyboard(photoID, text string, kb *InlineKeyboard) *AnswerMessage { +func (ctx *MessageContext) AnswerPhotoKeyboard(photoID, text string, kb *InlineKeyboard) *AnswerMessage { return ctx.answerPhoto(photoID, text, kb, tgapi.ParseNone) } // AnswerPhotoKeyboardMarkdown sends a photo with caption and inline keyboard using MarkdownV2. // // ⚠️ WARNING: User input must be escaped with tgfmt.EscapeMarkdownV2() before passing here. -func (ctx *MsgContext) AnswerPhotoKeyboardMarkdown(photoID, text string, kb *InlineKeyboard) *AnswerMessage { +func (ctx *MessageContext) AnswerPhotoKeyboardMarkdown(photoID, text string, kb *InlineKeyboard) *AnswerMessage { return ctx.answerPhoto(photoID, text, kb, tgapi.ParseMarkdownV2) } // AnswerPhotof formats a string and sends it as a photo caption (plain text). -func (ctx *MsgContext) AnswerPhotof(photoID, template string, args ...any) *AnswerMessage { +func (ctx *MessageContext) AnswerPhotof(photoID, template string, args ...any) *AnswerMessage { return ctx.answerPhoto(photoID, fmt.Sprintf(template, args...), nil, tgapi.ParseNone) } // AnswerPhotofMarkdown formats a string and sends it as a photo caption using MarkdownV2. // // ⚠️ WARNING: User input must be escaped with tgfmt.EscapeMarkdownV2() before passing here. -func (ctx *MsgContext) AnswerPhotofMarkdown(photoID, template string, args ...any) *AnswerMessage { +func (ctx *MessageContext) AnswerPhotofMarkdown(photoID, template string, args ...any) *AnswerMessage { return ctx.answerPhoto(photoID, fmt.Sprintf(template, args...), nil, tgapi.ParseMarkdownV2) } // Internal helper that deletes a message by ID. -func (ctx *MsgContext) delete(messageID int) { +func (ctx *MessageContext) delete(messageID int) { if messageID == 0 { ctx.Logger.Errorln(ErrMessageIDZero) return @@ -465,7 +466,7 @@ func (ctx *MsgContext) delete(messageID int) { func (m *AnswerMessage) Delete() { m.ctx.delete(m.MessageID) } // CallbackDelete deletes the message that triggered the callback query. -func (ctx *MsgContext) CallbackDelete() { +func (ctx *MessageContext) CallbackDelete() { if ctx.CallbackMsgID == 0 { ctx.Logger.Errorln(ErrCallbackMessageMissing) return @@ -474,7 +475,7 @@ func (ctx *MsgContext) CallbackDelete() { } // Internal helper that answers a callback query with optional text, alert, or URL. -func (ctx *MsgContext) answerCallbackQuery(url, text string, showAlert bool) { +func (ctx *MessageContext) answerCallbackQuery(url, text string, showAlert bool) { if len(ctx.CallbackQueryID) == 0 { return } @@ -488,19 +489,19 @@ func (ctx *MsgContext) answerCallbackQuery(url, text string, showAlert bool) { } // AnswerCallback answers the callback query with no text or alert. -func (ctx *MsgContext) AnswerCallback() { ctx.answerCallbackQuery("", "", false) } +func (ctx *MessageContext) AnswerCallback() { ctx.answerCallbackQuery("", "", false) } // AnswerCallbackText answers the callback query with a text notification. -func (ctx *MsgContext) AnswerCallbackText(text string) { ctx.answerCallbackQuery("", text, false) } +func (ctx *MessageContext) AnswerCallbackText(text string) { ctx.answerCallbackQuery("", text, false) } // AnswerCallbackAlert answers the callback query with a user-visible alert. -func (ctx *MsgContext) AnswerCallbackAlert(text string) { ctx.answerCallbackQuery("", text, true) } +func (ctx *MessageContext) AnswerCallbackAlert(text string) { ctx.answerCallbackQuery("", text, true) } // AnswerCallbackURL answers the callback query with a URL redirect. -func (ctx *MsgContext) AnswerCallbackURL(u string) { ctx.answerCallbackQuery(u, "", false) } +func (ctx *MessageContext) AnswerCallbackURL(u string) { ctx.answerCallbackQuery(u, "", false) } // SendAction sends a chat action (typing, uploading_photo, etc.) to indicate bot activity. -func (ctx *MsgContext) SendAction(action tgapi.ChatActionType) { +func (ctx *MessageContext) SendAction(action tgapi.ChatActionType) { if ctx.Msg == nil { ctx.Logger.Errorln("Can't send action without chat message context") return @@ -518,12 +519,12 @@ func (ctx *MsgContext) SendAction(action tgapi.ChatActionType) { } // Internal helper that formats, sends, and logs an error. -func (ctx *MsgContext) error(err error) { +func (ctx *MessageContext) error(err error) { if err == nil { return } ctx.Logger.Errorln(err) - if IsInternalError(err) { + if !IsUserError(err) { return } text := fmt.Sprintf(ctx.errorTemplate, err.Error()) @@ -536,9 +537,9 @@ func (ctx *MsgContext) error(err error) { } // Error is an alias for error(). -func (ctx *MsgContext) Error(err error) { ctx.error(err) } +func (ctx *MessageContext) Error(err error) { ctx.error(err) } -func (ctx *MsgContext) newDraft(parseMode tgapi.ParseMode) *Draft { +func (ctx *MessageContext) newDraft(parseMode tgapi.ParseMode) *Draft { if ctx.Msg == nil { ctx.Logger.Errorln(ErrMessageContextNil) return nil @@ -567,20 +568,20 @@ func (ctx *MsgContext) newDraft(parseMode tgapi.ParseMode) *Draft { // NewDraft creates a new message draft associated with the current chat. // Uses the API limiter to avoid rate limiting. -func (ctx *MsgContext) NewDraft() *Draft { +func (ctx *MessageContext) NewDraft() *Draft { return ctx.newDraft(tgapi.ParseNone) } // NewDraftMarkdown creates a new message draft associated with the current chat, // with Markdown V2 parse mode enabled. // Uses the API limiter to avoid rate limiting. -func (ctx *MsgContext) NewDraftMarkdown() *Draft { +func (ctx *MessageContext) NewDraftMarkdown() *Draft { return ctx.newDraft(tgapi.ParseMarkdownV2) } // Translate looks up a key in the current user's language. // Falls back to the bot's default language if user's language is unknown or unsupported. -func (ctx *MsgContext) Translate(key string) string { +func (ctx *MessageContext) Translate(key string) string { if ctx.From == nil { return key } @@ -590,12 +591,12 @@ func (ctx *MsgContext) Translate(key string) string { // NewInlineKeyboard creates a new keyboard builder with the context's payload // encoding type and the specified maximum number of buttons per row. -func (ctx *MsgContext) NewInlineKeyboard(maxRow int) *InlineKeyboard { +func (ctx *MessageContext) NewInlineKeyboard(maxRow int) *InlineKeyboard { return NewInlineKeyboard(ctx.payloadType, maxRow) } // NewInlineKeyboardButton creates a button builder using the context payload encoding. -func (ctx *MsgContext) NewInlineKeyboardButton(text string) InlineKeyboardButtonBuilder { +func (ctx *MessageContext) NewInlineKeyboardButton(text string) InlineKeyboardButtonBuilder { return NewInlineKeyboardButton(text).SetPayloadType(ctx.payloadType) } @@ -684,19 +685,19 @@ func bindPositional(args []string, dst any) error { // are provided than fields, the remaining fields keep their zero values. If the // final bindable field is a string, it receives the remaining arguments joined // with spaces. -func (ctx *MsgContext) BindArgs(dst any) error { +func (ctx *MessageContext) BindArgs(dst any) error { return bindPositional(ctx.Args, dst) } // Context returns the request-scoped context associated with the current update. -func (ctx *MsgContext) Context() context.Context { +func (ctx *MessageContext) Context() context.Context { if ctx.ctx == nil { return context.Background() } return ctx.ctx } -func (ctx *MsgContext) emitPolicyChecked(event PolicyCheckedEvent) { +func (ctx *MessageContext) emitPolicyChecked(event PolicyCheckedEvent) { if ctx == nil || ctx.observer == nil { return } @@ -713,7 +714,7 @@ func (ctx *MsgContext) emitPolicyChecked(event PolicyCheckedEvent) { } // EnterScene enters the named scene at its configured entry step. -func (ctx *MsgContext) EnterScene(name string) error { +func (ctx *MessageContext) EnterScene(name string) error { if ctx.sceneRuntime == nil { return ErrSceneRuntimeNil } @@ -723,7 +724,7 @@ func (ctx *MsgContext) EnterScene(name string) error { return ErrSceneNotFound } - key, ok := ctx.sceneRuntime.buildSceneKey(scene.Scope, ctx) + key, ok := buildSceneKey(scene.Scope, ctx) if !ok { return ErrCantFindSession } @@ -743,7 +744,7 @@ func (ctx *MsgContext) EnterScene(name string) error { } // EnterSceneStep enters the named scene at a specific step. -func (ctx *MsgContext) EnterSceneStep(name, step string) error { +func (ctx *MessageContext) EnterSceneStep(name, step string) error { if ctx.sceneRuntime == nil { return ErrSceneRuntimeNil } @@ -756,7 +757,7 @@ func (ctx *MsgContext) EnterSceneStep(name, step string) error { return ErrSceneStepNotFound } - key, ok := ctx.sceneRuntime.buildSceneKey(scene.Scope, ctx) + key, ok := buildSceneKey(scene.Scope, ctx) if !ok { return ErrCantFindSession } @@ -767,7 +768,7 @@ func (ctx *MsgContext) EnterSceneStep(name, step string) error { } // ExitScene leaves the currently active scene for this context. -func (ctx *MsgContext) ExitScene() error { +func (ctx *MessageContext) ExitScene() error { if ctx.sceneRuntime == nil { return ErrSceneRuntimeNil } @@ -785,7 +786,7 @@ func (ctx *MsgContext) ExitScene() error { return ErrSceneNotFound } - key, ok := ctx.sceneRuntime.buildSceneKey(scene.Scope, ctx) + key, ok := buildSceneKey(scene.Scope, ctx) if !ok { return ErrCantFindSession } @@ -794,16 +795,16 @@ func (ctx *MsgContext) ExitScene() error { } // IsCallback reports whether the context belongs to a callback query. -func (ctx *MsgContext) IsCallback() bool { +func (ctx *MessageContext) IsCallback() bool { return ctx.CallbackQueryID != "" || ctx.CallbackMsgID > 0 || ctx.InlineMsgID != "" } // HasPhoto reports whether the current message contains a photo payload. -func (ctx *MsgContext) HasPhoto() bool { +func (ctx *MessageContext) HasPhoto() bool { return ctx.Msg != nil && ctx.Msg.Photo.Len() > 0 } -func (ctx *MsgContext) upsertKeyboard(text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage { +func (ctx *MessageContext) upsertKeyboard(text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage { if ctx.IsCallback() { if ctx.HasPhoto() { ctx.CallbackDelete() @@ -815,11 +816,11 @@ func (ctx *MsgContext) upsertKeyboard(text string, keyboard *InlineKeyboard, par } // UpsertKeyboard edits a callback message or sends a new plain-text message with a keyboard. -func (ctx *MsgContext) UpsertKeyboard(text string, keyboard *InlineKeyboard) *AnswerMessage { +func (ctx *MessageContext) UpsertKeyboard(text string, keyboard *InlineKeyboard) *AnswerMessage { return ctx.upsertKeyboard(text, keyboard, tgapi.ParseNone) } // UpsertKeyboardMarkdown edits a callback message or sends a new MarkdownV2 message with a keyboard. -func (ctx *MsgContext) UpsertKeyboardMarkdown(text string, keyboard *InlineKeyboard) *AnswerMessage { +func (ctx *MessageContext) UpsertKeyboardMarkdown(text string, keyboard *InlineKeyboard) *AnswerMessage { return ctx.upsertKeyboard(text, keyboard, tgapi.ParseMarkdownV2) } diff --git a/msg_context_test.go b/msg_context_test.go index 3b9f2a6..6da8a93 100644 --- a/msg_context_test.go +++ b/msg_context_test.go @@ -44,7 +44,7 @@ func TestAnswerPhotoIncludesDirectMessagesTopicID(t *testing.T) { } }() - ctx := &MsgContext{ + ctx := &MessageContext{ API: api, Msg: &tgapi.Message{ Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}, @@ -56,6 +56,7 @@ func TestAnswerPhotoIncludesDirectMessagesTopicID(t *testing.T) { answer := ctx.AnswerPhoto("photo-id", "caption") if answer == nil { t.Fatal("expected answer message") + return } if answer.MessageID != 9 { t.Fatalf("unexpected message id: %d", answer.MessageID) @@ -73,7 +74,7 @@ func TestBindArgsBindsScalarFields(t *testing.T) { Name string } - ctx := &MsgContext{Args: []string{"42", "true", "3.5", "Ada", "Lovelace"}} + ctx := &MessageContext{Args: []string{"42", "true", "3.5", "Ada", "Lovelace"}} var got input if err := ctx.BindArgs(&got); err != nil { @@ -92,7 +93,7 @@ func TestBindArgsBindsScalarFields(t *testing.T) { } func TestNewInlineKeyboardButtonUsesContextPayloadType(t *testing.T) { - ctx := &MsgContext{payloadType: BotPayloadBase64} + ctx := &MessageContext{payloadType: BotPayloadBase64} kb := NewInlineKeyboardJSON(1). AddButton(ctx.NewInlineKeyboardButton("A").SetCallbackData("cmd", 1, "two")) @@ -115,7 +116,7 @@ func TestBindArgsLeavesTrailingFieldsZeroWhenArgsRunOut(t *testing.T) { Admin bool } - ctx := &MsgContext{Args: []string{"7"}} + ctx := &MessageContext{Args: []string{"7"}} var got input if err := ctx.BindArgs(&got); err != nil { @@ -134,7 +135,7 @@ func TestBindArgsLeavesTrailingFieldsZeroWhenArgsRunOut(t *testing.T) { } func TestBindArgsRejectsInvalidTargets(t *testing.T) { - ctx := &MsgContext{Args: []string{"1"}} + ctx := &MessageContext{Args: []string{"1"}} if err := ctx.BindArgs(nil); !errors.Is(err, ErrBindArgsTargetNotPointer) { t.Fatalf("expected ErrBindArgsTargetNotPointer for nil target, got %v", err) @@ -151,7 +152,7 @@ func TestBindArgsReportsConversionFailures(t *testing.T) { ID int } - ctx := &MsgContext{Args: []string{"oops"}} + ctx := &MessageContext{Args: []string{"oops"}} var got input err := ctx.BindArgs(&got) @@ -171,7 +172,7 @@ func TestBindArgsRejectsUnsupportedFieldTypes(t *testing.T) { Tags []string } - ctx := &MsgContext{Args: []string{"tag"}} + ctx := &MessageContext{Args: []string{"tag"}} var got input err := ctx.BindArgs(&got) @@ -183,7 +184,37 @@ func TestBindArgsRejectsUnsupportedFieldTypes(t *testing.T) { } } -func TestErrorDefaultRemainsUserVisibleForMessageFlow(t *testing.T) { +func TestErrorDefaultStaysInternalForMessageFlow(t *testing.T) { + client := &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + t.Fatal("unexpected HTTP request for unclassified error") + return nil, nil + }), + } + + api := tgapi.NewAPI( + tgapi.NewAPIOpts("token"). + SetAPIURL("https://example.test"). + SetHTTPClient(client), + ) + defer func() { + if err := api.Close(); err != nil { + t.Fatalf("Close returned error: %v", err) + } + }() + + ctx := &MessageContext{ + API: api, + Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}}, + Logger: sneklog.NewLogger(), + errorTemplate: "Error: %s", + } + + // Unclassified errors must not leak to the user. Only AsUserError replies. + ctx.error(errors.New("boom")) +} + +func TestErrorUserVisibleAnswersForMessageFlow(t *testing.T) { var requests int var gotBody map[string]any @@ -216,14 +247,14 @@ func TestErrorDefaultRemainsUserVisibleForMessageFlow(t *testing.T) { } }() - ctx := &MsgContext{ + ctx := &MessageContext{ API: api, Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}}, Logger: sneklog.NewLogger(), errorTemplate: "Error: %s", } - ctx.error(errors.New("boom")) + ctx.error(AsUserError(errors.New("boom"))) if requests != 1 { t.Fatalf("expected one user-facing error reply, got %d requests", requests) @@ -252,7 +283,7 @@ func TestErrorInternalSkipsUserReplyForMessageFlow(t *testing.T) { } }() - ctx := &MsgContext{ + ctx := &MessageContext{ API: api, Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}}, Logger: sneklog.NewLogger(), @@ -281,7 +312,7 @@ func TestErrorInternalSkipsCallbackAnswer(t *testing.T) { } }() - ctx := &MsgContext{ + ctx := &MessageContext{ API: api, Logger: sneklog.NewLogger(), errorTemplate: "%s", @@ -324,7 +355,7 @@ func TestErrorUserVisibleAnswersCallback(t *testing.T) { } }() - ctx := &MsgContext{ + ctx := &MessageContext{ API: api, Logger: sneklog.NewLogger(), errorTemplate: "Oops: %s", @@ -344,13 +375,13 @@ func TestErrorUserVisibleAnswersCallback(t *testing.T) { func TestIsCallbackIncludesInlineCallbackTargets(t *testing.T) { tests := []struct { name string - ctx MsgContext + ctx MessageContext want bool }{ - {name: "callback query id", ctx: MsgContext{CallbackQueryID: "cb-1"}, want: true}, - {name: "callback message id", ctx: MsgContext{CallbackMsgID: 12}, want: true}, - {name: "inline message id", ctx: MsgContext{InlineMsgID: "inline-1"}, want: true}, - {name: "not callback", ctx: MsgContext{}, want: false}, + {name: "callback query id", ctx: MessageContext{CallbackQueryID: "cb-1"}, want: true}, + {name: "callback message id", ctx: MessageContext{CallbackMsgID: 12}, want: true}, + {name: "inline message id", ctx: MessageContext{InlineMsgID: "inline-1"}, want: true}, + {name: "not callback", ctx: MessageContext{}, want: false}, } for _, tt := range tests { @@ -397,7 +428,7 @@ func TestUpsertKeyboardEditsInlineCallback(t *testing.T) { } }() - ctx := &MsgContext{ + ctx := &MessageContext{ API: api, InlineMsgID: "inline-1", Logger: sneklog.NewLogger(), @@ -426,7 +457,7 @@ func TestUpsertKeyboardEditsInlineCallback(t *testing.T) { } func TestAnswerRejectsEmptyMessage(t *testing.T) { - ctx := &MsgContext{ + ctx := &MessageContext{ Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}}, Logger: sneklog.NewLogger(), } @@ -455,7 +486,7 @@ func TestAnswerRejectsLongMessageWithoutSendingRequest(t *testing.T) { } }() - ctx := &MsgContext{ + ctx := &MessageContext{ API: api, Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}}, Logger: sneklog.NewLogger(), @@ -539,7 +570,7 @@ func TestAnswerLongSplitsRequestsAndAttachesKeyboardToLastChunk(t *testing.T) { } }() - ctx := &MsgContext{ + ctx := &MessageContext{ API: api, Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}}, Logger: sneklog.NewLogger(), diff --git a/msg_handler.go b/msg_handler.go index 5053ddd..9baad5e 100644 --- a/msg_handler.go +++ b/msg_handler.go @@ -7,7 +7,7 @@ import ( "git.scuroneko.dev/scuroneko/laniakea/tgapi" ) -func (bot *Bot[T]) handleMessage(update *tgapi.Update, ctx *MsgContext) bool { +func (bot *Bot[T]) handleMessage(update *tgapi.Update, ctx *MessageContext) bool { text, ok := messageText(update) if !ok { return false @@ -22,7 +22,7 @@ func (bot *Bot[T]) handleMessage(update *tgapi.Update, ctx *MsgContext) bool { if strings.Contains(cmd, "@") { botUsername := bot.username if botUsername != "" && strings.HasSuffix(cmd, "@"+botUsername) { - cmd = cmd[:len(cmd)-len("@"+botUsername)] // убираем @botname + cmd = cmd[:len(cmd)-len("@"+botUsername)] // remove @botname } } // Ищем команду по точному совпадению @@ -30,7 +30,7 @@ func (bot *Bot[T]) handleMessage(update *tgapi.Update, ctx *MsgContext) bool { if _, exists := plugin.commands[cmd]; exists { ctx.Text = args - ctx.Args = strings.Fields(args) // Убирает лишние пробелы + ctx.Args = strings.Fields(args) if plugin.logger != nil { ctx.Logger = plugin.logger @@ -90,7 +90,7 @@ func (bot *Bot[T]) handleMessage(update *tgapi.Update, ctx *MsgContext) bool { return bot.handleFallback(update, ctx) } -func (bot *Bot[T]) handleFallback(update *tgapi.Update, ctx *MsgContext) bool { +func (bot *Bot[T]) handleFallback(update *tgapi.Update, ctx *MessageContext) bool { text, ok := messageText(update) if !ok { return false @@ -180,7 +180,7 @@ func messageText(update *tgapi.Update) (string, bool) { return text, true } -func (bot *Bot[T]) handleCallback(update *tgapi.Update, ctx *MsgContext) bool { +func (bot *Bot[T]) handleCallback(update *tgapi.Update, ctx *MessageContext) bool { data, err := bot.decodePayload(update.CallbackQuery.Data) if err != nil { bot.logger.Errorln(err) diff --git a/plugins.go b/plugins.go index a2ac49a..a7074f6 100644 --- a/plugins.go +++ b/plugins.go @@ -23,6 +23,7 @@ type Plugin[T AppData] struct { middlewares extypes.Slice[Middleware[T]] // Shared middlewares for all commands/payloads skipAutoCmd bool // If true, all commands in this plugin are excluded from auto-help logger *sneklog.Logger + loggerOwned bool // true when the logger was created by the bot during registration; only owned loggers are closed by Close messageFallback CommandExecutor[T] handlers map[tgapi.UpdateType]CommandExecutor[T] @@ -53,6 +54,9 @@ func (p *Plugin[T]) AddCommand(command *Command[T]) *Plugin[T] { } return p } + if _, exists := p.commands[command.command]; exists && p.logger != nil { + p.logger.Warnf("command '%s' is already registered in plugin '%s'; overwriting", command.command, p.name) + } p.commands[command.command] = command return p } @@ -74,6 +78,9 @@ func (p *Plugin[T]) AddPayload(command *Command[T]) *Plugin[T] { } return p } + if _, exists := p.payloads[command.command]; exists && p.logger != nil { + p.logger.Warnf("payload '%s' is already registered in plugin '%s'; overwriting", command.command, p.name) + } p.payloads[command.command] = command return p } @@ -81,7 +88,7 @@ func (p *Plugin[T]) AddPayload(command *Command[T]) *Plugin[T] { // Payload creates and immediately adds a new payload command to the plugin. // Returns the created payload command for further configuration. func (p *Plugin[T]) Payload(command string, exec CommandExecutor[T], args ...CommandArg) *Command[T] { - cmd := NewPayload(command, exec, args...) + cmd := NewCommand(command, exec, args...) p.AddPayload(cmd) return cmd } @@ -101,6 +108,9 @@ func (p *Plugin[T]) AddScene(scene *Scene[T]) *Plugin[T] { } scene.PluginName = p.name scene.setPluginName(p.name) + if _, exists := p.scenes[scene.Name]; exists && p.logger != nil { + p.logger.Warnf("scene '%s' is already registered in plugin '%s'; overwriting", scene.Name, p.name) + } p.scenes[scene.Name] = scene return p } @@ -209,9 +219,13 @@ func (p *Plugin[T]) SetMessageFallback(handler CommandExecutor[T]) *Plugin[T] { // Close releases plugin-owned resources such as its logger and optional // OnClose callback. +// +// Only loggers created by the bot during registration are closed. A logger +// supplied via SetLogger remains the caller's responsibility — the framework +// never closes a logger it does not own. func (p *Plugin[T]) Close() error { var e []error - if p.logger != nil { + if p.logger != nil && p.loggerOwned { if err := p.logger.Close(); err != nil { e = append(e, err) } @@ -225,7 +239,7 @@ func (p *Plugin[T]) Close() error { } // Internal helper that validates and executes a command handler. -func (p *Plugin[T]) executeCmd(cmd string, ctx *MsgContext, db T) error { +func (p *Plugin[T]) executeCmd(cmd string, ctx *MessageContext, db T) error { command, exists := p.commands[cmd] if !exists { return AsInternalError(errCommandNotFound) @@ -247,7 +261,7 @@ func (p *Plugin[T]) executeCmd(cmd string, ctx *MsgContext, db T) error { } // Internal helper that validates and executes a payload handler. -func (p *Plugin[T]) executePayload(payload string, ctx *MsgContext, db T) error { +func (p *Plugin[T]) executePayload(payload string, ctx *MessageContext, db T) error { command, exists := p.payloads[payload] if !exists { return AsInternalError(errPayloadNotFound) @@ -269,7 +283,7 @@ func (p *Plugin[T]) executePayload(payload string, ctx *MsgContext, db T) error } // Internal helper that runs plugin middlewares in order. -func (p *Plugin[T]) executeMiddlewares(ctx *MsgContext, db T) bool { +func (p *Plugin[T]) executeMiddlewares(ctx *MessageContext, db T) bool { for _, m := range p.middlewares { if !m.Execute(ctx, db) { return false @@ -281,14 +295,14 @@ func (p *Plugin[T]) executeMiddlewares(ctx *MsgContext, db T) bool { // MiddlewareExecutor is the function type for middleware logic. // Returns true to continue execution, false to block it. // If async, return value is ignored. -type MiddlewareExecutor[T AppData] func(ctx *MsgContext, db T) bool +type MiddlewareExecutor[T AppData] func(ctx *MessageContext, db T) bool // Middleware represents a reusable execution interceptor. // Can be synchronous (blocking) or asynchronous (non-blocking). type Middleware[T AppData] struct { name string // Human-readable name for logging/debugging executor MiddlewareExecutor[T] // Function to execute - order int // Optional sort order (not used yet) + order int // Sort order for bot-level middleware ordering async bool // If true, runs in goroutine and doesn't block } @@ -313,12 +327,19 @@ func (m Middleware[T]) SetAsync(async bool) Middleware[T] { // Execute runs the middleware. // If async, runs in a goroutine and returns true immediately. // Otherwise, returns the result of the executor. -func (m Middleware[T]) Execute(ctx *MsgContext, db T) bool { +// +// Async note: the goroutine receives a shallow copy of MessageContext, so +// scalar fields (FromID, ChatID, CallbackQueryID, ...) remain a stable +// snapshot. Pointer and slice fields (Msg, From, Chat, API, Logger, Args) +// continue to share storage with the synchronous flow. Async middleware +// must treat those fields as read-only — mutating them races the sync chain +// that mutates the same context concurrently. +func (m Middleware[T]) Execute(ctx *MessageContext, db T) bool { if m.async { - ctx := *ctx // copy context to avoid race condition - go func(ctx MsgContext) { + ctxCopy := *ctx + go func(ctx MessageContext) { m.executor(&ctx, db) - }(ctx) + }(ctxCopy) return true } return m.executor(ctx, db) diff --git a/plugins_test.go b/plugins_test.go index ac12fd8..037b4a3 100644 --- a/plugins_test.go +++ b/plugins_test.go @@ -6,7 +6,7 @@ import ( ) func TestValidateArgsRequiresFullMatch(t *testing.T) { - intCmd := NewCommand("int", func(ctx *MsgContext, db NoData) error { return nil }, NewCommandArg("n").SetValueType(CommandValueInt).SetRequired()) + intCmd := NewCommand("int", func(ctx *MessageContext, db NoData) error { return nil }, NewCommandArg("n").SetValueType(CommandValueInt).SetRequired()) if err := intCmd.validateArgs([]string{"123"}); err != nil { t.Fatalf("expected valid integer argument, got %v", err) } @@ -14,7 +14,7 @@ func TestValidateArgsRequiresFullMatch(t *testing.T) { t.Fatalf("expected ErrCmdArgRegexpMismatch for partial int match, got %v", err) } - boolCmd := NewCommand("bool", func(ctx *MsgContext, db NoData) error { return nil }, NewCommandArg("flag").SetValueType(CommandValueBool).SetRequired()) + boolCmd := NewCommand("bool", func(ctx *MessageContext, db NoData) error { return nil }, NewCommandArg("flag").SetValueType(CommandValueBool).SetRequired()) if err := boolCmd.validateArgs([]string{"false"}); err != nil { t.Fatalf("expected valid bool argument, got %v", err) } @@ -26,7 +26,7 @@ func TestValidateArgsRequiresFullMatch(t *testing.T) { func TestValidateArgsEnforcesRequiredArgIndex(t *testing.T) { cmd := NewCommand( "mixed", - func(ctx *MsgContext, db NoData) error { return nil }, + func(ctx *MessageContext, db NoData) error { return nil }, NewCommandArg("optional"), NewCommandArg("required").SetRequired(), ) @@ -40,9 +40,9 @@ func TestValidateArgsEnforcesRequiredArgIndex(t *testing.T) { } func TestCommandGroupBuildsPrefixedCommandsWithoutMutatingOriginal(t *testing.T) { - groupMiddleware := NewMiddleware("group", func(ctx *MsgContext, db NoData) bool { return true }) - commandMiddleware := NewMiddleware("command", func(ctx *MsgContext, db NoData) bool { return true }) - cmd := NewCommand("ban", func(ctx *MsgContext, db NoData) error { return nil }). + groupMiddleware := NewMiddleware("group", func(ctx *MessageContext, db NoData) bool { return true }) + commandMiddleware := NewMiddleware("command", func(ctx *MessageContext, db NoData) bool { return true }) + cmd := NewCommand("ban", func(ctx *MessageContext, db NoData) error { return nil }). SetDescription("Ban user"). Use(commandMiddleware) @@ -78,9 +78,9 @@ func TestCommandGroupBuildsPrefixedCommandsWithoutMutatingOriginal(t *testing.T) func TestCommandGroupBuildIsRepeatable(t *testing.T) { group := NewCommandGroup[NoData]("admin"). - Use(NewMiddleware("group", func(ctx *MsgContext, db NoData) bool { return true })). - AddCommand(NewCommand("ban", func(ctx *MsgContext, db NoData) error { return nil }). - Use(NewMiddleware("command", func(ctx *MsgContext, db NoData) bool { return true }))) + Use(NewMiddleware("group", func(ctx *MessageContext, db NoData) bool { return true })). + AddCommand(NewCommand("ban", func(ctx *MessageContext, db NoData) error { return nil }). + Use(NewMiddleware("command", func(ctx *MessageContext, db NoData) bool { return true }))) first := group.Build() second := group.Build() @@ -103,7 +103,7 @@ func TestPluginCommandGroupRegistersBuiltCommands(t *testing.T) { plugin := NewPlugin[NoData]("admin") plugin.CommandGroup("admin_", func(group *CommandGroup[NoData]) { - group.AddCommand(NewCommand("ban", func(ctx *MsgContext, db NoData) error { return nil })) + group.AddCommand(NewCommand("ban", func(ctx *MessageContext, db NoData) error { return nil })) }) if _, ok := plugin.commands["admin_ban"]; !ok { diff --git a/policy.go b/policy.go index 9ea88a7..722c179 100644 --- a/policy.go +++ b/policy.go @@ -8,11 +8,11 @@ import ( ) // Policy defines a reusable authorization rule for the current update context. -type Policy[T AppData] func(ctx *MsgContext, data T) error +type Policy[T AppData] func(ctx *MessageContext, data T) error // RequirePolicy adapts a Policy into a blocking middleware. func RequirePolicy[T AppData](name string, p Policy[T]) Middleware[T] { - return NewMiddleware(name, func(ctx *MsgContext, data T) bool { + return NewMiddleware(name, func(ctx *MessageContext, data T) bool { if err := p(ctx, data); err != nil { ctx.emitPolicyChecked(PolicyCheckedEvent{ Name: name, @@ -37,7 +37,7 @@ func RequirePolicy[T AppData](name string, p Policy[T]) Middleware[T] { // AllPolicies composes policies that all must succeed. func AllPolicies[T AppData](policies ...Policy[T]) Policy[T] { - return func(ctx *MsgContext, data T) error { + return func(ctx *MessageContext, data T) error { for _, p := range policies { if err := p(ctx, data); err != nil { return err @@ -49,7 +49,7 @@ func AllPolicies[T AppData](policies ...Policy[T]) Policy[T] { // AnyPolicy composes policies where at least one must succeed. func AnyPolicy[T AppData](policies ...Policy[T]) Policy[T] { - return func(ctx *MsgContext, data T) error { + return func(ctx *MessageContext, data T) error { var firstDeny error var internalErr error for _, p := range policies { @@ -79,7 +79,7 @@ func AnyPolicy[T AppData](policies ...Policy[T]) Policy[T] { // NotPolicy inverts a policy deny result while preserving internal failures. func NotPolicy[T AppData](policy Policy[T]) Policy[T] { - return func(ctx *MsgContext, data T) error { + return func(ctx *MessageContext, data T) error { var err error if err = policy(ctx, data); err == nil { return AsUserError(errors.New("the action is not allowed due to policy violation")) @@ -93,7 +93,7 @@ func NotPolicy[T AppData](policy Policy[T]) Policy[T] { // RequirePrivateChat allows execution only in private chats. func RequirePrivateChat[T AppData]() Policy[T] { - return func(ctx *MsgContext, data T) error { + return func(ctx *MessageContext, data T) error { if ctx.Msg == nil || ctx.Msg.Chat == nil { return AsInternalError(errors.New("private-chat policy requires message chat context")) } @@ -108,7 +108,7 @@ func RequirePrivateChat[T AppData]() Policy[T] { // RequireGroupChat allows execution only in group or supergroup chats. func RequireGroupChat[T AppData]() Policy[T] { - return func(ctx *MsgContext, data T) error { + return func(ctx *MessageContext, data T) error { if ctx.Msg == nil || ctx.Msg.Chat == nil { return AsInternalError(errors.New("group-chat policy requires message chat context")) } @@ -123,7 +123,7 @@ func RequireGroupChat[T AppData]() Policy[T] { // RequireSupergroupChat allows execution only in supergroup chats. func RequireSupergroupChat[T AppData]() Policy[T] { - return func(ctx *MsgContext, data T) error { + return func(ctx *MessageContext, data T) error { if ctx.Msg == nil || ctx.Msg.Chat == nil { return AsInternalError(errors.New("supergroup-chat policy requires message chat context")) } @@ -138,7 +138,7 @@ func RequireSupergroupChat[T AppData]() Policy[T] { // RequireChatAdmin allows execution only for chat administrators or owners. func RequireChatAdmin[T AppData]() Policy[T] { - return func(ctx *MsgContext, data T) error { + return func(ctx *MessageContext, data T) error { if ctx.FromID == 0 || ctx.ChatID == 0 { return AsInternalError(errors.New("chat-admin policy requires message chat context")) } @@ -161,7 +161,7 @@ func RequireChatAdmin[T AppData]() Policy[T] { // RequireChatCreator allows execution only for the chat owner. func RequireChatCreator[T AppData]() Policy[T] { - return func(ctx *MsgContext, data T) error { + return func(ctx *MessageContext, data T) error { if ctx.FromID == 0 || ctx.ChatID == 0 { return AsInternalError(errors.New("chat-creator policy requires message chat context")) } @@ -184,19 +184,16 @@ func RequireChatCreator[T AppData]() Policy[T] { // RequireBotAdmin allows execution only when the bot is an admin in the chat. func RequireBotAdmin[T AppData]() Policy[T] { - return func(ctx *MsgContext, data T) error { + return func(ctx *MessageContext, data T) error { if ctx.ChatID == 0 { return AsInternalError(errors.New("bot-admin policy requires message chat context")) } - - bot, err := ctx.API.GetMe() - if err != nil { - return AsInternalError(fmt.Errorf("failed to fetch bot info: %w", err)) + if ctx.botID == 0 { + return AsInternalError(errors.New("bot ID is not set in context")) } member, err := ctx.API.GetChatMember(tgapi.GetChatMember{ - ChatID: ctx.ChatID, - UserID: bot.ID, + ChatID: ctx.ChatID, UserID: ctx.botID, }) if err != nil { return AsInternalError(fmt.Errorf("failed to fetch bot member status: %w", err)) @@ -212,7 +209,7 @@ func RequireBotAdmin[T AppData]() Policy[T] { // RequireCallbackFromUser allows execution only for callback queries sent by non-bot users. func RequireCallbackFromUser[T AppData]() Policy[T] { - return func(ctx *MsgContext, data T) error { + return func(ctx *MessageContext, data T) error { if ctx.Update.CallbackQuery == nil { return AsInternalError(errors.New("callback-user policy requires callback query context")) } diff --git a/policy_test.go b/policy_test.go index d48d508..9338884 100644 --- a/policy_test.go +++ b/policy_test.go @@ -46,14 +46,14 @@ func TestRequirePolicyStopsExecutionOnDeniedPolicy(t *testing.T) { } }() - ctx := &MsgContext{ + ctx := &MessageContext{ API: api, Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}}, Logger: sneklog.NewLogger(), errorTemplate: "Error: %s", } - mw := RequirePolicy("deny", func(ctx *MsgContext, data NoData) error { + mw := RequirePolicy("deny", func(ctx *MessageContext, data NoData) error { return AsUserError(errors.New("blocked")) }) @@ -69,7 +69,7 @@ func TestRequirePolicyStopsExecutionOnDeniedPolicy(t *testing.T) { } func TestRequirePrivateChatAllowsPrivateChat(t *testing.T) { - ctx := &MsgContext{ + ctx := &MessageContext{ Msg: &tgapi.Message{ Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}, }, @@ -82,7 +82,7 @@ func TestRequirePrivateChatAllowsPrivateChat(t *testing.T) { } func TestRequirePrivateChatDeniesNonPrivateChat(t *testing.T) { - ctx := &MsgContext{ + ctx := &MessageContext{ Msg: &tgapi.Message{ Chat: &tgapi.Chat{ID: -100, Type: tgapi.ChatTypeSupergroup}, }, @@ -136,7 +136,7 @@ func TestRequireChatAdminUsesNormalizedIDs(t *testing.T) { } }() - ctx := &MsgContext{ + ctx := &MessageContext{ API: api, ChatID: -2001, FromID: 55, @@ -160,15 +160,15 @@ func TestRequireChatAdminUsesNormalizedIDs(t *testing.T) { func TestAllPoliciesReturnsFirstError(t *testing.T) { want := AsUserError(errors.New("blocked")) policy := AllPolicies( - func(ctx *MsgContext, data NoData) error { return nil }, - func(ctx *MsgContext, data NoData) error { return want }, - func(ctx *MsgContext, data NoData) error { + func(ctx *MessageContext, data NoData) error { return nil }, + func(ctx *MessageContext, data NoData) error { return want }, + func(ctx *MessageContext, data NoData) error { t.Fatal("unexpected evaluation after first failure") return nil }, ) - err := policy(&MsgContext{Logger: sneklog.NewLogger()}, NoData{}) + err := policy(&MessageContext{Logger: sneklog.NewLogger()}, NoData{}) if !errors.Is(err, want) { t.Fatalf("expected first policy error, got %v", err) } @@ -176,11 +176,11 @@ func TestAllPoliciesReturnsFirstError(t *testing.T) { func TestAnyPolicyAllowsLaterSuccessAfterInternalError(t *testing.T) { policy := AnyPolicy( - func(ctx *MsgContext, data NoData) error { return AsInternalError(errors.New("temporary")) }, - func(ctx *MsgContext, data NoData) error { return nil }, + func(ctx *MessageContext, data NoData) error { return AsInternalError(errors.New("temporary")) }, + func(ctx *MessageContext, data NoData) error { return nil }, ) - if err := policy(&MsgContext{Logger: sneklog.NewLogger()}, NoData{}); err != nil { + if err := policy(&MessageContext{Logger: sneklog.NewLogger()}, NoData{}); err != nil { t.Fatalf("expected later success to allow access, got %v", err) } } @@ -188,11 +188,11 @@ func TestAnyPolicyAllowsLaterSuccessAfterInternalError(t *testing.T) { func TestAnyPolicyReturnsInternalErrorWhenNonePass(t *testing.T) { internal := AsInternalError(errors.New("temporary")) policy := AnyPolicy( - func(ctx *MsgContext, data NoData) error { return AsUserError(errors.New("denied")) }, - func(ctx *MsgContext, data NoData) error { return internal }, + func(ctx *MessageContext, data NoData) error { return AsUserError(errors.New("denied")) }, + func(ctx *MessageContext, data NoData) error { return internal }, ) - err := policy(&MsgContext{Logger: sneklog.NewLogger()}, NoData{}) + err := policy(&MessageContext{Logger: sneklog.NewLogger()}, NoData{}) if !errors.Is(err, internal) { t.Fatalf("expected internal error, got %v", err) } @@ -201,29 +201,29 @@ func TestAnyPolicyReturnsInternalErrorWhenNonePass(t *testing.T) { func TestAnyPolicyReturnsFirstDenyWhenNoPolicyPasses(t *testing.T) { first := AsUserError(errors.New("first deny")) policy := AnyPolicy( - func(ctx *MsgContext, data NoData) error { return first }, - func(ctx *MsgContext, data NoData) error { return AsUserError(errors.New("second deny")) }, + func(ctx *MessageContext, data NoData) error { return first }, + func(ctx *MessageContext, data NoData) error { return AsUserError(errors.New("second deny")) }, ) - err := policy(&MsgContext{Logger: sneklog.NewLogger()}, NoData{}) + err := policy(&MessageContext{Logger: sneklog.NewLogger()}, NoData{}) if !errors.Is(err, first) { t.Fatalf("expected first deny error, got %v", err) } } func TestNotPolicyInvertsUserDenyButPreservesInternalErrors(t *testing.T) { - inverted := NotPolicy(func(ctx *MsgContext, data NoData) error { + inverted := NotPolicy(func(ctx *MessageContext, data NoData) error { return AsUserError(errors.New("denied")) }) - if err := inverted(&MsgContext{Logger: sneklog.NewLogger()}, NoData{}); err != nil { + if err := inverted(&MessageContext{Logger: sneklog.NewLogger()}, NoData{}); err != nil { t.Fatalf("expected inverted deny to succeed, got %v", err) } internal := AsInternalError(errors.New("temporary")) - preserve := NotPolicy(func(ctx *MsgContext, data NoData) error { + preserve := NotPolicy(func(ctx *MessageContext, data NoData) error { return internal }) - err := preserve(&MsgContext{Logger: sneklog.NewLogger()}, NoData{}) + err := preserve(&MessageContext{Logger: sneklog.NewLogger()}, NoData{}) if !errors.Is(err, internal) { t.Fatalf("expected internal error to be preserved, got %v", err) } @@ -232,7 +232,7 @@ func TestNotPolicyInvertsUserDenyButPreservesInternalErrors(t *testing.T) { func TestRequirePolicyEmitsObserverEvents(t *testing.T) { t.Run("allow", func(t *testing.T) { observer := &recordingObserver{} - ctx := &MsgContext{ + ctx := &MessageContext{ Logger: sneklog.NewLogger(), ctx: context.Background(), observer: observer, @@ -240,7 +240,7 @@ func TestRequirePolicyEmitsObserverEvents(t *testing.T) { ChatID: 20, } - mw := RequirePolicy("allow", func(ctx *MsgContext, data NoData) error { + mw := RequirePolicy("allow", func(ctx *MessageContext, data NoData) error { return nil }) @@ -257,14 +257,14 @@ func TestRequirePolicyEmitsObserverEvents(t *testing.T) { t.Run("deny", func(t *testing.T) { observer := &recordingObserver{} - ctx := &MsgContext{ + ctx := &MessageContext{ Logger: sneklog.NewLogger(), ctx: context.Background(), observer: observer, errorTemplate: "%s", } - mw := RequirePolicy("deny", func(ctx *MsgContext, data NoData) error { + mw := RequirePolicy("deny", func(ctx *MessageContext, data NoData) error { return AsInternalError(errors.New("blocked")) }) diff --git a/runners.go b/runners.go index 72d075f..883280b 100644 --- a/runners.go +++ b/runners.go @@ -161,23 +161,30 @@ func (bot *Bot[T]) ExecRunners(ctx context.Context) { case <-ctx.Done(): return case <-ticker.C: - startedAt := time.Now() - err := r.fn(bot) - bot.safeEmitEvent(ctx, RunnerFinishedEvent{ - Name: r.name, - Duration: time.Since(startedAt), - Err: err, + } + // When both ctx.Done() and ticker.C are ready at the same + // time, Go's select picks one at random. Re-check ctx so a + // late tick after cancellation does not fire one extra + // invocation past shutdown. + if ctx.Err() != nil { + return + } + startedAt := time.Now() + err := r.fn(bot) + bot.safeEmitEvent(ctx, RunnerFinishedEvent{ + Name: r.name, + Duration: time.Since(startedAt), + Err: err, + }) + if err != nil { + bot.safeEmitEvent(ctx, ErrorEvent{ + Plugin: "bot", + HandlerKind: HandlerRunnerKind, + HandlerName: r.name, + Err: err, + UserFacing: false, }) - if err != nil { - bot.safeEmitEvent(ctx, ErrorEvent{ - Plugin: "bot", - HandlerKind: HandlerRunnerKind, - HandlerName: r.name, - Err: err, - UserFacing: false, - }) - bot.logger.Warnf("Runner %s failed: %s\n", r.name, err) - } + bot.logger.Warnf("Runner %s failed: %s\n", r.name, err) } } }(runner) diff --git a/scene.go b/scene.go index a868d31..c083253 100644 --- a/scene.go +++ b/scene.go @@ -15,7 +15,7 @@ type Scene[T any] struct { Name string // Scope controls how active scene sessions are keyed and shared. Scope SceneScope - // Entry names the first step used by MsgContext.EnterScene. + // Entry names the first step used by MessageContext.EnterScene. Entry string // PluginName stores the owning plugin name for scene resolution. PluginName string @@ -45,7 +45,7 @@ func (s *Scene[T]) SetScope(scope SceneScope) *Scene[T] { return s } -// SetEntry sets the initial step entered by MsgContext.EnterScene. +// SetEntry sets the initial step entered by MessageContext.EnterScene. func (s *Scene[T]) SetEntry(step string) *Scene[T] { s.Entry = step return s @@ -260,8 +260,7 @@ type sceneRuntime interface { getSession(key string) (SceneSession, error) setSession(key string, session SceneSession) error deleteSession(key string) error - buildSceneKey(scope SceneScope, ctx *MsgContext) (string, bool) - findSceneSession(ctx *MsgContext) (string, SceneSession, error) + findSceneSession(ctx *MessageContext) (string, SceneSession, error) } type sceneMeta struct { diff --git a/scene_context.go b/scene_context.go index a3ef20b..dc4da74 100644 --- a/scene_context.go +++ b/scene_context.go @@ -1,8 +1,8 @@ package laniakea -// SceneContext wraps MsgContext with scene session state for scene handlers. +// SceneContext wraps MessageContext with scene session state for scene handlers. type SceneContext struct { - *MsgContext + *MessageContext sess SceneSession key string } diff --git a/scene_handler.go b/scene_handler.go index b4c0c22..fb22545 100644 --- a/scene_handler.go +++ b/scene_handler.go @@ -7,10 +7,10 @@ import ( "time" ) -func (bot *Bot[T]) tryHandleScene(ctx *MsgContext) (bool, error) { +func (bot *Bot[T]) tryHandleScene(ctx *MessageContext) (bool, error) { key, session, err := bot.findSceneSession(ctx) if err != nil { - if errors.Is(err, ErrCantFindSession) || errors.Is(err, ErrMessageNil) { + if errors.Is(err, ErrCantFindSession) { return false, nil } return false, err @@ -31,9 +31,9 @@ func (bot *Bot[T]) tryHandleScene(ctx *MsgContext) (bool, error) { return false, nil } sceneCtx := &SceneContext{ - MsgContext: ctx, - sess: session, - key: key, + MessageContext: ctx, + sess: session, + key: key, } return bot.executeScene(sceneCtx, scene) @@ -42,7 +42,7 @@ func (bot *Bot[T]) tryHandleScene(ctx *MsgContext) (bool, error) { } func (bot *Bot[T]) executeScene(ctx *SceneContext, scene *Scene[T]) (bool, error) { - if ctx.MsgContext == nil || ctx.sess.Scene == "" { + if ctx.MessageContext == nil || ctx.sess.Scene == "" { return false, nil } @@ -269,7 +269,7 @@ func (bot *Bot[T]) applySceneResult(scene *Scene[T], ctx *SceneContext, result S return false, nil } } -func buildSceneKey(scope SceneScope, ctx *MsgContext) (string, bool) { +func buildSceneKey(scope SceneScope, ctx *MessageContext) (string, bool) { if ctx == nil { return "", false } diff --git a/scene_test.go b/scene_test.go index bde5f10..a641f03 100644 --- a/scene_test.go +++ b/scene_test.go @@ -71,7 +71,7 @@ func TestBotAddPluginsPreservesScenesAndHandlesThem(t *testing.T) { t.Fatalf("unexpected scene entry: got %q want %q", sceneMeta.Entry, "start") } - enterCtx := &MsgContext{ + enterCtx := &MessageContext{ Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}}, FromID: 42, sceneRuntime: bot, @@ -95,7 +95,7 @@ func TestBotAddPluginsPreservesScenesAndHandlesThem(t *testing.T) { t.Fatal("expected scene step handler to be called") } - lookupCtx := &MsgContext{ + lookupCtx := &MessageContext{ Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}}, FromID: 42, } @@ -108,7 +108,7 @@ func TestBuildSceneKeyRejectsMissingContextFields(t *testing.T) { tests := []struct { name string scope SceneScope - ctx *MsgContext + ctx *MessageContext }{ { name: "nil context", @@ -118,17 +118,17 @@ func TestBuildSceneKeyRejectsMissingContextFields(t *testing.T) { { name: "missing message for chat scope", scope: SceneScopeChat, - ctx: &MsgContext{}, + ctx: &MessageContext{}, }, { name: "missing from id for user scope", scope: SceneScopeUser, - ctx: &MsgContext{}, + ctx: &MessageContext{}, }, { name: "missing from id for user chat scope", scope: SceneScopeUserChat, - ctx: &MsgContext{ + ctx: &MessageContext{ Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}}, }, }, @@ -155,7 +155,7 @@ func TestEnterSceneRejectsMissingEntryConfiguration(t *testing.T) { } bot.AddPlugins(plugin) - ctx := &MsgContext{ + ctx := &MessageContext{ Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}}, FromID: 42, sceneRuntime: bot, @@ -178,7 +178,7 @@ func TestEnterSceneRejectsMissingEntryConfiguration(t *testing.T) { } bot.AddPlugins(plugin) - ctx := &MsgContext{ + ctx := &MessageContext{ Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}}, FromID: 42, sceneRuntime: bot, @@ -192,7 +192,7 @@ func TestEnterSceneRejectsMissingEntryConfiguration(t *testing.T) { } func TestSceneContextMethodsRequireRuntime(t *testing.T) { - ctx := &MsgContext{} + ctx := &MessageContext{} if err := ctx.EnterScene("signup"); !errors.Is(err, ErrSceneRuntimeNil) { t.Fatalf("expected ErrSceneRuntimeNil from EnterScene, got %v", err) @@ -238,7 +238,7 @@ func TestSceneCommandHandlerRunsBeforeStep(t *testing.T) { } bot.AddPlugins(plugin) - enterCtx := &MsgContext{ + enterCtx := &MessageContext{ Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}}, FromID: 42, sceneRuntime: bot, @@ -287,7 +287,7 @@ func TestSceneCommandObserverEmitsLifecycleEvents(t *testing.T) { } bot.AddPlugins(plugin) - enterCtx := &MsgContext{ + enterCtx := &MessageContext{ Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}}, FromID: 42, sceneRuntime: bot, @@ -339,7 +339,7 @@ func TestSceneStepObserverEmitsLifecycleEvents(t *testing.T) { } bot.AddPlugins(plugin) - enterCtx := &MsgContext{ + enterCtx := &MessageContext{ Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}}, FromID: 42, sceneRuntime: bot, @@ -394,7 +394,7 @@ func TestSceneMessageObserverEmitsLifecycleEvents(t *testing.T) { } bot.AddPlugins(plugin) - key, ok := buildSceneKey(SceneScopeUserChat, &MsgContext{ + key, ok := buildSceneKey(SceneScopeUserChat, &MessageContext{ Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}}, FromID: 42, }) @@ -460,7 +460,7 @@ func TestScenePayloadHandlerRunsBeforeStep(t *testing.T) { } bot.AddPlugins(plugin) - enterCtx := &MsgContext{ + enterCtx := &MessageContext{ Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}}, FromID: 42, sceneRuntime: bot, @@ -517,7 +517,7 @@ func TestScenePayloadObserverEmitsLifecycleEvents(t *testing.T) { } bot.AddPlugins(plugin) - enterCtx := &MsgContext{ + enterCtx := &MessageContext{ Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}}, FromID: 42, sceneRuntime: bot, @@ -563,7 +563,7 @@ func TestSceneUnmatchedPayloadFallsThroughWithoutRunningStep(t *testing.T) { stepCalled := false plugin := NewPlugin[NoData]("wizard") - plugin.Payload("ping", func(ctx *MsgContext, db NoData) error { return nil }) + plugin.Payload("ping", func(ctx *MessageContext, db NoData) error { return nil }) plugin.Scene("signup"). SetEntry("start"). OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) { @@ -579,7 +579,7 @@ func TestSceneUnmatchedPayloadFallsThroughWithoutRunningStep(t *testing.T) { } bot.AddPlugins(plugin) - enterCtx := &MsgContext{ + enterCtx := &MessageContext{ Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}}, FromID: 42, sceneRuntime: bot, @@ -588,7 +588,7 @@ func TestSceneUnmatchedPayloadFallsThroughWithoutRunningStep(t *testing.T) { t.Fatalf("EnterScene returned error: %v", err) } - key, ok := buildSceneKey(SceneScopeUserChat, &MsgContext{ + key, ok := buildSceneKey(SceneScopeUserChat, &MessageContext{ Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}}, FromID: 42, }) @@ -632,7 +632,7 @@ func TestScenePassDoesNotPersistSessionData(t *testing.T) { commandCalled := false plugin := NewPlugin[NoData]("wizard") - plugin.Command("ping", func(ctx *MsgContext, db NoData) error { + plugin.Command("ping", func(ctx *MessageContext, db NoData) error { commandCalled = true return nil }) @@ -655,7 +655,7 @@ func TestScenePassDoesNotPersistSessionData(t *testing.T) { } bot.AddPlugins(plugin) - enterCtx := &MsgContext{ + enterCtx := &MessageContext{ Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}}, FromID: 42, sceneRuntime: bot, @@ -664,7 +664,7 @@ func TestScenePassDoesNotPersistSessionData(t *testing.T) { t.Fatalf("EnterScene returned error: %v", err) } - key, ok := buildSceneKey(SceneScopeUserChat, &MsgContext{ + key, ok := buildSceneKey(SceneScopeUserChat, &MessageContext{ Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}}, FromID: 42, }) @@ -718,7 +718,7 @@ func TestSceneUnmatchedCommandFallsThroughWithoutRunningStep(t *testing.T) { stepCalled = true return ctx.Stay(), nil }) - plugin.Command("ping", func(ctx *MsgContext, db NoData) error { + plugin.Command("ping", func(ctx *MessageContext, db NoData) error { commandCalled = true return nil }) @@ -731,7 +731,7 @@ func TestSceneUnmatchedCommandFallsThroughWithoutRunningStep(t *testing.T) { } bot.AddPlugins(plugin) - enterCtx := &MsgContext{ + enterCtx := &MessageContext{ Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}}, FromID: 42, sceneRuntime: bot, @@ -740,7 +740,7 @@ func TestSceneUnmatchedCommandFallsThroughWithoutRunningStep(t *testing.T) { t.Fatalf("EnterScene returned error: %v", err) } - key, ok := buildSceneKey(SceneScopeUserChat, &MsgContext{ + key, ok := buildSceneKey(SceneScopeUserChat, &MessageContext{ Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}}, FromID: 42, }) @@ -800,7 +800,7 @@ func TestSceneMessageFallbackRunsWhenNoCommandOrStepMatch(t *testing.T) { } bot.AddPlugins(plugin) - enterCtx := &MsgContext{ + enterCtx := &MessageContext{ Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}}, FromID: 42, sceneRuntime: bot, @@ -809,7 +809,7 @@ func TestSceneMessageFallbackRunsWhenNoCommandOrStepMatch(t *testing.T) { t.Fatalf("EnterScene returned error: %v", err) } - key, ok := buildSceneKey(SceneScopeUserChat, &MsgContext{ + key, ok := buildSceneKey(SceneScopeUserChat, &MessageContext{ Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}}, FromID: 42, }) @@ -847,7 +847,7 @@ func TestFindSceneSessionSupportsUserScopeWithoutMessage(t *testing.T) { t.Fatalf("Set returned error: %v", err) } - key, session, err := bot.findSceneSession(&MsgContext{FromID: 42}) + key, session, err := bot.findSceneSession(&MessageContext{FromID: 42}) if err != nil { t.Fatalf("findSceneSession returned error: %v", err) } @@ -870,7 +870,7 @@ func TestSceneStoreErrorsPropagate(t *testing.T) { sceneScopePriority: []SceneScope{SceneScopeUser}, } - _, _, err := bot.findSceneSession(&MsgContext{FromID: 42}) + _, _, err := bot.findSceneSession(&MessageContext{FromID: 42}) if !errors.Is(err, getErr) { t.Fatalf("expected getErr, got %v", err) } @@ -887,9 +887,9 @@ func TestSceneStoreErrorsPropagate(t *testing.T) { } _, err := bot.applySceneResult(scene, &SceneContext{ - MsgContext: &MsgContext{}, - sess: SceneSession{Scene: "signup", Step: "start"}, - key: "user_id:42:chat_id:100", + MessageContext: &MessageContext{}, + sess: SceneSession{Scene: "signup", Step: "start"}, + key: "user_id:42:chat_id:100", }, SceneResult{Action: SceneActionStay}) if !errors.Is(err, setErr) { t.Fatalf("expected setErr, got %v", err) diff --git a/update_context.go b/update_context.go index cb2167b..39c21fe 100644 --- a/update_context.go +++ b/update_context.go @@ -6,7 +6,7 @@ import ( "git.scuroneko.dev/scuroneko/laniakea/tgapi" ) -func (bot *Bot[T]) handleUpdate(u *tgapi.Update, ctx *MsgContext) bool { +func (bot *Bot[T]) handleUpdate(u *tgapi.Update, ctx *MessageContext) bool { handled := false for _, plugin := range bot.plugins { handler, ok := plugin.handlers[u.Type] @@ -66,7 +66,7 @@ func (bot *Bot[T]) handleUpdate(u *tgapi.Update, ctx *MsgContext) bool { return handled } -func (bot *Bot[T]) prepareUpdateCtx(u *tgapi.Update, ctx *MsgContext) { +func (bot *Bot[T]) prepareUpdateCtx(u *tgapi.Update, ctx *MessageContext) { var from *tgapi.User var chat *tgapi.Chat switch u.Type { diff --git a/utils/limiter.go b/utils/limiter.go index ed72897..698d692 100644 --- a/utils/limiter.go +++ b/utils/limiter.go @@ -16,6 +16,10 @@ var ErrDropOverflow = errors.New("drop overflow limit") // It supports two modes: // - "drop" mode: immediately reject if limits are exceeded. // - "wait" mode: block until capacity is available. +// +// Per-chat limiters are created lazily and accumulate indefinitely. Call Cleanup +// periodically (e.g. from a background runner) to evict idle entries and prevent +// unbounded memory growth in bots that serve many distinct chats. type RateLimiter struct { globalLockUntil time.Time // global cooldown timestamp (set by API errors) globalLimiter *rate.Limiter // global token bucket (30 req/sec) @@ -23,7 +27,8 @@ type RateLimiter struct { chatLocks map[int64]time.Time // per-chat cooldown timestamps chatLimiters map[int64]*rate.Limiter // per-chat token buckets (1 req/sec) - chatMu sync.RWMutex // protects chatLocks and chatLimiters + chatLastSeen map[int64]time.Time // last access timestamp per chat, for Cleanup eviction + chatMu sync.RWMutex // protects chatLocks, chatLimiters, and chatLastSeen } // NewRateLimiter creates a new RateLimiter with default limits. @@ -34,6 +39,32 @@ func NewRateLimiter() *RateLimiter { globalLimiter: rate.NewLimiter(30, 30), chatLimiters: make(map[int64]*rate.Limiter), chatLocks: make(map[int64]time.Time), + chatLastSeen: make(map[int64]time.Time), + } +} + +// Cleanup removes per-chat limiter state that has not been touched within +// idleThreshold and chat cooldowns whose expiry has already passed. +// +// Safe to call concurrently with Wait/Allow. Intended for periodic invocation +// from a background runner (e.g. once a minute) to bound memory in long-running +// bots that serve many distinct chats. +func (rl *RateLimiter) Cleanup(idleThreshold time.Duration) { + now := time.Now() + rl.chatMu.Lock() + defer rl.chatMu.Unlock() + + for chatID, lastSeen := range rl.chatLastSeen { + if now.Sub(lastSeen) <= idleThreshold { + continue + } + delete(rl.chatLimiters, chatID) + delete(rl.chatLastSeen, chatID) + } + for chatID, until := range rl.chatLocks { + if !until.After(now) { + delete(rl.chatLocks, chatID) + } } } @@ -228,14 +259,28 @@ func (rl *RateLimiter) waitForChatUnlock(ctx context.Context, chatID int64) erro } // Internal helper that returns or creates a per-chat limiter. +// Updates chatLastSeen so Cleanup can evict idle entries. func (rl *RateLimiter) getChatLimiter(chatID int64) *rate.Limiter { - rl.chatMu.Lock() - defer rl.chatMu.Unlock() + now := time.Now() - if lim, ok := rl.chatLimiters[chatID]; ok { + rl.chatMu.RLock() + lim, ok := rl.chatLimiters[chatID] + rl.chatMu.RUnlock() + if ok { + rl.chatMu.Lock() + rl.chatLastSeen[chatID] = now + rl.chatMu.Unlock() return lim } - lim := rate.NewLimiter(1, 1) + + rl.chatMu.Lock() + defer rl.chatMu.Unlock() + if lim, ok := rl.chatLimiters[chatID]; ok { + rl.chatLastSeen[chatID] = now + return lim + } + lim = rate.NewLimiter(1, 1) rl.chatLimiters[chatID] = lim + rl.chatLastSeen[chatID] = now return lim } diff --git a/utils/limiter_test.go b/utils/limiter_test.go index ab69db9..adef0c5 100644 --- a/utils/limiter_test.go +++ b/utils/limiter_test.go @@ -39,3 +39,52 @@ func TestRateLimiterGlobalWaitRespectsContextCancellation(t *testing.T) { t.Fatalf("expected DeadlineExceeded, got %v", err) } } + +// TestRateLimiterCleanupEvictsIdleChats guards the memory-leak fix: per-chat +// limiter and lastSeen state must be reclaimed by Cleanup once the entry has +// been idle for longer than the threshold, while still-active chats and +// unexpired cooldowns must survive. +func TestRateLimiterCleanupEvictsIdleChats(t *testing.T) { + rl := NewRateLimiter() + + // Touch chat 1 to make it tracked, then backdate its last-seen marker + // so it looks idle from Cleanup's perspective. + if !rl.Allow(1) { + t.Fatal("expected initial Allow for chat 1 to succeed") + } + rl.chatMu.Lock() + rl.chatLastSeen[1] = time.Now().Add(-time.Hour) + rl.chatMu.Unlock() + + // Touch chat 2 so it stays "active". + if !rl.Allow(2) { + t.Fatal("expected initial Allow for chat 2 to succeed") + } + + // Expired cooldown should be evicted; future cooldown should survive. + rl.SetChatLock(10, 1) + rl.chatMu.Lock() + rl.chatLocks[10] = time.Now().Add(-time.Second) + rl.chatLocks[11] = time.Now().Add(time.Hour) + rl.chatMu.Unlock() + + rl.Cleanup(time.Minute) + + rl.chatMu.RLock() + defer rl.chatMu.RUnlock() + if _, ok := rl.chatLimiters[1]; ok { + t.Fatal("expected idle chat 1 limiter to be evicted") + } + if _, ok := rl.chatLastSeen[1]; ok { + t.Fatal("expected idle chat 1 lastSeen to be evicted") + } + if _, ok := rl.chatLimiters[2]; !ok { + t.Fatal("expected active chat 2 limiter to remain") + } + if _, ok := rl.chatLocks[10]; ok { + t.Fatal("expected expired chat 10 lock to be evicted") + } + if _, ok := rl.chatLocks[11]; !ok { + t.Fatal("expected future chat 11 lock to remain") + } +}