REPOSITORY / ScuroNeko/Laniakea
Pull Requests
v1.0.0 #9
@@ -123,9 +123,12 @@ Prefer the repository’s 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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
+8
-1
@@ -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.
|
||||||
@@ -125,7 +131,8 @@ func LoadOptsFromEnv() *BotOpts {
|
|||||||
DropRLOverflow: os.Getenv("DROP_RL_OVERFLOW") == "true",
|
DropRLOverflow: os.Getenv("DROP_RL_OVERFLOW") == "true",
|
||||||
StrictPayloadType: os.Getenv("STRICT_PAYLOAD_TYPE") == "true",
|
StrictPayloadType: os.Getenv("STRICT_PAYLOAD_TYPE") == "true",
|
||||||
|
|
||||||
MaxWorkers: maxWorkers,
|
MaxWorkers: maxWorkers,
|
||||||
|
FileConfigVersion: ConfigVersion,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
+30
-7
@@ -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) {
|
||||||
@@ -92,13 +97,14 @@ func TestSaveBotOptsFileWritesEncodedData(t *testing.T) {
|
|||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
filename := filepath.Join(dir, "config.json")
|
filename := filepath.Join(dir, "config.json")
|
||||||
want := &BotOpts{
|
want := &BotOpts{
|
||||||
Token: "TOKEN",
|
Token: "TOKEN",
|
||||||
UpdateTypes: []tgapi.UpdateType{tgapi.UpdateTypeMessage},
|
UpdateTypes: []tgapi.UpdateType{tgapi.UpdateTypeMessage},
|
||||||
ErrorTemplate: "Error: %s",
|
ErrorTemplate: "Error: %s",
|
||||||
Prefixes: []string{"/"},
|
Prefixes: []string{"/"},
|
||||||
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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user