REPOSITORY / ScuroNeko/Laniakea

Pull Requests

PULL REQUESTS REPOSITORY

v1.0.0 #9

Merged
ScuroNeko merged 101 commits from dev into main 2026-05-20 13:43:34 +03:00
8 changed files with 127 additions and 7 deletions
Showing only changes of commit 4807dec6ae - Show all commits
+10
View File
@@ -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
+7 -2
View File
@@ -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
+50
View File
@@ -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},
+8
View File
@@ -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
}
}
+42 -1
View File
@@ -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
+3
View File
@@ -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
+5 -2
View File
@@ -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,
+2 -2
View File
@@ -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
)