From 4807dec6aef2156d49038da0b7c9b60558102e3a Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Wed, 29 Apr 2026 13:11:54 +0300 Subject: [PATCH] (fix): observer lifecycle (tests): webhook runtime (doc): logger options --- CHANGELOG.md | 10 +++++++++ bot_opts.go | 9 ++++++-- bot_webhook_test.go | 50 +++++++++++++++++++++++++++++++++++++++++++++ handler.go | 8 ++++++++ handler_test.go | 43 +++++++++++++++++++++++++++++++++++++- tgapi/api.go | 3 +++ utils/utils.go | 7 +++++-- utils/version.go | 4 ++-- 8 files changed, 127 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c430c14..b99d636 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## v1.0.0 + +### Changed +- Bot-level middleware blocks now emit a final `UpdateHandledEvent` with `Handled=false`, keeping observer update lifecycles balanced. +- `BotOpts`, `tgapi.APIOpts`, and logger utility godoc now document `LOG_FORMAT`, `LogFormat`, and logger formatting options consistently. + +### Tests +- Added regression coverage proving bot-level middleware blocks still complete the observer update lifecycle. +- Added webhook runtime regression coverage for request enqueue through worker execution of a command handler. + ## v1.0.0-rc.16 ### Breaking Changes diff --git a/bot_opts.go b/bot_opts.go index d03e1e7..790927d 100644 --- a/bot_opts.go +++ b/bot_opts.go @@ -71,7 +71,9 @@ type BotOpts struct { // It is zero when the options were not loaded from a versioned file. FileConfigVersion int - LogFormat utils.LogFormat + // LogFormat selects text or JSON output for bot-managed loggers. + LogFormat utils.LogFormat + // LogFormatter customizes bot-managed logger writers when supported. LogFormatter *sneklog.Formatter } @@ -92,7 +94,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) -// - JSON_LOG: +// - LOG_FORMAT: logger output format, "text" or "json" (default: "text") // // Returns a populated BotOpts. // NewBot validates required fields and returns ErrTokenRequired when TG_TOKEN is missing. @@ -254,10 +256,13 @@ func (opts *BotOpts) SetMaxWorkers(workers int) *BotOpts { return opts } +// SetLogFormat sets the output format used by bot-managed loggers. func (opts *BotOpts) SetLogFormat(format utils.LogFormat) *BotOpts { opts.LogFormat = format return opts } + +// SetLogFormatter sets the formatter used by bot-managed logger writers. func (opts *BotOpts) SetLogFormatter(formatter *sneklog.Formatter) *BotOpts { opts.LogFormatter = formatter return opts diff --git a/bot_webhook_test.go b/bot_webhook_test.go index 00c95e4..7df0fb2 100644 --- a/bot_webhook_test.go +++ b/bot_webhook_test.go @@ -9,6 +9,7 @@ import ( "strings" "sync/atomic" "testing" + "time" "git.scuroneko.dev/scuroneko/laniakea/tgapi" "git.scuroneko.dev/scuroneko/sneklog/v2" @@ -108,6 +109,55 @@ func TestRunWebhookRuntimeExecutesRunners(t *testing.T) { } } +func TestRunWebhookRuntimeProcessesEnqueuedUpdate(t *testing.T) { + var calls atomic.Int32 + plugin := NewPlugin[NoData]("demo") + plugin.NewCommand(func(ctx *MsgContext, db NoData) error { + calls.Add(1) + return nil + }, "start") + + bot := &Bot[NoData]{ + logger: sneklog.NewLogger(), + webHookLogger: sneklog.NewLogger(), + prefixes: []string{"/"}, + plugins: []Plugin[NoData]{*plugin}, + updateQueue: make(chan *tgapi.Update, 1), + maxWorkers: 1, + } + t.Cleanup(func() { + _ = bot.logger.Close() + _ = bot.webHookLogger.Close() + }) + + err := bot.runWebhookRuntime(context.Background(), func(ctx context.Context) error { + 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) + if rec.Result().StatusCode != http.StatusOK { + t.Fatalf("unexpected status: got %d want %d", rec.Result().StatusCode, http.StatusOK) + } + + deadline := time.After(time.Second) + for calls.Load() == 0 { + select { + case <-deadline: + t.Fatal("webhook runtime did not process enqueued update") + default: + time.Sleep(time.Millisecond) + } + } + return nil + }) + if err != nil { + t.Fatalf("runWebhookRuntime returned error: %v", err) + } + if calls.Load() != 1 { + t.Fatalf("expected command handler to run once, got %d", calls.Load()) + } +} + func TestWebhookAllowedUpdatesUsesBotUpdateTypesByDefault(t *testing.T) { bot := &Bot[NoData]{ updateTypes: []tgapi.UpdateType{tgapi.UpdateTypeMessage, tgapi.UpdateTypeCallbackQuery}, diff --git a/handler.go b/handler.go index 07bc6ef..f4e2a54 100644 --- a/handler.go +++ b/handler.go @@ -46,6 +46,14 @@ func (bot *Bot[T]) handle(parentCtx context.Context, u *tgapi.Update) { for _, middleware := range bot.middlewares { if !middleware.Execute(msgCtx, bot.appData) { + bot.safeEmitEvent(ctx, UpdateHandledEvent{ + UpdateID: u.UpdateID, + UpdateType: u.Type, + FromID: msgCtx.FromID, + ChatID: msgCtx.ChatID, + Duration: time.Since(startTime), + Handled: false, + }) return } } diff --git a/handler_test.go b/handler_test.go index 3b76ebb..f61b4ff 100644 --- a/handler_test.go +++ b/handler_test.go @@ -10,6 +10,7 @@ import ( ) type recordingObserver struct { + received []UpdateReceivedEvent started []HandlerStartedEvent finished []HandlerFinishedEvent errors []ErrorEvent @@ -19,7 +20,9 @@ type recordingObserver struct { retries []PollingRetryEvent } -func (*recordingObserver) OnReceiveUpdate(context.Context, UpdateReceivedEvent) {} +func (o *recordingObserver) OnReceiveUpdate(_ context.Context, ev UpdateReceivedEvent) { + o.received = append(o.received, ev) +} func (o *recordingObserver) OnHandledUpdate(_ context.Context, ev UpdateHandledEvent) { o.handled = append(o.handled, ev) } @@ -587,6 +590,44 @@ func TestHandleUpdateObserverEmitsUpdateErrors(t *testing.T) { } } +func TestHandleObserverCompletesUpdateWhenBotMiddlewareBlocks(t *testing.T) { + observer := &recordingObserver{} + bot := &Bot[NoData]{ + logger: sneklog.NewLogger(), + observer: observer, + middlewares: []Middleware[NoData]{ + NewMiddleware("block", func(ctx *MsgContext, db NoData) bool { + return false + }), + }, + } + + bot.handle(context.Background(), &tgapi.Update{ + UpdateID: 8, + Type: tgapi.UpdateTypeMessage, + Message: &tgapi.Message{ + MessageID: 1, + Date: 1, + Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}, + Text: "/start", + }, + }) + + if len(observer.received) != 1 { + t.Fatalf("expected one received event, got %d", len(observer.received)) + } + if len(observer.handled) != 1 { + t.Fatalf("expected one handled event, got %d", len(observer.handled)) + } + got := observer.handled[0] + if got.UpdateID != 8 || got.UpdateType != tgapi.UpdateTypeMessage || got.ChatID != 42 || got.Handled { + t.Fatalf("unexpected handled event: %#v", got) + } + if len(observer.started) != 0 || len(observer.finished) != 0 || len(observer.errors) != 0 { + t.Fatalf("middleware block should not emit handler lifecycle or errors: started=%d finished=%d errors=%d", len(observer.started), len(observer.finished), len(observer.errors)) + } +} + func TestHandleMessageFallbackRunsAfterCommandMiss(t *testing.T) { observer := &recordingObserver{} called := false diff --git a/tgapi/api.go b/tgapi/api.go index ee09625..74d67ab 100644 --- a/tgapi/api.go +++ b/tgapi/api.go @@ -64,10 +64,13 @@ func (opts *APIOpts) SetAPIURL(apiURL string) *APIOpts { return opts } +// SetLogFormat sets the output format used by API-managed loggers. func (opts *APIOpts) SetLogFormat(format utils.LogFormat) *APIOpts { opts.logFormat = format return opts } + +// SetLogFormatter sets the formatter used by API-managed logger writers. func (opts *APIOpts) SetLogFormatter(formatter *sneklog.Formatter) *APIOpts { opts.logFormatter = formatter return opts diff --git a/utils/utils.go b/utils/utils.go index cc94244..a0a7570 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -6,10 +6,13 @@ import ( "git.scuroneko.dev/scuroneko/sneklog/v2" ) +// LogFormat selects the writer format used by framework loggers. type LogFormat string const ( + // LogFormatText writes human-readable text logs. LogFormatText LogFormat = "text" + // LogFormatJSON writes structured JSON logs. LogFormatJSON LogFormat = "json" ) @@ -22,8 +25,8 @@ func GetLoggerLevel() sneklog.LogLevel { return level } -// CreateLogger creates a logger with the shared default policy: -// JSON stdout output, provided prefix, and provided level. +// CreateLogger creates a logger with stdout output, the provided name, level, +// format, and optional formatter. func CreateLogger( name string, level sneklog.LogLevel, format LogFormat, formatter *sneklog.Formatter, diff --git a/utils/version.go b/utils/version.go index abd6b55..aa4a98a 100644 --- a/utils/version.go +++ b/utils/version.go @@ -2,7 +2,7 @@ package utils const ( // VersionString is the module version string. - VersionString = "1.0.0-rc.16" + VersionString = "1.0.0" // VersionMajor is the module major version. VersionMajor = 1 // VersionMinor is the module minor version. @@ -10,5 +10,5 @@ const ( // VersionPatch is the module patch version. VersionPatch = 0 // VersionBeta is the prerelease counter for the current version. - VersionBeta = 16 + VersionBeta = 0 )