(new): add scene payload routing
Golang lint / lint (push) Has been cancelled

(fix): stabilize config file versions

(tests): cover routing and versions

(doc): improve exported godoc
This commit is contained in:
2026-04-24 11:34:53 +03:00
parent 7d4b150b0b
commit b0882a46d5
11 changed files with 327 additions and 17 deletions
+6 -3
View File
@@ -123,9 +123,12 @@ Prefer the repositorys documented commands. If multiple choices exist, use th
## Commit message format ## Commit message format
- When the user asks for a commit message, the agent must produce it in this format: - When the user asks for a commit message, the agent must produce it in this format:
1. a short summary line; 1. one to four short lines;
2. up to three additional lines with only the most important changes; 2. each line must use the format `(<kind>): <text>`;
3. each additional line must start on its own new line. 3. `<kind>` must be a short change type such as `fix`, `new`, `tests`, `doc`, `refactor`, or `ci/cd`;
4. `<text>` must be a concise 1-5 word description of the change or function;
5. each line must start on its own new line;
6. when multiple lines are present, kinds must be ordered from top to bottom by this priority: `new`, `fix`, `refactor`, `ci/cd`, `tests`, `doc`.
- The agent must output the commit message as a plain multiline block that the user can copy directly. - The agent must output the commit message as a plain multiline block that the user can copy directly.
- Do not collapse the lines into a paragraph, bullet list, or wrapped prose explanation. - Do not collapse the lines into a paragraph, bullet list, or wrapped prose explanation.
- Keep commit text concise and high-signal. - Keep commit text concise and high-signal.
+3
View File
@@ -4,9 +4,12 @@
### Changed ### Changed
- 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. - 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.
- 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.
### Tests ### Tests
- 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 as well.
- Added regression coverage for JSON config version handling and scene-local payload routing, including observer lifecycle events and callback fallthrough behavior.
## v1.0.0-rc.15 ## v1.0.0-rc.15
+10
View File
@@ -209,6 +209,16 @@ func NewBot[T any](opts *BotOpts) (*Bot[T], error) {
} }
bot.initLoggers(opts) bot.initLoggers(opts)
if opts.FileConfigVersion > 0 && opts.FileConfigVersion < ConfigVersion {
bot.logger.Warnln(
fmt.Sprintf(
"Config file version %d is older than library version %d; please update your config file to access new features and avoid compatibility issues",
opts.FileConfigVersion,
ConfigVersion,
),
)
}
// Fetch bot info to validate token and get username // Fetch bot info to validate token and get username
u, err := api.GetMe() u, err := api.GetMe()
if err != nil { if err != nil {
+7
View File
@@ -62,6 +62,12 @@ type BotOpts struct {
// MaxWorkers is the maximum number of update handlers that may run concurrently. // MaxWorkers is the maximum number of update handlers that may run concurrently.
MaxWorkers int MaxWorkers int
// FileConfigVersion stores the version declared by the config file used to
// load these options.
//
// It is zero when the options were not loaded from a versioned file.
FileConfigVersion int
} }
// LoadOptsFromEnv loads BotOpts from environment variables. // LoadOptsFromEnv loads BotOpts from environment variables.
@@ -126,6 +132,7 @@ func LoadOptsFromEnv() *BotOpts {
StrictPayloadType: os.Getenv("STRICT_PAYLOAD_TYPE") == "true", StrictPayloadType: os.Getenv("STRICT_PAYLOAD_TYPE") == "true",
MaxWorkers: maxWorkers, MaxWorkers: maxWorkers,
FileConfigVersion: ConfigVersion,
} }
} }
+15
View File
@@ -2,6 +2,7 @@ package laniakea
import ( import (
"encoding/json" "encoding/json"
"fmt"
"io" "io"
"os" "os"
"regexp" "regexp"
@@ -9,8 +10,16 @@ import (
"git.scuroneko.dev/scuroneko/laniakea/tgapi" "git.scuroneko.dev/scuroneko/laniakea/tgapi"
) )
// ConfigVersion is the current version of the built-in JSON BotOpts file format.
const ConfigVersion = 1
// ErrConfigVersionMismatch reports that a config file declares a newer version
// than this library knows how to decode.
var ErrConfigVersionMismatch = fmt.Errorf("config version mismatch: expected %d", ConfigVersion)
// BotOptsFileJson is the JSON file representation of BotOpts. // BotOptsFileJson is the JSON file representation of BotOpts.
type BotOptsFileJson struct { type BotOptsFileJson struct {
Version int `json:"version"`
Token string `json:"token"` Token string `json:"token"`
UpdateTypes []tgapi.UpdateType `json:"update_types"` UpdateTypes []tgapi.UpdateType `json:"update_types"`
Debug bool `json:"debug"` Debug bool `json:"debug"`
@@ -41,6 +50,9 @@ func (codec BotOptsFileJsonCodec) FromBytes(data []byte) (*BotOpts, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
if fileOpts.Version > ConfigVersion {
return nil, ErrConfigVersionMismatch
}
opts := &BotOpts{ opts := &BotOpts{
Token: fileOpts.Token, Token: fileOpts.Token,
UpdateTypes: fileOpts.UpdateTypes, UpdateTypes: fileOpts.UpdateTypes,
@@ -59,6 +71,8 @@ func (codec BotOptsFileJsonCodec) FromBytes(data []byte) (*BotOpts, error) {
StrictPayloadType: fileOpts.StrictPayloadType, StrictPayloadType: fileOpts.StrictPayloadType,
MaxWorkers: fileOpts.MaxWorkers, MaxWorkers: fileOpts.MaxWorkers,
FileConfigVersion: fileOpts.Version,
} }
return opts, nil return opts, nil
} }
@@ -66,6 +80,7 @@ func (codec BotOptsFileJsonCodec) FromBytes(data []byte) (*BotOpts, error) {
// ToBytes encodes BotOpts into JSON file bytes. // ToBytes encodes BotOpts into JSON file bytes.
func (codec BotOptsFileJsonCodec) ToBytes(opts *BotOpts) ([]byte, error) { func (codec BotOptsFileJsonCodec) ToBytes(opts *BotOpts) ([]byte, error) {
fileOpts := &BotOptsFileJson{ fileOpts := &BotOptsFileJson{
Version: ConfigVersion,
Token: opts.Token, Token: opts.Token,
UpdateTypes: opts.UpdateTypes, UpdateTypes: opts.UpdateTypes,
Debug: opts.Debug, Debug: opts.Debug,
+23
View File
@@ -1,6 +1,7 @@
package laniakea package laniakea
import ( import (
"errors"
"os" "os"
"path/filepath" "path/filepath"
"reflect" "reflect"
@@ -26,6 +27,7 @@ func TestBotOptsFileJsonCodecRoundTrip(t *testing.T) {
DropRLOverflow: true, DropRLOverflow: true,
StrictPayloadType: true, StrictPayloadType: true,
MaxWorkers: 64, MaxWorkers: 64,
FileConfigVersion: ConfigVersion,
} }
data, err := codec.ToBytes(want) data, err := codec.ToBytes(want)
@@ -74,6 +76,9 @@ func TestLoadBotOptsFileExpandsEnvPlaceholders(t *testing.T) {
if got.ErrorTemplate != "Error: %s" { if got.ErrorTemplate != "Error: %s" {
t.Fatalf("unexpected error template: got %q", got.ErrorTemplate) t.Fatalf("unexpected error template: got %q", got.ErrorTemplate)
} }
if got.FileConfigVersion != 0 {
t.Fatalf("unexpected file config version: got %d want 0", got.FileConfigVersion)
}
} }
func TestLoadBotOptsFileReturnsDecodeError(t *testing.T) { func TestLoadBotOptsFileReturnsDecodeError(t *testing.T) {
@@ -99,6 +104,7 @@ func TestSaveBotOptsFileWritesEncodedData(t *testing.T) {
APIUrl: "https://api.example.invalid", APIUrl: "https://api.example.invalid",
RateLimit: 30, RateLimit: 30,
MaxWorkers: 32, MaxWorkers: 32,
FileConfigVersion: ConfigVersion,
} }
if err := SaveBotOptsFile(BotOptsFileJsonCodec{}, filename, want); err != nil { if err := SaveBotOptsFile(BotOptsFileJsonCodec{}, filename, want); err != nil {
@@ -114,3 +120,20 @@ func TestSaveBotOptsFileWritesEncodedData(t *testing.T) {
t.Fatalf("saved file mismatch:\n got: %#v\nwant: %#v", got, want) t.Fatalf("saved file mismatch:\n got: %#v\nwant: %#v", got, want)
} }
} }
func TestLoadBotOptsFileRejectsFutureConfigVersion(t *testing.T) {
dir := t.TempDir()
filename := filepath.Join(dir, "config.json")
data := []byte(`{
"version": 2,
"token": "TOKEN"
}`)
if err := os.WriteFile(filename, data, 0o644); err != nil {
t.Fatalf("WriteFile returned error: %v", err)
}
_, err := LoadBotOptsFile(BotOptsFileJsonCodec{}, filename)
if !errors.Is(err, ErrConfigVersionMismatch) {
t.Fatalf("expected ErrConfigVersionMismatch, got %v", err)
}
}
+4 -6
View File
@@ -204,13 +204,11 @@ func cloneScene[T AppData](scene *Scene[T]) *Scene[T] {
cloned := *scene cloned := *scene
cloned.steps = make(map[string]SceneHandler[T], len(scene.steps)) cloned.steps = make(map[string]SceneHandler[T], len(scene.steps))
cloned.commands = make(map[string]SceneHandler[T], len(scene.commands)) cloned.commands = make(map[string]SceneHandler[T], len(scene.commands))
cloned.payloads = make(map[string]SceneHandler[T], len(scene.payloads))
for name, handler := range scene.steps { maps.Copy(cloned.steps, scene.steps)
cloned.steps[name] = handler maps.Copy(cloned.commands, scene.commands)
} maps.Copy(cloned.payloads, scene.payloads)
for name, handler := range scene.commands {
cloned.commands[name] = handler
}
return &cloned return &cloned
} }
+2
View File
@@ -30,6 +30,8 @@ const (
HandlerSceneStepKind HandlerEventKind = "scene_step" HandlerSceneStepKind HandlerEventKind = "scene_step"
// HandlerSceneCommandKind identifies a scene-local command handler. // HandlerSceneCommandKind identifies a scene-local command handler.
HandlerSceneCommandKind HandlerEventKind = "scene_command" HandlerSceneCommandKind HandlerEventKind = "scene_command"
// HandlerScenePayloadKind identifies a scene-local callback payload handler.
HandlerScenePayloadKind HandlerEventKind = "scene_payload"
// HandlerSceneMessageKind identifies a scene message fallback handler. // HandlerSceneMessageKind identifies a scene message fallback handler.
HandlerSceneMessageKind HandlerEventKind = "scene_message" HandlerSceneMessageKind HandlerEventKind = "scene_message"
) )
+16
View File
@@ -21,6 +21,7 @@ type Scene[T any] struct {
steps map[string]SceneHandler[T] steps map[string]SceneHandler[T]
commands map[string]SceneHandler[T] commands map[string]SceneHandler[T]
payloads map[string]SceneHandler[T]
message SceneHandler[T] message SceneHandler[T]
} }
@@ -32,6 +33,7 @@ func NewScene[T any](name string) *Scene[T] {
Entry: "", Entry: "",
steps: make(map[string]SceneHandler[T]), steps: make(map[string]SceneHandler[T]),
commands: make(map[string]SceneHandler[T]), commands: make(map[string]SceneHandler[T]),
payloads: make(map[string]SceneHandler[T]),
message: nil, message: nil,
} }
} }
@@ -65,6 +67,12 @@ func (s *Scene[T]) OnCommand(cmd string, handler SceneHandler[T]) *Scene[T] {
return s return s
} }
// OnPayload registers a callback payload handler active while the scene is running.
func (s *Scene[T]) OnPayload(cmd string, handler SceneHandler[T]) *Scene[T] {
s.payloads[cmd] = handler
return s
}
// OnMessage registers a fallback handler used when no scene command or step matches. // OnMessage registers a fallback handler used when no scene command or step matches.
func (s *Scene[T]) OnMessage(handler SceneHandler[T]) *Scene[T] { func (s *Scene[T]) OnMessage(handler SceneHandler[T]) *Scene[T] {
s.message = handler s.message = handler
@@ -79,6 +87,14 @@ func (s *Scene[T]) executeCommand(cmd string, ctx *SceneContext, db T) (SceneRes
result, err := handler(ctx, db) result, err := handler(ctx, db)
return result, true, err return result, true, err
} }
func (s *Scene[T]) executePayload(cmd string, ctx *SceneContext, db T) (SceneResult, bool, error) {
handler, ok := s.payloads[cmd]
if !ok {
return SceneResult{}, false, nil
}
result, err := handler(ctx, db)
return result, true, err
}
func (s *Scene[T]) executeStep(step string, ctx *SceneContext, db T) (SceneResult, bool, error) { func (s *Scene[T]) executeStep(step string, ctx *SceneContext, db T) (SceneResult, bool, error) {
handler, ok := s.steps[step] handler, ok := s.steps[step]
if !ok { if !ok {
+35
View File
@@ -86,6 +86,41 @@ func (bot *Bot[T]) executeScene(ctx *SceneContext, scene *Scene[T]) (bool, error
// instead of also triggering the active scene step or fallback handler. // instead of also triggering the active scene step or fallback handler.
return false, nil return false, nil
} }
query := ctx.Update.CallbackQuery
if query != nil {
data, err := bot.decodePayload(query.Data)
if err != nil {
return false, err
}
ctx.Args = data.Args
cmd := data.Command
if _, ok := scene.payloads[cmd]; ok {
startTime := time.Now()
bot.emitSceneStarted(ctx, scene, HandlerScenePayloadKind, cmd)
res, _, err := scene.executePayload(cmd, ctx, bot.appData)
if err != nil {
bot.emitSceneFinished(ctx, scene, HandlerScenePayloadKind, cmd, startTime, err)
bot.emitSceneError(ctx, scene, HandlerScenePayloadKind, cmd, err)
return false, err
}
from := ctx.sess.Step
ok, err := bot.applySceneResult(scene, ctx, res)
bot.emitSceneFinished(ctx, scene, HandlerScenePayloadKind, cmd, startTime, err)
if err != nil {
bot.emitSceneError(ctx, scene, HandlerScenePayloadKind, cmd, err)
}
if ok {
bot.emitSceneTransition(ctx, scene, from, res)
}
return ok, err
}
// Unmatched payloads should not trigger the active scene step or fallback handler.
// This allows using payloads for other bot features like pagination without interfering with active scenes.
return false, nil
}
ctx.Text = text ctx.Text = text
ctx.Args = nil ctx.Args = nil
ctx.Prefix = "" ctx.Prefix = ""
+198
View File
@@ -430,6 +430,204 @@ func TestSceneMessageObserverEmitsLifecycleEvents(t *testing.T) {
} }
} }
func TestScenePayloadHandlerRunsBeforeStep(t *testing.T) {
payloadCalled := false
stepCalled := false
plugin := NewPlugin[NoData]("wizard")
plugin.NewScene("signup").
SetEntry("start").
OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) {
stepCalled = true
return ctx.Stay(), nil
}).
OnPayload("confirm", func(ctx *SceneContext, db NoData) (SceneResult, error) {
payloadCalled = true
if got, want := ctx.Args, []string{"7", "ok"}; len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
t.Fatalf("unexpected payload args: got %v want %v", got, want)
}
if ctx.Text != "" {
t.Fatalf("callback flow must not populate Text, got %q", ctx.Text)
}
return ctx.Exit(), nil
})
bot := &Bot[NoData]{
logger: slog.CreateLogger(),
payloadType: BotPayloadJson,
sessionStore: NewMemorySessionStore(),
sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser},
}
bot.AddPlugins(plugin)
enterCtx := &MsgContext{
Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}},
FromID: 42,
sceneRuntime: bot,
}
if err := enterCtx.EnterScene("signup"); err != nil {
t.Fatalf("EnterScene returned error: %v", err)
}
data, err := encodeJsonPayload(CallbackData{Command: "confirm", Args: []string{"7", "ok"}})
if err != nil {
t.Fatalf("encodeJsonPayload returned error: %v", err)
}
bot.handle(context.Background(), &tgapi.Update{
UpdateID: 25,
Type: tgapi.UpdateTypeCallbackQuery,
CallbackQuery: &tgapi.CallbackQuery{
ID: "cb-scene",
Data: data,
From: tgapi.User{ID: 42},
Message: &tgapi.Message{
MessageID: 12,
Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate},
},
},
})
if !payloadCalled {
t.Fatal("expected scene payload handler to be called")
}
if stepCalled {
t.Fatal("expected scene payload to short-circuit the active step")
}
}
func TestScenePayloadObserverEmitsLifecycleEvents(t *testing.T) {
observer := &recordingObserver{}
plugin := NewPlugin[NoData]("wizard")
plugin.NewScene("signup").
SetEntry("start").
OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) {
return ctx.Stay(), nil
}).
OnPayload("confirm", func(ctx *SceneContext, db NoData) (SceneResult, error) {
return ctx.Exit(), nil
})
bot := &Bot[NoData]{
logger: slog.CreateLogger(),
payloadType: BotPayloadJson,
sessionStore: NewMemorySessionStore(),
sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser},
observer: observer,
}
bot.AddPlugins(plugin)
enterCtx := &MsgContext{
Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}},
FromID: 42,
sceneRuntime: bot,
}
if err := enterCtx.EnterScene("signup"); err != nil {
t.Fatalf("EnterScene returned error: %v", err)
}
data, err := encodeJsonPayload(CallbackData{Command: "confirm"})
if err != nil {
t.Fatalf("encodeJsonPayload returned error: %v", err)
}
bot.handle(context.Background(), &tgapi.Update{
UpdateID: 26,
Type: tgapi.UpdateTypeCallbackQuery,
CallbackQuery: &tgapi.CallbackQuery{
ID: "cb-scene",
Data: data,
From: tgapi.User{ID: 42},
Message: &tgapi.Message{
MessageID: 13,
Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate},
},
},
})
if len(observer.started) != 1 {
t.Fatalf("expected one scene started event, got %d", len(observer.started))
}
if got := observer.started[0]; got.HandlerKind != HandlerScenePayloadKind || got.HandlerName != "confirm" || got.Plugin != "wizard" {
t.Fatalf("unexpected scene payload started event: %#v", got)
}
if len(observer.finished) != 1 {
t.Fatalf("expected one scene finished event, got %d", len(observer.finished))
}
if got := observer.finished[0]; got.HandlerKind != HandlerScenePayloadKind || got.HandlerName != "confirm" || got.Plugin != "wizard" || got.Err != nil {
t.Fatalf("unexpected scene payload finished event: %#v", got)
}
}
func TestSceneUnmatchedPayloadFallsThroughWithoutRunningStep(t *testing.T) {
stepCalled := false
plugin := NewPlugin[NoData]("wizard")
plugin.NewPayload(func(ctx *MsgContext, db NoData) error { return nil }, "ping")
plugin.NewScene("signup").
SetEntry("start").
OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) {
stepCalled = true
return ctx.Stay(), nil
})
bot := &Bot[NoData]{
logger: slog.CreateLogger(),
payloadType: BotPayloadJson,
sessionStore: NewMemorySessionStore(),
sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser},
}
bot.AddPlugins(plugin)
enterCtx := &MsgContext{
Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}},
FromID: 42,
sceneRuntime: bot,
}
if err := enterCtx.EnterScene("signup"); err != nil {
t.Fatalf("EnterScene returned error: %v", err)
}
key, ok := buildSceneKey(SceneScopeUserChat, &MsgContext{
Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}},
FromID: 42,
})
if !ok {
t.Fatal("expected scene key to be built")
}
data, err := encodeJsonPayload(CallbackData{Command: "ping"})
if err != nil {
t.Fatalf("encodeJsonPayload returned error: %v", err)
}
bot.handle(context.Background(), &tgapi.Update{
UpdateID: 27,
Type: tgapi.UpdateTypeCallbackQuery,
CallbackQuery: &tgapi.CallbackQuery{
ID: "cb-global",
Data: data,
From: tgapi.User{ID: 42},
Message: &tgapi.Message{
MessageID: 14,
Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate},
},
},
})
if stepCalled {
t.Fatal("scene step must not run for an unmatched payload")
}
after, err := bot.sessionStore.Get(key)
if err != nil {
t.Fatalf("Get after handle returned error: %v", err)
}
if after.Scene != "signup" || after.Step != "start" {
t.Fatalf("unexpected session after payload fallback: %#v", after)
}
}
func TestScenePassDoesNotPersistSessionData(t *testing.T) { func TestScenePassDoesNotPersistSessionData(t *testing.T) {
commandCalled := false commandCalled := false