diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f4cd31..c430c14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,17 +2,35 @@ ## v1.0.0-rc.16 +### Breaking Changes +- Replaced `git.scuroneko.dev/scuroneko/slog` with `git.scuroneko.dev/scuroneko/sneklog/v2` across public logger APIs, including `AppDataLogger`, logger getters, and custom logger setters. +- Renamed exported `Json`, `Url`, and `Id` identifiers to idiomatic `JSON`, `URL`, and `ID` spellings, including `BotOpts.APIURL`, `BotOpts.SetAPIURL(...)`, `tgapi.APIOpts.SetAPIURL(...)`, `BotOptsFileJSONCodec`, `BotPayloadJSON`, and related README examples. +- Made the request logger field internal; use `Bot.SetRequestLogger(...)` and `Bot.GetRequestLogger()` instead of accessing `Bot.RequestLogger` directly. + +### Added +- Added `Bot.UpdatesIter(...)` as an iterator wrapper around a single `Bot.Updates(...)` call, including error delivery through the iterator. +- Added scene-local callback payload handlers through `Scene.OnPayload(...)`, including observer lifecycle events for scene payload execution. +- Added configurable logger output through `BotOpts.LogFormat`, `BotOpts.SetLogFormat(...)`, `BotOpts.SetLogFormatter(...)`, `tgapi.APIOpts.SetLogFormat(...)`, and `tgapi.APIOpts.SetLogFormatter(...)`. +- Added JSON BotOpts file format versioning through `ConfigVersion`, `ErrConfigVersionMismatch`, and `BotOpts.FileConfigVersion`. +- Added `Bot.SetLogger(...)`, `Bot.SetRequestLogger(...)`, `Bot.SetWebHookLogger(...)`, `Bot.GetRequestLogger()`, and `Bot.GetWebHookLogger()` helpers for explicit logger customization. + ### Changed -- Updated `slog` to `v2`. -- Bot loggers now apply the configured token replacer consistently across the main bot logger, request logger, internal API and uploader loggers, webhook logger, and auto-managed plugin loggers, so bot tokens stay masked in both stdout and file-backed logs. +- Updated `pond/v2` to `v2.7.1`. +- `Bot.RunWithContext(...)` now closes an explicitly set request logger when `UseRequestLogger` is false and closes webhook loggers before long-polling startup. +- Bot loggers now apply the configured token replacer consistently across the main bot logger, request logger, internal API and uploader loggers, webhook logger, app-data logger writers, and auto-managed plugin loggers. - JSON `BotOpts` files now write `version`, reject newer unsupported config versions, keep older unversioned files loadable, and preserve the loaded file version in `BotOpts.FileConfigVersion`. -- Active scenes now support scene-local callback payload handlers through `Scene.OnPayload(...)`, including observer lifecycle events for scene payload execution. -- Updated Go initialism names for JSON, URL, ID, and API helpers. +- `Bot.RunWithContext(...)` treats `context.DeadlineExceeded` like `context.Canceled` and exits polling without retry logging. +- README and README_RU now use the current `JSON`, `URL`, and `ID` public API names. + +### Fixed +- Fixed the go-lint workflow file to end with a newline. ### Tests +- Added regression coverage for `Bot.UpdatesIter(...)` error delivery and early iterator stop behavior. +- Added regression coverage proving `Bot.RunWithContext(...)` preserves polling retry attempts and backoff delays across repeated getUpdates failures. - Added regression coverage proving polling startup preserves an enabled request logger. - Updated file logger regression coverage for the current `sneklog` text prefix format. -- Added regression coverage proving token masking still applies after `initLoggers(...)` switches loggers to file-backed writers and that auto-managed plugin loggers inherit token masking as well. +- Added regression coverage proving token masking still applies after `initLoggers(...)` switches loggers to file-backed writers and that auto-managed plugin loggers inherit token masking. - Added regression coverage for JSON config version handling and scene-local payload routing, including observer lifecycle events and callback fallthrough behavior. - Updated logger helper tests for the explicit log format and formatter parameters. diff --git a/bot.go b/bot.go index afc9176..7f3a8ff 100644 --- a/bot.go +++ b/bot.go @@ -442,7 +442,7 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) error { default: updates, err := bot.Updates(ctx) if err != nil { - if errors.Is(err, context.Canceled) { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { return } bot.logger.Errorln("failed to fetch updates:", err) diff --git a/bot_test.go b/bot_test.go index 7104985..c35b5fb 100644 --- a/bot_test.go +++ b/bot_test.go @@ -24,12 +24,13 @@ func (f pollingRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, erro type pollingRetryObserver struct { recordingObserver - cancel context.CancelFunc + cancel context.CancelFunc + cancelAfter int } func (o *pollingRetryObserver) OnPollingRetry(ctx context.Context, ev PollingRetryEvent) { o.recordingObserver.OnPollingRetry(ctx, ev) - if o.cancel != nil { + if o.cancel != nil && (o.cancelAfter == 0 || len(o.retries) >= o.cancelAfter) { o.cancel() } } @@ -510,6 +511,55 @@ func TestRunWithContextEmitsPollingRetryAndErrorEvents(t *testing.T) { } } +func TestRunWithContextPreservesPollingRetryBackoff(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + observer := &pollingRetryObserver{cancel: cancel, cancelAfter: 2} + + client := &http.Client{ + Transport: pollingRoundTripFunc(func(r *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"ok":false,"error_code":500,"description":"boom"}`)), + }, nil + }), + } + + api := tgapi.NewAPI( + tgapi.NewAPIOpts("token"). + SetAPIURL("http://example.invalid"). + SetHTTPClient(client), + ) + defer func() { + _ = api.Close() + }() + + bot := &Bot[NoData]{ + logger: sneklog.NewLogger(), + api: api, + prefixes: []string{"/"}, + plugins: []Plugin[NoData]{{name: "demo"}}, + updateQueue: make(chan *tgapi.Update, 1), + maxWorkers: 1, + observer: observer, + } + + if err := bot.RunWithContext(ctx); err != nil { + t.Fatalf("RunWithContext returned error: %v", err) + } + + if len(observer.retries) != 2 { + t.Fatalf("expected two polling retry events, got %d", len(observer.retries)) + } + if got := observer.retries[0]; got.Attempt != 1 || got.Delay != time.Second { + t.Fatalf("unexpected first retry event: %#v", got) + } + if got := observer.retries[1]; got.Attempt != 2 || got.Delay != 2*time.Second { + t.Fatalf("unexpected second retry event: %#v", got) + } +} + func TestBotConfigurationFreezesAfterRunStarts(t *testing.T) { type testDB struct{ Name string } diff --git a/methods.go b/methods.go index b72802b..50e24e6 100644 --- a/methods.go +++ b/methods.go @@ -3,6 +3,7 @@ package laniakea import ( "context" "encoding/json" + "iter" "git.scuroneko.dev/scuroneko/laniakea/tgapi" ) @@ -67,3 +68,22 @@ func (bot *Bot[T]) Updates(ctx context.Context) ([]tgapi.Update, error) { } return updates, err } + +// UpdatesIter fetches updates once and yields each update in order. +// +// If fetching updates fails, the iterator yields the error once with a zero +// update and then stops. +func (bot *Bot[T]) UpdatesIter(ctx context.Context) iter.Seq2[tgapi.Update, error] { + return func(yield func(tgapi.Update, error) bool) { + updates, err := bot.Updates(ctx) + if err != nil { + yield(tgapi.Update{}, err) + return + } + for _, u := range updates { + if !yield(u, nil) { + return + } + } + } +} diff --git a/methods_test.go b/methods_test.go new file mode 100644 index 0000000..1e95a79 --- /dev/null +++ b/methods_test.go @@ -0,0 +1,78 @@ +package laniakea + +import ( + "context" + "io" + "net/http" + "strings" + "testing" + + "git.scuroneko.dev/scuroneko/laniakea/tgapi" +) + +func TestUpdatesIterYieldsFetchError(t *testing.T) { + bot := newUpdatesIterTestBot(t, `{"ok":false,"error_code":500,"description":"boom"}`) + + var gotErr error + var gotUpdates int + bot.UpdatesIter(context.Background())(func(update tgapi.Update, err error) bool { + gotUpdates++ + if update.UpdateID != 0 { + t.Fatalf("expected zero update on error, got %d", update.UpdateID) + } + gotErr = err + return true + }) + + if gotUpdates != 1 { + t.Fatalf("expected one yielded error, got %d yields", gotUpdates) + } + if gotErr == nil { + t.Fatal("expected fetch error") + } + if !strings.Contains(gotErr.Error(), "boom") { + t.Fatalf("expected Telegram error description, got %v", gotErr) + } +} + +func TestUpdatesIterStopsWhenYieldReturnsFalse(t *testing.T) { + bot := newUpdatesIterTestBot(t, `{"ok":true,"result":[{"update_id":11},{"update_id":12}]}`) + + var gotIDs []int + bot.UpdatesIter(context.Background())(func(update tgapi.Update, err error) bool { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + gotIDs = append(gotIDs, update.UpdateID) + return false + }) + + if len(gotIDs) != 1 || gotIDs[0] != 11 { + t.Fatalf("expected only first update, got %v", gotIDs) + } +} + +func newUpdatesIterTestBot(t *testing.T, response string) *Bot[NoData] { + t.Helper() + + api := tgapi.NewAPI( + tgapi.NewAPIOpts("token"). + SetAPIURL("https://example.test"). + SetHTTPClient(&http.Client{ + Transport: pollingRoundTripFunc(func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(response)), + }, nil + }), + }), + ) + t.Cleanup(func() { + if err := api.Close(); err != nil { + t.Fatalf("Close returned error: %v", err) + } + }) + + return &Bot[NoData]{api: api} +}