diff --git a/AGENTS.md b/AGENTS.md index 6c3eb99..ae5a585 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -123,9 +123,12 @@ Prefer the repository’s documented commands. If multiple choices exist, use th ## Commit message format - When the user asks for a commit message, the agent must produce it in this format: - 1. a short summary line; - 2. up to three additional lines with only the most important changes; - 3. each additional line must start on its own new line. + 1. one to four short lines; + 2. each line must use the format `(): `; + 3. `` must be a short change type such as `fix`, `new`, `tests`, `doc`, `refactor`, or `ci/cd`; + 4. `` 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. - Do not collapse the lines into a paragraph, bullet list, or wrapped prose explanation. - Keep commit text concise and high-signal. diff --git a/CHANGELOG.md b/CHANGELOG.md index 35f3cd5..5a735ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,12 @@ ### 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. +- 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 - 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 diff --git a/bot.go b/bot.go index f92f8d9..34b67cc 100644 --- a/bot.go +++ b/bot.go @@ -209,6 +209,16 @@ func NewBot[T any](opts *BotOpts) (*Bot[T], error) { } 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 u, err := api.GetMe() if err != nil { diff --git a/bot_opts.go b/bot_opts.go index 83e7413..36bfaab 100644 --- a/bot_opts.go +++ b/bot_opts.go @@ -62,6 +62,12 @@ type BotOpts struct { // MaxWorkers is the maximum number of update handlers that may run concurrently. 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. @@ -125,7 +131,8 @@ func LoadOptsFromEnv() *BotOpts { DropRLOverflow: os.Getenv("DROP_RL_OVERFLOW") == "true", StrictPayloadType: os.Getenv("STRICT_PAYLOAD_TYPE") == "true", - MaxWorkers: maxWorkers, + MaxWorkers: maxWorkers, + FileConfigVersion: ConfigVersion, } } diff --git a/bot_opts_loader.go b/bot_opts_loader.go index 7cee524..65e9856 100644 --- a/bot_opts_loader.go +++ b/bot_opts_loader.go @@ -2,6 +2,7 @@ package laniakea import ( "encoding/json" + "fmt" "io" "os" "regexp" @@ -9,8 +10,16 @@ import ( "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. type BotOptsFileJson struct { + Version int `json:"version"` Token string `json:"token"` UpdateTypes []tgapi.UpdateType `json:"update_types"` Debug bool `json:"debug"` @@ -41,6 +50,9 @@ func (codec BotOptsFileJsonCodec) FromBytes(data []byte) (*BotOpts, error) { if err != nil { return nil, err } + if fileOpts.Version > ConfigVersion { + return nil, ErrConfigVersionMismatch + } opts := &BotOpts{ Token: fileOpts.Token, UpdateTypes: fileOpts.UpdateTypes, @@ -59,6 +71,8 @@ func (codec BotOptsFileJsonCodec) FromBytes(data []byte) (*BotOpts, error) { StrictPayloadType: fileOpts.StrictPayloadType, MaxWorkers: fileOpts.MaxWorkers, + + FileConfigVersion: fileOpts.Version, } return opts, nil } @@ -66,6 +80,7 @@ func (codec BotOptsFileJsonCodec) FromBytes(data []byte) (*BotOpts, error) { // ToBytes encodes BotOpts into JSON file bytes. func (codec BotOptsFileJsonCodec) ToBytes(opts *BotOpts) ([]byte, error) { fileOpts := &BotOptsFileJson{ + Version: ConfigVersion, Token: opts.Token, UpdateTypes: opts.UpdateTypes, Debug: opts.Debug, diff --git a/bot_opts_loader_test.go b/bot_opts_loader_test.go index fc76b7b..09922ce 100644 --- a/bot_opts_loader_test.go +++ b/bot_opts_loader_test.go @@ -1,6 +1,7 @@ package laniakea import ( + "errors" "os" "path/filepath" "reflect" @@ -26,6 +27,7 @@ func TestBotOptsFileJsonCodecRoundTrip(t *testing.T) { DropRLOverflow: true, StrictPayloadType: true, MaxWorkers: 64, + FileConfigVersion: ConfigVersion, } data, err := codec.ToBytes(want) @@ -74,6 +76,9 @@ func TestLoadBotOptsFileExpandsEnvPlaceholders(t *testing.T) { if got.ErrorTemplate != "Error: %s" { 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) { @@ -92,13 +97,14 @@ func TestSaveBotOptsFileWritesEncodedData(t *testing.T) { dir := t.TempDir() filename := filepath.Join(dir, "config.json") want := &BotOpts{ - Token: "TOKEN", - UpdateTypes: []tgapi.UpdateType{tgapi.UpdateTypeMessage}, - ErrorTemplate: "Error: %s", - Prefixes: []string{"/"}, - APIUrl: "https://api.example.invalid", - RateLimit: 30, - MaxWorkers: 32, + Token: "TOKEN", + UpdateTypes: []tgapi.UpdateType{tgapi.UpdateTypeMessage}, + ErrorTemplate: "Error: %s", + Prefixes: []string{"/"}, + APIUrl: "https://api.example.invalid", + RateLimit: 30, + MaxWorkers: 32, + FileConfigVersion: ConfigVersion, } 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) } } + +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) + } +} diff --git a/bot_utils.go b/bot_utils.go index 843b6cf..29fc3b2 100644 --- a/bot_utils.go +++ b/bot_utils.go @@ -204,13 +204,11 @@ func cloneScene[T AppData](scene *Scene[T]) *Scene[T] { cloned := *scene cloned.steps = make(map[string]SceneHandler[T], len(scene.steps)) 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 { - cloned.steps[name] = handler - } - for name, handler := range scene.commands { - cloned.commands[name] = handler - } + maps.Copy(cloned.steps, scene.steps) + maps.Copy(cloned.commands, scene.commands) + maps.Copy(cloned.payloads, scene.payloads) return &cloned } diff --git a/observer.go b/observer.go index d454acc..205ccfb 100644 --- a/observer.go +++ b/observer.go @@ -30,6 +30,8 @@ const ( HandlerSceneStepKind HandlerEventKind = "scene_step" // HandlerSceneCommandKind identifies a scene-local command handler. HandlerSceneCommandKind HandlerEventKind = "scene_command" + // HandlerScenePayloadKind identifies a scene-local callback payload handler. + HandlerScenePayloadKind HandlerEventKind = "scene_payload" // HandlerSceneMessageKind identifies a scene message fallback handler. HandlerSceneMessageKind HandlerEventKind = "scene_message" ) diff --git a/scene.go b/scene.go index 8e4132d..c3ec067 100644 --- a/scene.go +++ b/scene.go @@ -21,6 +21,7 @@ type Scene[T any] struct { steps map[string]SceneHandler[T] commands map[string]SceneHandler[T] + payloads map[string]SceneHandler[T] message SceneHandler[T] } @@ -32,6 +33,7 @@ func NewScene[T any](name string) *Scene[T] { Entry: "", steps: make(map[string]SceneHandler[T]), commands: make(map[string]SceneHandler[T]), + payloads: make(map[string]SceneHandler[T]), message: nil, } } @@ -65,6 +67,12 @@ func (s *Scene[T]) OnCommand(cmd string, handler SceneHandler[T]) *Scene[T] { 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. func (s *Scene[T]) OnMessage(handler SceneHandler[T]) *Scene[T] { s.message = handler @@ -79,6 +87,14 @@ func (s *Scene[T]) executeCommand(cmd string, ctx *SceneContext, db T) (SceneRes result, err := handler(ctx, db) 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) { handler, ok := s.steps[step] if !ok { diff --git a/scene_handler.go b/scene_handler.go index a70b5e5..161398d 100644 --- a/scene_handler.go +++ b/scene_handler.go @@ -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. 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.Args = nil ctx.Prefix = "" diff --git a/scene_test.go b/scene_test.go index 2fd57e5..93d5f04 100644 --- a/scene_test.go +++ b/scene_test.go @@ -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) { commandCalled := false