diff --git a/TODO.md b/TODO.md index d5bc7d9..63099cf 100644 --- a/TODO.md +++ b/TODO.md @@ -1,5 +1,54 @@ # TODO +## v1.0.0 pre-release review + +Findings from the full-repo review against `AGENTS.md` priorities. Build, vet, tests, and lint are clean; items below are public-API and godoc hygiene before the stable tag. + +### Major — close before 1.0.0 tag + +- [X] **M1. `BotPayloadType*` are `var`, must be `const`** — `bot.go:50-59`. Public sentinels are user-mutable globals. `KeyboardButtonStyle*` in `keyboard.go:10-17` already uses `const`; match the pattern. +- [X] **M2. `Observer` method naming asymmetry** — `observer.go:147-157`. `OnReceiveUpdate` → `OnUpdateReceived`; `OnHandledUpdate` → `OnUpdateHandled` to match `UpdateReceivedEvent` / `UpdateHandledEvent` and the rest of the `OnX` pattern. Breaking after 1.0. +- [X] **M3. Uploader returns ad-hoc error string instead of `*ResponseError`** — `tgapi/uploader_api.go:183`. `tgapi/api.go:258-292` returns `*ResponseError`; uploader must do the same so `errors.As(err, &tgapi.ResponseError{})` works for upload paths too. +- [X] **M4. `BotOptsFileJSON` is missing `PollTimeout`** — `bot_opts_loader.go:35-46`, plus `FromBytes`/`ToBytes` mapping. File round-trip silently drops `PollTimeout`. +- [X] **M5. Stale `Bot.Updates` godoc** — `methods.go:11-44`. Claims "30-second timeout" and "empty slice if none"; in reality timeout is `bot.pollTimeout` and the function returns `nil` on error. +- [X] **M6. Self-contradicting `NewRandomDraftProvider` godoc** — `drafts.go:50-59`. Says "cryptographically secure random numbers" but uses `math/rand/v2` (the underlying generator type correctly notes it is not crypto-secure). +- [X] **M7. `Draft.Delete` godoc says "internal method"** — `drafts.go:190-201`. Method is exported; either rewrite the godoc with a public-intent description or unexport. +- [X] **M8. Russian comments in production code** + - `msg_handler.go:28` — "Ищем команду по точному совпадению" + - `tgapi/uploader_api.go:181` — "Повторяем запрос" +- [X] **M9. `MessageContext.Error` godoc references unexported helper** — `msg_context.go:540`. "Error is an alias for error()" — rewrite to describe the centralized handler error path and `IsUserError` gating. +- [X] **M10. `Scene` and `SceneSession` mix exported fields with setters** + - `Scene` exports `Name/Scope/Entry/PluginName` and also has `SetScope/SetEntry`; `PluginName` is framework-assigned but publicly mutable. + - `SceneSession` exports `Data []byte` and also has `Set/Get/HasData/ClearData/BindData/SaveData`. + - Pick one model per type before 1.0.0. +- [X] **M11. Constant-time compare for webhook secret** — `bot_webhook.go:296` (update handler) and `bot_webhook.go:341` (`/status`). Use `subtle.ConstantTimeCompare`. + +### Minor — can slip to 1.0.x + +- [X] Strip `// Internal helper …` godoc from unexported funcs (~23 occurrences in repo); `AGENTS.md` explicitly forbids godoc-style comments on unexported declarations without a strong reason. +- [X] `Plugin.AddCommand` godoc references unexported field `.command` — `plugins.go:48-49`. +- [X] `Runner` builder naming: `runner.Once(true)`, `runner.Async(true)` read awkwardly; consider `SetOnce`/`SetAsync` to match `Set*` on other types, or zero-arg `Once()` + paired `Repeat(every)`. +- [X] Typo in webhook error string: `bot_webhook.go:143` — "MaxConnections must between 1 and 100" (missing `be`). +- [X] `RunWebhookWithContext` uses inline `errors.New(...)` instead of `Err*` sentinels (`bot_webhook.go:131-156`); rest of the package uses sentinels from `errors.go`. +- [X] `tgapi.UpdateTypeManagedBot` (`tgapi/types.go:61`) has no godoc. +- [X] `Bot.GetAPI`, `Bot.GetUploader`, `InlineKeyboard.GetMaxRow` have no godoc. +- [X] `Bot.L10n` godoc says "Returns empty string if translation not found"; actually returns the key (`l10n.go:48-59`). +- [X] `Bot.handle` panic recovery only logs — emit `ErrorEvent` so observers see panics (`handler.go:18-23`). +- [X] `handleCallback` vs `handleMessage` differ in plugin-logger assignment: callback assigns unconditionally then falls back to bot logger (`msg_handler.go:209-212`); message only assigns if non-nil (`msg_handler.go:35-37`). Align. +- [X] `SetCallbackData` godoc says "default payload type is JSON" — actually the zero `BotPayloadType` falls through to the `default` branch (which happens to be JSON). Either document the zero-value behavior explicitly or initialize the builder with the bot's default (`keyboard.go:106-122`). +- [X] `commands.go:62-66` — empty `case CommandValueAny:` next to `default: regex = nil` looks like an incomplete switch. Merge or add a one-line comment. +- [X] `Bot.SetDebug` does not call `configMutable` unlike sibling setters; if intentional, note it in godoc. + +### Tests to add after the fixes + +- `BotOptsFileJSON` round-trip for `PollTimeout` (after M4). +- Uploader 4xx/429 surfaces `*tgapi.ResponseError` (after M3). +- `Bot.handle` panic → observer receives `ErrorEvent` (after panic-recovery fix). +- Webhook `/status` with wrong `SecretToken` returns 403 / `403`-equivalent (after M11), incl. a constant-time-compare smoke. +- Table-driven `parseCommand` cases for `/cmd@botname` and stripping behavior. + +--- + The framework backlog has moved to the wiki. Primary page: diff --git a/bot.go b/bot.go index 6ee6e16..75dc0c4 100644 --- a/bot.go +++ b/bot.go @@ -47,7 +47,7 @@ type AppDataLogger[T AppData] func(data T) sneklog.LoggerWriter // BotPayloadType defines the serialization format for callback data payloads. type BotPayloadType string -var ( +const ( // BotPayloadBase64 encodes callback data as a Base64 string. BotPayloadBase64 BotPayloadType = "base64" // BotPayloadJSON encodes callback data as a JSON string. @@ -275,8 +275,10 @@ func (bot *Bot[T]) SetWebhookLogger(l *sneklog.Logger) *Bot[T] { return bot } +// GetAPI returns the underlying Telegram Bot API client. func (bot *Bot[T]) GetAPI() *tgapi.API { return bot.api } +// GetUploader returns the underlying file uploader client. func (bot *Bot[T]) GetUploader() *tgapi.Uploader { return bot.uploader } // Close gracefully shuts down bot-owned resources. @@ -385,7 +387,7 @@ func (bot *Bot[T]) GetLoggerLevel() sneklog.LogLevel { } // L10n translates a key in the given language. -// Returns empty string if translation not found. +// Returns key if translation not found. func (bot *Bot[T]) L10n(lang, key string) string { return bot.l10n.Translate(lang, key) } diff --git a/bot_config.go b/bot_config.go index e5c329e..a87fcbc 100644 --- a/bot_config.go +++ b/bot_config.go @@ -125,7 +125,7 @@ func (bot *Bot[T]) GetAppData() T { return bot.appData } // SetUpdateTypes sets the list of update types the bot will request from Telegram. // Overwrites any previously set types. func (bot *Bot[T]) SetUpdateTypes(t ...tgapi.UpdateType) *Bot[T] { - if !bot.configMutable("UpdateTypes") { + if !bot.configMutable("SetUpdateTypes") { return bot } bot.updateTypes = make([]tgapi.UpdateType, 0) @@ -177,7 +177,7 @@ func (bot *Bot[T]) SetStrictPayloadType(strict bool) *Bot[T] { // Use "%s" to insert the error message. // Example: "❌ Error: %s" → "❌ Error: Command not found". func (bot *Bot[T]) SetErrorTemplate(s string) *Bot[T] { - if !bot.configMutable("ErrorTemplate") { + if !bot.configMutable("SetErrorTemplate") { return bot } bot.errorTemplate = s @@ -186,6 +186,9 @@ func (bot *Bot[T]) SetErrorTemplate(s string) *Bot[T] { // SetDebug enables or disables debug logging. func (bot *Bot[T]) SetDebug(debug bool) *Bot[T] { + if !bot.configMutable("SetDebug") { + return bot + } bot.debug = debug level := sneklog.FATAL if debug { diff --git a/bot_opts_loader.go b/bot_opts_loader.go index 608a5a3..b3cf7cc 100644 --- a/bot_opts_loader.go +++ b/bot_opts_loader.go @@ -28,6 +28,7 @@ type botOptsFileJSONAPI struct { UseTestServer bool `json:"use_test_server"` APIURL string `json:"url"` RateLimit int `json:"rate_limit"` + PollTimeout int `json:"poll_timeout"` DropRLOverflow bool `json:"drop_overflow"` } @@ -73,6 +74,7 @@ func (codec BotOptsFileJSONCodec) FromBytes(data []byte) (*BotOpts, error) { UseTestServer: fileOpts.API.UseTestServer, APIURL: fileOpts.API.APIURL, RateLimit: fileOpts.API.RateLimit, + PollTimeout: fileOpts.API.PollTimeout, DropRateLimitOverflow: fileOpts.API.DropRLOverflow, StrictPayloadType: fileOpts.StrictPayloadType, @@ -102,6 +104,7 @@ func (codec BotOptsFileJSONCodec) ToBytes(opts *BotOpts) ([]byte, error) { UseTestServer: opts.UseTestServer, APIURL: opts.APIURL, RateLimit: opts.RateLimit, + PollTimeout: opts.PollTimeout, DropRLOverflow: opts.DropRateLimitOverflow, }, StrictPayloadType: opts.StrictPayloadType, diff --git a/bot_scene.go b/bot_scene.go index db71fad..e39b8c2 100644 --- a/bot_scene.go +++ b/bot_scene.go @@ -24,9 +24,9 @@ func (bot *Bot[T]) findScene(name string) (*sceneMeta, bool) { } return &sceneMeta{ - Name: scene.Name, - Scope: scene.Scope, - Entry: scene.Entry, + Name: scene.name, + Scope: scene.scope, + Entry: scene.entry, Steps: steps, }, true } diff --git a/bot_test.go b/bot_test.go index ade1071..baddfac 100644 --- a/bot_test.go +++ b/bot_test.go @@ -37,8 +37,8 @@ func (o *pollingRetryObserver) OnPollingRetry(ctx context.Context, ev PollingRet type testObserver struct{} -func (testObserver) OnReceiveUpdate(context.Context, UpdateReceivedEvent) {} -func (testObserver) OnHandledUpdate(context.Context, UpdateHandledEvent) {} +func (testObserver) OnUpdateReceived(context.Context, UpdateReceivedEvent) {} +func (testObserver) OnUpdateHandled(context.Context, UpdateHandledEvent) {} func (testObserver) OnHandlerStarted(context.Context, HandlerStartedEvent) {} func (testObserver) OnHandlerFinished(context.Context, HandlerFinishedEvent) { } diff --git a/bot_webhook.go b/bot_webhook.go index 1106686..5e0281f 100644 --- a/bot_webhook.go +++ b/bot_webhook.go @@ -2,6 +2,7 @@ package laniakea import ( "context" + "crypto/subtle" "encoding/json" "errors" "fmt" @@ -128,7 +129,7 @@ func (opts *BotWebhookOpts) SetSecretToken(secretToken string) *BotWebhookOpts { // argument order. func (bot *Bot[T]) RunWebhookWithContext(ctx context.Context, opts *BotWebhookOpts, tlsFiles ...string) error { if opts == nil { - return errors.New("nil BotWebhookOpts") + return ErrNilBotWebhookOpts } if len(bot.prefixes) == 0 { return ErrNoPrefixes @@ -137,28 +138,28 @@ func (bot *Bot[T]) RunWebhookWithContext(ctx context.Context, opts *BotWebhookOp return ErrNoPlugins } if opts.URL == "" { - return errors.New("empty BotWebhookOpts.URL") + return ErrNoBotWebhookOptsURL } if opts.MaxConnections > 100 || opts.MaxConnections <= 0 { - return errors.New("BotWebhookOpts.MaxConnections must between 1 and 100") + return ErrBotWebhookOptsMaxConnectionsRange } if err := validateWebhookPath(opts.Path, opts.UseStatusPath); err != nil { return err } if opts.UseStatusPath && opts.SecretToken == "" { - return errors.New("BotWebhookOpts.SecretToken required when status path is enabled") + return ErrStatusPathSecretRequired } if err := validateWebhookTLSFiles(tlsFiles); err != nil { return err } if opts.Certificate != nil && bot.uploader == nil { - return errors.New("bot uploader nil, but certificate set") + return ErrBotUploaderWhenCertificate } return bot.runWebhookRuntime(ctx, func(runCtx context.Context) error { if opts.SecretToken == "" { - bot.webhookLogger.Warnln("Bot webhook secret token empty. It's VERY recommended to set secret.") + bot.webhookLogger.Warnln("Using webhook without secret is very dangerous. Anyone can simulate Telegram requests.") } i, err := bot.api.GetWebhookInfoWithContext(runCtx) @@ -284,7 +285,7 @@ func (bot *Bot[T]) runWebhookRuntime(ctx context.Context, run func(context.Conte return runErr } -func updateHandler[T any](ctx context.Context, bot *Bot[T], secret string) http.HandlerFunc { +func updateHandler[T any](ctx context.Context, bot *Bot[T], secret []byte) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { defer func() { _ = r.Body.Close() @@ -293,7 +294,9 @@ func updateHandler[T any](ctx context.Context, bot *Bot[T], secret string) http. w.WriteHeader(http.StatusMethodNotAllowed) return } - if secret != "" && r.Header.Get("X-Telegram-Bot-Api-Secret-Token") != secret { + provided := []byte(r.Header.Get("X-Telegram-Bot-Api-Secret-Token")) + + if len(secret) > 0 && subtle.ConstantTimeCompare(secret, provided) != 1 { w.WriteHeader(http.StatusForbidden) return } @@ -330,7 +333,7 @@ func updateHandler[T any](ctx context.Context, bot *Bot[T], secret string) http. } } -func statusHandler[T any](bot *Bot[T], opts *BotWebhookOpts) http.HandlerFunc { +func statusHandler[T any](bot *Bot[T], secret []byte) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { auth := "" if r.Header.Get("Authorization") != "" { @@ -338,7 +341,7 @@ func statusHandler[T any](bot *Bot[T], opts *BotWebhookOpts) http.HandlerFunc { } else if r.Header.Get("X-Telegram-Bot-Api-Secret-Token") != "" { auth = r.Header.Get("X-Telegram-Bot-Api-Secret-Token") } - if auth != opts.SecretToken { + if len(secret) > 0 && subtle.ConstantTimeCompare(secret, []byte(auth)) != 1 { w.WriteHeader(http.StatusNotFound) return } @@ -363,11 +366,12 @@ func statusHandler[T any](bot *Bot[T], opts *BotWebhookOpts) http.HandlerFunc { } func (bot *Bot[T]) newWebhookMux(ctx context.Context, opts *BotWebhookOpts) *http.ServeMux { + token := []byte(opts.SecretToken) r := http.NewServeMux() if opts.UseStatusPath { - r.HandleFunc("/status", statusHandler(bot, opts)) + r.HandleFunc("/status", statusHandler(bot, token)) } - r.HandleFunc(opts.Path, updateHandler(ctx, bot, opts.SecretToken)) + r.HandleFunc(opts.Path, updateHandler(ctx, bot, token)) return r } func (bot *Bot[T]) baseRunWebhook(ctx context.Context, opts *BotWebhookOpts, runFunc func(*http.Server, chan error)) error { diff --git a/bot_webhook_test.go b/bot_webhook_test.go index 49d2019..88e90d1 100644 --- a/bot_webhook_test.go +++ b/bot_webhook_test.go @@ -46,7 +46,7 @@ func TestUpdateHandlerEnqueuesUpdate(t *testing.T) { req.Header.Set("X-Telegram-Bot-Api-Secret-Token", "secret") rec := httptest.NewRecorder() - updateHandler(context.Background(), bot, "secret").ServeHTTP(rec, req) + updateHandler(context.Background(), bot, []byte("secret")).ServeHTTP(rec, req) if rec.Result().StatusCode != http.StatusOK { t.Fatalf("unexpected status: got %d want %d", rec.Result().StatusCode, http.StatusOK) @@ -94,7 +94,7 @@ func TestRunWebhookRuntimeExecutesRunners(t *testing.T) { NewRunner("runner", func(bot *Bot[NoData]) error { calls.Add(1) return nil - }).Once(true).Async(false), + }).Async(false), }, } t.Cleanup(func() { @@ -157,7 +157,7 @@ func TestRunWebhookRuntimeProcessesEnqueuedUpdate(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"update_id":9,"message":{"message_id":1,"date":1,"chat":{"id":1,"type":"private"},"from":{"id":2,"is_bot":false,"first_name":"Test"},"text":"/start"}}`)) rec := httptest.NewRecorder() - updateHandler(ctx, bot, "").ServeHTTP(rec, req) + updateHandler(ctx, bot, []byte("")).ServeHTTP(rec, req) if rec.Result().StatusCode != http.StatusOK { t.Fatalf("unexpected status: got %d want %d", rec.Result().StatusCode, http.StatusOK) } @@ -267,7 +267,7 @@ func TestUpdateHandlerRejectsOversizedBody(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(strings.Repeat("a", (256<<10)+1))) rec := httptest.NewRecorder() - updateHandler(context.Background(), bot, "").ServeHTTP(rec, req) + updateHandler(context.Background(), bot, []byte("")).ServeHTTP(rec, req) if rec.Result().StatusCode != http.StatusRequestEntityTooLarge { t.Fatalf("unexpected status: got %d want %d", rec.Result().StatusCode, http.StatusRequestEntityTooLarge) @@ -301,7 +301,7 @@ func TestStatusHandlerRequiresMatchingSecret(t *testing.T) { _ = bot.webhookLogger.Close() }) - handler := statusHandler(bot, &BotWebhookOpts{SecretToken: "secret"}) + handler := statusHandler(bot, []byte("secret")) tests := []struct { name string diff --git a/cmd_generator.go b/cmd_generator.go index 3008d50..5cc3494 100644 --- a/cmd_generator.go +++ b/cmd_generator.go @@ -21,7 +21,6 @@ var cmdRegexp = regexp.MustCompile("^[_a-z0-9]{1,32}$") // bot initialization. var ErrTooManyCommands = errors.New("too many commands. max 100") -// Internal helper to build a BotCommand description with generated usage text. func generateBotCommand[T any](cmd *Command[T]) tgapi.BotCommand { desc := "" if len(cmd.description) > 0 { @@ -45,10 +44,8 @@ func generateBotCommand[T any](cmd *Command[T]) tgapi.BotCommand { return tgapi.BotCommand{Command: cmd.command, Description: usage} } -// Internal helper to validate Telegram command names. func checkCmdRegex(cmd string) bool { return cmdRegexp.MatchString(cmd) } -// Internal helper to collect non-skipped, valid commands from one plugin. func gatherCommandsForPlugin[T any](pl Plugin[T]) []tgapi.BotCommand { commands := make([]tgapi.BotCommand, 0) names := make([]string, 0, len(pl.commands)) @@ -70,7 +67,6 @@ func gatherCommandsForPlugin[T any](pl Plugin[T]) []tgapi.BotCommand { return commands } -// Internal helper to collect all auto-generated commands from registered plugins. func gatherCommands[T any](bot *Bot[T]) []tgapi.BotCommand { commands := make([]tgapi.BotCommand, 0) for _, pl := range bot.plugins { diff --git a/commands.go b/commands.go index 7e86ee2..c333b3b 100644 --- a/commands.go +++ b/commands.go @@ -66,9 +66,6 @@ func (c CommandArg) SetValueType(t CommandValueType) CommandArg { regex = CommandRegexBool case CommandValueString: regex = CommandRegexString - case CommandValueAny: - default: - regex = nil // Skip validation } c.valueType = t c.regex = regex @@ -133,7 +130,6 @@ func (c *Command[T]) SkipCommandAutoGen() *Command[T] { return c } -// Internal helper that validates provided command arguments. func (c *Command[T]) validateArgs(args []string) error { for i := range c.args.Len() { if i >= len(args) && c.args.Get(i).required { diff --git a/drafts.go b/drafts.go index 7c43826..d0a9638 100644 --- a/drafts.go +++ b/drafts.go @@ -49,7 +49,7 @@ type DraftProvider struct { // NewRandomDraftProvider creates a new DraftProvider using random draft IDs. // -// The provider will use cryptographically secure random numbers for draft IDs. +// The provider will use random numbers for draft IDs. // All drafts created via this provider will have unpredictable, unique IDs. func NewRandomDraftProvider(api *tgapi.API) *DraftProvider { return &DraftProvider{ @@ -189,8 +189,7 @@ func (d *Draft) Clear() { // Delete removes the draft from its provider and clears its content. // -// This is an internal method used by Flush(). You may call it manually if you -// want to cancel a draft without sending it. +// You may call it manually if you want to cancel a draft without sending it. func (d *Draft) Delete() { if d.provider != nil { d.provider.mu.Lock() @@ -241,8 +240,6 @@ func (d *Draft) Flush() error { return err } -// 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 diff --git a/errors.go b/errors.go index 3132155..c214aa1 100644 --- a/errors.go +++ b/errors.go @@ -38,6 +38,8 @@ var ( ErrAPIIsNil = errors.New("api is nil") // ErrMessageIDZero reports that an operation requires a non-zero message ID. ErrMessageIDZero = errors.New("message ID is zero") +) +var ( // ErrBindArgsTargetNotPointer reports that BindArgs received a nil or non-pointer destination. ErrBindArgsTargetNotPointer = errors.New("bind args: dst must be a non-nil pointer") // ErrBindArgsTargetNotStruct reports that BindArgs received a pointer to a non-struct value. @@ -60,6 +62,19 @@ var ( ErrSceneRuntimeNil = errors.New("scene runtime is nil") ) +var ( + // ErrNilBotWebhookOpts reports that a nil BotWebhookOpts was passed. + ErrNilBotWebhookOpts = errors.New("nil BotWebhookOpts") + // ErrNoBotWebhookOptsURL reports that BotWebhookOpts.URL is empty. + ErrNoBotWebhookOptsURL = errors.New("empty BotWebhookOpts.URL") + // ErrBotWebhookOptsMaxConnectionsRange reports that BotWebhookOpts.MaxConnections is out of range. + ErrBotWebhookOptsMaxConnectionsRange = errors.New("BotWebhookOpts.MaxConnections must be between 1 and 100") + // ErrBotUploaderWhenCertificate reports that a certificate was set without an uploader. + ErrBotUploaderWhenCertificate = errors.New("bot uploader nil, but certificate set") + // ErrStatusPathSecretRequired reports that UseStatusPath requires SecretToken to be set. + ErrStatusPathSecretRequired = errors.New("SecretToken required when UseStatusPath is enabled") +) + func validateMessageText(text string) error { length := utf8.RuneCountInString(text) switch { diff --git a/handler.go b/handler.go index 7d37a77..ce9012a 100644 --- a/handler.go +++ b/handler.go @@ -19,6 +19,18 @@ func (bot *Bot[T]) handle(parentCtx context.Context, u *tgapi.Update) { defer func() { if r := recover(); r != nil { bot.logger.Errorln(fmt.Sprintf("panic in handle: %v", r)) + + var err error + var ok bool + if err, ok = r.(error); !ok { + err = fmt.Errorf("%v", r) + } + bot.safeEmitEvent(parentCtx, ErrorEvent{ + UpdateID: u.UpdateID, + UpdateType: u.Type, + Err: err, + UserFacing: false, + }) } }() startTime := time.Now() diff --git a/handler_test.go b/handler_test.go index ae4b963..97c093c 100644 --- a/handler_test.go +++ b/handler_test.go @@ -20,10 +20,10 @@ type recordingObserver struct { retries []PollingRetryEvent } -func (o *recordingObserver) OnReceiveUpdate(_ context.Context, ev UpdateReceivedEvent) { +func (o *recordingObserver) OnUpdateReceived(_ context.Context, ev UpdateReceivedEvent) { o.received = append(o.received, ev) } -func (o *recordingObserver) OnHandledUpdate(_ context.Context, ev UpdateHandledEvent) { +func (o *recordingObserver) OnUpdateHandled(_ context.Context, ev UpdateHandledEvent) { o.handled = append(o.handled, ev) } func (o *recordingObserver) OnHandlerStarted(_ context.Context, ev HandlerStartedEvent) { diff --git a/keyboard.go b/keyboard.go index 52a03a7..58e9d2d 100644 --- a/keyboard.go +++ b/keyboard.go @@ -121,7 +121,6 @@ func (b InlineKeyboardButtonBuilder) SetCallbackData(cmd string, args ...any) In return b } -// Internal helper that converts the builder state into a Telegram button. func (b InlineKeyboardButtonBuilder) build() tgapi.InlineKeyboardButton { return tgapi.InlineKeyboardButton{ Text: b.text, @@ -203,9 +202,9 @@ func (in *InlineKeyboard) SetMaxRow(maxRow int) *InlineKeyboard { return in } +// GetMaxRow returns the maximum number of buttons per row. 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 { in.AddLine() diff --git a/methods.go b/methods.go index e970d8b..fd20dcb 100644 --- a/methods.go +++ b/methods.go @@ -22,15 +22,15 @@ import ( // // Behavior: // 1. Uses the bot's current update offset (via GetUpdateOffset) -// 2. Requests updates with 30-second timeout +// 2. Requests updates with the timeout configured via PollTimeout // 3. Filters updates by types specified in bot.GetUpdateTypes() // 4. Logs raw update JSON if RequestLogger is configured // 5. Automatically updates the offset to the last received update ID + 1 // 6. Returns all received updates (empty slice if none) // -// Note: This is a blocking call that waits up to 30 seconds for new updates, -// unless ctx is canceled earlier. For non-blocking behavior, consider using -// webhooks instead. +// Note: This is a blocking call that waits up to the configured PollTimeout +// for new updates, unless ctx is canceled earlier. For non-blocking behavior, +// consider using webhooks instead. // // Example: // @@ -50,9 +50,10 @@ func (bot *Bot[T]) Updates(ctx context.Context) ([]tgapi.Update, error) { AllowedUpdates: bot.GetUpdateTypes(), } + zero := make([]tgapi.Update, 0) updates, err := bot.api.GetUpdatesWithContext(ctx, params) if err != nil { - return nil, err + return zero, err } if bot.requestLogger != nil { @@ -67,7 +68,10 @@ func (bot *Bot[T]) Updates(ctx context.Context) ([]tgapi.Update, error) { if len(updates) > 0 { bot.SetUpdateOffset(updates[len(updates)-1].UpdateID + 1) } - return updates, err + if updates == nil { + return zero, nil + } + return updates, nil } // UpdatesIter fetches updates once and yields each update in order. diff --git a/msg_context.go b/msg_context.go index 6a25968..ea6cfcc 100644 --- a/msg_context.go +++ b/msg_context.go @@ -95,7 +95,6 @@ type AnswerMessage struct { ctx *MessageContext // internal back-reference } -// Internal helper for text edits with optional keyboard and parse mode. func (ctx *MessageContext) edit(messageID int, text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage { if err := validateMessageText(text); err != nil { ctx.Logger.Errorln(err) @@ -146,7 +145,6 @@ func (m *AnswerMessage) EditMarkdown(text string) *AnswerMessage { return m.ctx.edit(m.MessageID, text, nil, tgapi.ParseMarkdownV2) } -// Internal helper for editing callback-linked messages. func (ctx *MessageContext) editCallback(text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage { if ctx.CallbackMsgID == 0 && ctx.InlineMsgID == "" { ctx.Logger.Errorln(ErrCallbackMessageMissing) @@ -179,7 +177,6 @@ func (ctx *MessageContext) EditCallbackfMarkdown(format string, keyboard *Inline return ctx.editCallback(fmt.Sprintf(format, args...), keyboard, tgapi.ParseMarkdownV2) } -// Internal helper for media-caption edits. func (ctx *MessageContext) editPhotoText(messageID int, text string, kb *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage { if err := validateCaptionText(text); err != nil { ctx.Logger.Errorln(err) @@ -241,7 +238,6 @@ func (m *AnswerMessage) EditCaptionKeyboardMarkdown(text string, kb *InlineKeybo return m.ctx.editPhotoText(m.MessageID, text, kb, tgapi.ParseMarkdownV2) } -// Internal helper for message replies with optional keyboard and parse mode. func (ctx *MessageContext) answer(text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage { if ctx.Msg == nil { ctx.Logger.Errorln(ErrMessageContextNil) @@ -371,7 +367,6 @@ func (ctx *MessageContext) answerLong(text string, keyboard *InlineKeyboard, par return messages } -// Internal helper for photo replies with optional caption and keyboard. func (ctx *MessageContext) answerPhoto(photoID, text string, kb *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage { if ctx.Msg == nil { ctx.Logger.Errorln(ErrMessageContextNil) @@ -443,7 +438,6 @@ func (ctx *MessageContext) AnswerPhotofMarkdown(photoID, template string, args . return ctx.answerPhoto(photoID, fmt.Sprintf(template, args...), nil, tgapi.ParseMarkdownV2) } -// Internal helper that deletes a message by ID. func (ctx *MessageContext) delete(messageID int) { if messageID == 0 { ctx.Logger.Errorln(ErrMessageIDZero) @@ -474,7 +468,6 @@ func (ctx *MessageContext) CallbackDelete() { ctx.delete(ctx.CallbackMsgID) } -// Internal helper that answers a callback query with optional text, alert, or URL. func (ctx *MessageContext) answerCallbackQuery(url, text string, showAlert bool) { if len(ctx.CallbackQueryID) == 0 { return @@ -518,7 +511,6 @@ func (ctx *MessageContext) SendAction(action tgapi.ChatActionType) { } } -// Internal helper that formats, sends, and logs an error. func (ctx *MessageContext) error(err error) { if err == nil { return @@ -536,7 +528,7 @@ func (ctx *MessageContext) error(err error) { } } -// Error is an alias for error(). +// Error routes err through the centralized handler error path… func (ctx *MessageContext) Error(err error) { ctx.error(err) } func (ctx *MessageContext) newDraft(parseMode tgapi.ParseMode) *Draft { diff --git a/msg_handler.go b/msg_handler.go index 9baad5e..7a9b3b7 100644 --- a/msg_handler.go +++ b/msg_handler.go @@ -25,15 +25,16 @@ func (bot *Bot[T]) handleMessage(update *tgapi.Update, ctx *MessageContext) bool cmd = cmd[:len(cmd)-len("@"+botUsername)] // remove @botname } } - // Ищем команду по точному совпадению + for _, plugin := range bot.plugins { if _, exists := plugin.commands[cmd]; exists { ctx.Text = args ctx.Args = strings.Fields(args) + ctx.Logger = plugin.logger - if plugin.logger != nil { - ctx.Logger = plugin.logger + if ctx.Logger == nil { + ctx.Logger = bot.logger } if !plugin.executeMiddlewares(ctx, bot.appData) { return false diff --git a/observer.go b/observer.go index 205ccfb..4ea52e1 100644 --- a/observer.go +++ b/observer.go @@ -145,8 +145,8 @@ func (ErrorEvent) isEvent() {} // Observer receives best-effort runtime instrumentation events. type Observer interface { - OnReceiveUpdate(ctx context.Context, event UpdateReceivedEvent) - OnHandledUpdate(ctx context.Context, event UpdateHandledEvent) + OnUpdateReceived(ctx context.Context, event UpdateReceivedEvent) + OnUpdateHandled(ctx context.Context, event UpdateHandledEvent) OnHandlerStarted(ctx context.Context, event HandlerStartedEvent) OnHandlerFinished(ctx context.Context, event HandlerFinishedEvent) OnSceneTransition(ctx context.Context, event SceneTransitionEvent) @@ -167,9 +167,9 @@ func (bot *Bot[T]) safeEmitEvent(ctx context.Context, event Event) { }() switch e := event.(type) { case UpdateReceivedEvent: - bot.observer.OnReceiveUpdate(ctx, e) + bot.observer.OnUpdateReceived(ctx, e) case UpdateHandledEvent: - bot.observer.OnHandledUpdate(ctx, e) + bot.observer.OnUpdateHandled(ctx, e) case HandlerStartedEvent: bot.observer.OnHandlerStarted(ctx, e) case HandlerFinishedEvent: diff --git a/plugins.go b/plugins.go index a7074f6..95f477f 100644 --- a/plugins.go +++ b/plugins.go @@ -46,7 +46,6 @@ func NewPlugin[T AppData](name string) *Plugin[T] { } // AddCommand registers a command in the plugin. -// The command's .command field is used as the key. func (p *Plugin[T]) AddCommand(command *Command[T]) *Plugin[T] { if command == nil { if p.logger != nil { @@ -55,7 +54,7 @@ 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.logger.Warnf("command '%s' already registered in plugin '%s'; overwriting", command.command, p.name) } p.commands[command.command] = command return p @@ -106,12 +105,12 @@ func (p *Plugin[T]) AddScene(scene *Scene[T]) *Plugin[T] { if scene == nil { return p } - scene.PluginName = p.name + 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) + 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 + p.scenes[scene.name] = scene return p } @@ -238,7 +237,6 @@ func (p *Plugin[T]) Close() error { return errors.Join(e...) } -// Internal helper that validates and executes a command handler. func (p *Plugin[T]) executeCmd(cmd string, ctx *MessageContext, db T) error { command, exists := p.commands[cmd] if !exists { @@ -260,7 +258,6 @@ func (p *Plugin[T]) executeCmd(cmd string, ctx *MessageContext, db T) error { return command.exec(ctx, db) } -// Internal helper that validates and executes a payload handler. func (p *Plugin[T]) executePayload(payload string, ctx *MessageContext, db T) error { command, exists := p.payloads[payload] if !exists { @@ -282,7 +279,6 @@ func (p *Plugin[T]) executePayload(payload string, ctx *MessageContext, db T) er return command.exec(ctx, db) } -// Internal helper that runs plugin middlewares in order. func (p *Plugin[T]) executeMiddlewares(ctx *MessageContext, db T) bool { for _, m := range p.middlewares { if !m.Execute(ctx, db) { diff --git a/runners.go b/runners.go index 883280b..b45ea16 100644 --- a/runners.go +++ b/runners.go @@ -16,20 +16,19 @@ type RunnerFn[T AppData] func(*Bot[T]) error // Once Execute() is called, the Runner should not be modified. // // Execution semantics: -// - once=true, async=false: Run once synchronously (blocks). -// - once=true, async=true: Run once in a goroutine (non-blocking). -// - once=false, async=true: Run repeatedly in a goroutine with timeout. -// - once=false, async=false: Invalid configuration — ignored with warning. +// - every=0, async=false: Run once synchronously (blocks). +// - every=0, async=true: Run once in a goroutine (non-blocking). +// - every>0, async=true: Run repeatedly in a goroutine with timeout. +// - every>0, async=false: Invalid configuration — ignored with warning. type Runner[T AppData] struct { name string // Human-readable name for logging - once bool // If true, runs once; if false, runs periodically async bool // If true, runs in a goroutine; else, runs synchronously every time.Duration // Duration to wait between periodic executions (ignored if once=true) fn RunnerFn[T] // The function to execute } // NewRunner creates a new Runner with the given name and function. -// By default, the Runner is configured as async=true (non-blocking). +// By default, the Runner is configured as async=true (non-blocking), once=true/mo // // Builder methods (Once, Async, Every) can be chained to customize behavior. // DO NOT call builder methods concurrently or after Execute(). @@ -38,18 +37,10 @@ func NewRunner[T AppData](name string, fn RunnerFn[T]) Runner[T] { name: name, fn: fn, async: true, // Default: run asynchronously - every: 0, // Default: no timeout (ignored if once=true) + every: 0, // Default: 0 - one time } } -// Once sets whether the runner executes once or repeatedly. -// If true, the runner runs only once. -// If false, the runner runs in a loop with the configured timeout. -func (r Runner[T]) Once(once bool) Runner[T] { - r.once = once - return r -} - // Async sets whether the runner executes synchronously or asynchronously. // If true, the runner runs in a goroutine (non-blocking). // If false, the runner blocks the caller during execution. @@ -94,16 +85,16 @@ func (bot *Bot[T]) ExecRunners(ctx context.Context) { bot.logger.Infoln("Executing runners...") for _, runner := range bot.runners { // Validate configuration - if !runner.once && !runner.async { + if runner.every > 0 && !runner.async { bot.logger.Warnf("Runner %s not once, but sync — skipping\n", runner.name) continue } - if !runner.once && runner.async && runner.every == 0 { + if runner.every > 0 && runner.async && runner.every == 0 { bot.logger.Warnf("Background runner \"%s\" has no timeout — skipping\n", runner.name) continue } - if runner.once && runner.async { + if runner.every == 0 && runner.async { // One-time async: fire and forget bot.runnerOnceWG.Add(1) go func(r Runner[T]) { @@ -126,7 +117,7 @@ func (bot *Bot[T]) ExecRunners(ctx context.Context) { bot.logger.Warnf("Runner %s failed: %s\n", r.name, err) } }(runner) - } else if runner.once && !runner.async { + } else if runner.every == 0 && !runner.async { // One-time sync: block until done t := time.Now() err := runner.fn(bot) @@ -149,7 +140,7 @@ func (bot *Bot[T]) ExecRunners(ctx context.Context) { if elapsed > time.Second*2 { bot.logger.Warnf("Runner %s too slow. Elapsed time %v >= 2s\n", runner.name, elapsed) } - } else if !runner.once && runner.async { + } else if runner.every > 0 && runner.async { // Background loop: periodic execution with graceful shutdown bot.runnerBgWG.Add(1) go func(r Runner[T]) { diff --git a/runners_test.go b/runners_test.go index 28a8b30..496df4d 100644 --- a/runners_test.go +++ b/runners_test.go @@ -22,7 +22,7 @@ func TestExecRunnersRunsOnceSyncRunner(t *testing.T) { NewRunner("sync-once", func(*Bot[NoData]) error { calls.Add(1) return nil - }).Once(true).Async(false), + }).Async(false), }, } @@ -76,7 +76,7 @@ func TestExecRunnersEmitObserverEvents(t *testing.T) { runners: []Runner[NoData]{ NewRunner("sync-once", func(*Bot[NoData]) error { return wantErr - }).Once(true).Async(false), + }).Async(false), }, } diff --git a/scene.go b/scene.go index c083253..1bd17a4 100644 --- a/scene.go +++ b/scene.go @@ -11,14 +11,10 @@ type SceneHandler[T any] func(ctx *SceneContext, db T) (SceneResult, error) // Scene defines a multi-step conversational flow. type Scene[T any] struct { - // Name identifies the scene in plugin registration and session state. - Name string - // Scope controls how active scene sessions are keyed and shared. - Scope SceneScope - // Entry names the first step used by MessageContext.EnterScene. - Entry string - // PluginName stores the owning plugin name for scene resolution. - PluginName string + name string + scope SceneScope + entry string + pluginName string steps map[string]SceneHandler[T] commands map[string]SceneHandler[T] @@ -29,9 +25,9 @@ type Scene[T any] struct { // NewScene creates a new scene with user-chat scope by default. func NewScene[T any](name string) *Scene[T] { return &Scene[T]{ - Name: name, - Scope: SceneScopeUserChat, - Entry: "", + name: name, + scope: SceneScopeUserChat, + entry: "", steps: make(map[string]SceneHandler[T]), commands: make(map[string]SceneHandler[T]), payloads: make(map[string]SceneHandler[T]), @@ -41,18 +37,18 @@ func NewScene[T any](name string) *Scene[T] { // SetScope changes how scene sessions are keyed and shared. func (s *Scene[T]) SetScope(scope SceneScope) *Scene[T] { - s.Scope = scope + s.scope = scope return s } // SetEntry sets the initial step entered by MessageContext.EnterScene. func (s *Scene[T]) SetEntry(step string) *Scene[T] { - s.Entry = step + s.entry = step return s } func (s *Scene[T]) setPluginName(name string) *Scene[T] { - s.PluginName = name + s.pluginName = name return s } @@ -135,36 +131,36 @@ type SceneSession struct { Scene string // Step is the current step name inside the active scene. Step string - // Data stores opaque session payload bytes, typically JSON. - Data []byte + // data stores opaque session payload bytes, typically JSON. + data []byte } // SetData stores arbitrary opaque session data. func (s *SceneSession) SetData(data []byte) { - s.Data = data + s.data = data } // GetData returns the raw session data payload. func (s *SceneSession) GetData() []byte { - return s.Data + return s.data } // HasData reports whether the session has a non-empty data payload. func (s *SceneSession) HasData() bool { - return len(s.Data) > 0 + return len(s.data) > 0 } // ClearData removes any stored session data. func (s *SceneSession) ClearData() { - s.Data = nil + s.data = nil } // BindData unmarshals the stored JSON payload into v. func (s *SceneSession) BindData(v any) error { - if len(s.Data) == 0 { + if len(s.data) == 0 { return nil } - return json.Unmarshal(s.Data, v) + return json.Unmarshal(s.data, v) } // SaveData marshals v as JSON and stores it in the session. @@ -173,7 +169,7 @@ func (s *SceneSession) SaveData(v any) error { if err != nil { return err } - s.Data = data + s.data = data return nil } diff --git a/scene_handler.go b/scene_handler.go index fb22545..65ebdf9 100644 --- a/scene_handler.go +++ b/scene_handler.go @@ -24,7 +24,7 @@ func (bot *Bot[T]) tryHandleScene(ctx *MessageContext) (bool, error) { if !ok { continue } - if scene.PluginName != "" && scene.PluginName != plugin.name { + if scene.pluginName != "" && scene.pluginName != plugin.name { continue } if !plugin.executeMiddlewares(ctx, bot.appData) { @@ -176,7 +176,7 @@ func (bot *Bot[T]) emitSceneStarted(ctx *SceneContext, scene *Scene[T], kind Han bot.safeEmitEvent(ctx.Context(), HandlerStartedEvent{ UpdateID: ctx.Update.UpdateID, UpdateType: ctx.Update.Type, - Plugin: scene.PluginName, + Plugin: scene.pluginName, HandlerKind: kind, HandlerName: name, FromID: ctx.FromID, @@ -188,7 +188,7 @@ func (bot *Bot[T]) emitSceneFinished(ctx *SceneContext, scene *Scene[T], kind Ha bot.safeEmitEvent(ctx.Context(), HandlerFinishedEvent{ UpdateID: ctx.Update.UpdateID, UpdateType: ctx.Update.Type, - Plugin: scene.PluginName, + Plugin: scene.pluginName, HandlerKind: kind, HandlerName: name, FromID: ctx.FromID, @@ -203,7 +203,7 @@ func (bot *Bot[T]) emitSceneError(ctx *SceneContext, scene *Scene[T], kind Handl bot.safeEmitEvent(ctx.Context(), ErrorEvent{ UpdateID: ctx.Update.UpdateID, UpdateType: ctx.Update.Type, - Plugin: scene.PluginName, + Plugin: scene.pluginName, HandlerKind: kind, HandlerName: name, FromID: ctx.FromID, @@ -229,8 +229,8 @@ func (bot *Bot[T]) emitSceneTransition(ctx *SceneContext, scene *Scene[T], from } bot.safeEmitEvent(ctx.Context(), SceneTransitionEvent{ - Plugin: scene.PluginName, - Scene: scene.Name, + Plugin: scene.pluginName, + Scene: scene.name, From: from, To: to, Action: result.Action, diff --git a/scene_test.go b/scene_test.go index a641f03..db34f82 100644 --- a/scene_test.go +++ b/scene_test.go @@ -36,8 +36,8 @@ func TestPluginAddSceneRegistersScene(t *testing.T) { if got, ok := plugin.scenes["signup"]; !ok || got != scene { t.Fatalf("scene was not registered in plugin: ok=%v got=%p want=%p", ok, got, scene) } - if scene.PluginName != "wizard" { - t.Fatalf("unexpected plugin name on scene: got %q want %q", scene.PluginName, "wizard") + if scene.pluginName != "wizard" { + t.Fatalf("unexpected plugin name on scene: got %q want %q", scene.pluginName, "wizard") } } @@ -401,7 +401,7 @@ func TestSceneMessageObserverEmitsLifecycleEvents(t *testing.T) { if !ok { t.Fatal("expected scene key to be built") } - if err := bot.sessionStore.Set(key, SceneSession{Scene: scene.Name}); err != nil { + if err := bot.sessionStore.Set(key, SceneSession{Scene: scene.name}); err != nil { t.Fatalf("failed to seed scene session: %v", err) } diff --git a/tgapi/api.go b/tgapi/api.go index 73db648..78a9e0c 100644 --- a/tgapi/api.go +++ b/tgapi/api.go @@ -328,13 +328,11 @@ func (r TelegramRequest[R, P]) Do(api *API) (R, error) { return r.DoWithContext(context.Background(), api) } -// Internal helper that reads and caps a Telegram response body. func readBody(body io.ReadCloser) ([]byte, error) { reader := io.LimitReader(body, 10<<20) // 10 MB return io.ReadAll(reader) } -// Internal helper that parses a typed Telegram API response body. func parseBody[R any](data []byte) (TelegramResponse[R], error) { var resp TelegramResponse[R] err := json.Unmarshal(data, &resp) diff --git a/tgapi/types.go b/tgapi/types.go index 901ece6..291fa2d 100644 --- a/tgapi/types.go +++ b/tgapi/types.go @@ -58,6 +58,7 @@ const ( // UpdateTypeRemovedChatBoost is a removed chat boost update. UpdateTypeRemovedChatBoost UpdateType = "removed_chat_boost" + // UpdateTypeManagedBot is a managed bot update. UpdateTypeManagedBot UpdateType = "managed_bot" // UpdateTypeGuestMessage is a guest message update. diff --git a/tgapi/uploader_api.go b/tgapi/uploader_api.go index 17eb6a4..e02b3e5 100644 --- a/tgapi/uploader_api.go +++ b/tgapi/uploader_api.go @@ -177,10 +177,14 @@ func (r UploaderRequest[R, P]) doRequest(ctx context.Context, up *Uploader) (R, case <-ctx.Done(): return zero, ctx.Err() case <-time.After(time.Duration(after) * time.Second): - continue // Повторяем запрос + continue } } - return zero, fmt.Errorf("[%d] %s", response.ErrorCode, response.Description) + return zero, &ResponseError{ + Code: response.ErrorCode, + Description: response.Description, + Parameters: response.Parameters, + } } return response.Result, nil } @@ -218,7 +222,6 @@ func (r UploaderRequest[R, P]) Do(up *Uploader) (R, error) { return r.DoWithContext(context.Background(), up) } -// Internal helper that builds a finalized multipart body from files and params. func prepareMultipart[P any](files []UploaderFile, params P) (*bytes.Buffer, string, error) { buf := bytes.NewBuffer(nil) w := multipart.NewWriter(buf) @@ -251,7 +254,6 @@ func prepareMultipart[P any](files []UploaderFile, params P) (*bytes.Buffer, str return buf, w.FormDataContentType(), nil } -// Internal helper that infers an upload field name from a file extension. func uploaderTypeByExt(filename string) UploaderFileType { ext := strings.ToLower(filepath.Ext(filename)) switch ext { diff --git a/utils/limiter.go b/utils/limiter.go index 698d692..c38c1a5 100644 --- a/utils/limiter.go +++ b/utils/limiter.go @@ -134,7 +134,6 @@ func (rl *RateLimiter) Wait(ctx context.Context, chatID int64) error { return chatLimiter.Wait(ctx) } -// Internal helper that returns the global limiter under read lock. func (rl *RateLimiter) getGlobalLimiter() *rate.Limiter { rl.globalMu.RLock() defer rl.globalMu.RUnlock() @@ -222,7 +221,6 @@ func (rl *RateLimiter) Check(ctx context.Context, dropOverflow bool, chatID int6 return nil } -// Internal helper that waits for the global cooldown to expire. func (rl *RateLimiter) waitForGlobalUnlock(ctx context.Context) error { rl.globalMu.RLock() until := rl.globalLockUntil @@ -240,7 +238,6 @@ func (rl *RateLimiter) waitForGlobalUnlock(ctx context.Context) error { } } -// Internal helper that waits for a chat-specific cooldown to expire. func (rl *RateLimiter) waitForChatUnlock(ctx context.Context, chatID int64) error { rl.chatMu.RLock() until, ok := rl.chatLocks[chatID] @@ -258,7 +255,6 @@ 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 { now := time.Now()