From a4d70e1510d1ab6ac8c88aefa729defafbf9cde9 Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Sat, 28 Mar 2026 12:58:05 +0000 Subject: [PATCH] Stabilize scene session skeleton Hide internal scene runtime methods Refresh docs, tests, and changelog for scenes --- CHANGELOG.md | 10 +-- TODO.md | 2 +- bot.go | 31 ++++++++- bot_scene.go | 15 ++-- errors.go | 17 +++-- msg_context.go | 31 ++++++--- plugins.go | 7 ++ scene.go | 59 ++++++++++++---- scene_context.go | 18 +++++ scene_handler.go | 15 +++- scene_test.go | 174 +++++++++++++++++++++++++++++++++++++++++++++++ 11 files changed, 332 insertions(+), 47 deletions(-) create mode 100644 scene_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index ae55230..f639188 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,23 +8,15 @@ - `Bot.GetPayloadType()`, `InlineKeyboard.GetPayloadType()`, and optional strict payload decoding via `BotOpts.StrictPayloadType` / `Bot.SetStrictPayloadType(...)`. - `MsgContext.BindArgs(...)` for binding positional command arguments into exported struct fields. - Binding sentinels `ErrBindArgsTargetNotPointer`, `ErrBindArgsTargetNotStruct`, `ErrBindArgsUnsupportedFieldType`, and `ErrBindArgsConversion`. +- Work-in-progress scene/session support, including plugin scene registration, scoped scene sessions, scene entry/exit APIs on `MsgContext`, default in-memory session storage, scene-local routing before normal command handling, and state helpers on `SceneContext`. ### Changed - `CommandExecutor` now returns `error`, and command, payload, and non-command update handlers now use centralized bot error handling for returned errors. - README and README_RU examples now use the new handler signature and document the long-message helpers. - README and README_RU now link to the project wiki, and the wiki now includes a page-priority tracker while content is being filled in. -- `AGENTS.md` now requires every change to be recorded in `CHANGELOG.md`, enforces version alignment with `utils/version.go`, and blocks breaking changes without a major-version bump. -- `AGENTS.md` now also defines a short commit-message format: one summary line plus up to three high-signal detail lines. -- `AGENTS.md` now explicitly requires each commit-message detail line to be placed on its own new line. -- `AGENTS.md` now also requires commit messages to be emitted as a plain multiline block instead of collapsed prose or list formatting. -- `AGENTS.md` now requires new or expanded project documentation to be maintained in both English and Russian whenever reasonably possible. -- `AGENTS.md` now requires all agent-created commits to be GPG-signed and to fail fast instead of falling back to unsigned commits when signing cannot be completed. -- `AGENTS.md` now also links the wiki backlog flow more tightly to `TODO.md` and `CHANGELOG.md`, requiring draft-wiki confirmation for large new ideas and synchronized completion records for backlog items. -- Added `TODO.md` to track missing framework-level concepts, with detailed notes for scenes, typed handler input, and request-scoped cancellation. - Payload-type comments and docs now distinguish between the bot's default payload type and keyboard-local overrides. - `MsgContext.Context()` now safely falls back to `context.Background()` when no request-scoped context is attached. - `MsgContext` reply, edit, callback, delete, action, and draft-limiter paths now use the context accessor instead of reaching into raw internal state. -- `TODO.md` is now a short pointer file, while the detailed framework backlog lives in the wiki as `Framework-Backlog` / `Framework-Backlog-RU`. - Version constants were bumped to `v1.0.0-rc.12`. ### Fixed diff --git a/TODO.md b/TODO.md index 1038fc4..ba2122f 100644 --- a/TODO.md +++ b/TODO.md @@ -12,6 +12,6 @@ Russian page: Current high-priority status: -- `1. Conversation / Scene Model`: not implemented yet. +- `1. Conversation / Scene Model`: work in progress. - `2. Typed Handler Input Model`: completed in `v1.0.0-rc.12`. - `3. Request Context / Cancellation Model`: completed in `v1.0.0-rc.12`. diff --git a/bot.go b/bot.go index 00753cc..305d0ed 100644 --- a/bot.go +++ b/bot.go @@ -342,18 +342,24 @@ func (bot *Bot[T]) SetDraftProvider(p *DraftProvider) *Bot[T] { bot.draftProvider = p return bot } + +// GetDraftProvider returns the draft provider currently used by the bot. func (bot *Bot[T]) GetDraftProvider() *DraftProvider { return bot.draftProvider } -func (bot *Bot[T]) SetSettionStore(store SessionStore) *Bot[T] { +// SetSessionStore replaces the session store used for scene management. +func (bot *Bot[T]) SetSessionStore(store SessionStore) *Bot[T] { bot.sessionStore = store return bot } + +// GetSessionStore returns the session store used for scene management. func (bot *Bot[T]) GetSessionStore() SessionStore { return bot.sessionStore } +// SetSceneScopePriority sets the lookup order for resolving active scene sessions. func (bot *Bot[T]) SetSceneScopePriority(priority []SceneScope) *Bot[T] { newPriority := make([]SceneScope, 0, 3) for _, scope := range priority { @@ -782,6 +788,7 @@ func clonePlugin[T DbContext](p *Plugin[T]) Plugin[T] { name: p.name, commands: make(map[string]*Command[T], len(p.commands)), payloads: make(map[string]*Command[T], len(p.payloads)), + scenes: make(map[string]*Scene[T], len(p.scenes)), middlewares: append(extypes.Slice[Middleware[T]](nil), p.middlewares...), skipAutoCmd: p.skipAutoCmd, logger: p.logger, @@ -795,6 +802,9 @@ func clonePlugin[T DbContext](p *Plugin[T]) Plugin[T] { for name, command := range p.payloads { cloned.payloads[name] = cloneCommand(command) } + for name, scene := range p.scenes { + cloned.scenes[name] = cloneScene(scene) + } maps.Copy(cloned.handlers, p.handlers) return cloned @@ -810,3 +820,22 @@ func cloneCommand[T DbContext](command *Command[T]) *Command[T] { cloned.middlewares = append(extypes.Slice[Middleware[T]](nil), command.middlewares...) return &cloned } + +func cloneScene[T DbContext](scene *Scene[T]) *Scene[T] { + if scene == nil { + return nil + } + + cloned := *scene + cloned.steps = make(map[string]SceneHandler[T], len(scene.steps)) + cloned.commands = make(map[string]SceneHandler[T], len(scene.commands)) + + for name, handler := range scene.steps { + cloned.steps[name] = handler + } + for name, handler := range scene.commands { + cloned.commands[name] = handler + } + + return &cloned +} diff --git a/bot_scene.go b/bot_scene.go index d8136eb..e5c97ed 100644 --- a/bot_scene.go +++ b/bot_scene.go @@ -1,17 +1,17 @@ package laniakea -func (bot *Bot[T]) GetSession(key string) (SceneSession, error) { +func (bot *Bot[T]) getSession(key string) (SceneSession, error) { return bot.sessionStore.Get(key) } -func (bot *Bot[T]) SetSession(key string, session SceneSession) error { +func (bot *Bot[T]) setSession(key string, session SceneSession) error { return bot.sessionStore.Set(key, session) } -func (bot *Bot[T]) DeleteSession(key string) error { +func (bot *Bot[T]) deleteSession(key string) error { return bot.sessionStore.Delete(key) } -func (bot *Bot[T]) FindScene(name string) (*sceneMeta, bool) { +func (bot *Bot[T]) findScene(name string) (*sceneMeta, bool) { for _, plugin := range bot.plugins { scene, ok := plugin.scenes[name] if !ok { @@ -33,9 +33,9 @@ func (bot *Bot[T]) FindScene(name string) (*sceneMeta, bool) { return nil, false } -func (bot *Bot[T]) FindSceneSession(ctx *MsgContext) (string, SceneSession, error) { +func (bot *Bot[T]) findSceneSession(ctx *MsgContext) (string, SceneSession, error) { var zero SceneSession - if ctx.Msg == nil { + if ctx.Msg == nil && ctx.FromID == 0 { return "", zero, ErrMessageNil } @@ -56,6 +56,7 @@ func (bot *Bot[T]) FindSceneSession(ctx *MsgContext) (string, SceneSession, erro return "", zero, ErrCantFindSession } -func (bot *Bot[T]) BuildSceneKey(scope SceneScope, ctx *MsgContext) (string, bool) { + +func (bot *Bot[T]) buildSceneKey(scope SceneScope, ctx *MsgContext) (string, bool) { return buildSceneKey(scope, ctx) } diff --git a/errors.go b/errors.go index 40c4cbb..0ccfde9 100644 --- a/errors.go +++ b/errors.go @@ -44,12 +44,17 @@ var ( // ErrBindArgsUnsupportedFieldType reports that BindArgs encountered an unsupported field kind. ErrBindArgsUnsupportedFieldType = errors.New("bind args: unsupported field type") // ErrBindArgsConversion reports that BindArgs could not convert a string argument into a field type. - ErrBindArgsConversion = errors.New("bind args: conversion failed") - ErrCantFindSession = errors.New("can't find session for this context") - ErrSceneNotFound = errors.New("scene not found") - ErrSceneStepNotFound = errors.New("scene step not found") - ErrSceneCommandNotFound = errors.New("scene command not found") - ErrNotInScene = errors.New("not in scene") + ErrBindArgsConversion = errors.New("bind args: conversion failed") + // ErrCantFindSession reports that no scene session matches the current context. + ErrCantFindSession = errors.New("can't find session for this context") + // ErrSceneNotFound reports that the requested scene is not registered. + ErrSceneNotFound = errors.New("scene not found") + // ErrSceneStepNotFound reports that the requested scene step is not registered. + ErrSceneStepNotFound = errors.New("scene step not found") + // ErrNotInScene reports that the current context has no active scene session. + ErrNotInScene = errors.New("not in scene") + // ErrSceneEntryNotSet reports that a scene has no configured entry step. + ErrSceneEntryNotSet = errors.New("scene entry step not set") ) func validateMessageText(text string) error { diff --git a/msg_context.go b/msg_context.go index 26f460b..a334439 100644 --- a/msg_context.go +++ b/msg_context.go @@ -644,26 +644,35 @@ func (ctx *MsgContext) Context() context.Context { return ctx.ctx } +// EnterScene enters the named scene at its configured entry step. func (ctx *MsgContext) EnterScene(name string) error { - scene, ok := ctx.sceneRuntime.FindScene(name) + scene, ok := ctx.sceneRuntime.findScene(name) if !ok { return ErrSceneNotFound } - key, ok := ctx.sceneRuntime.BuildSceneKey(scene.Scope, ctx) + key, ok := ctx.sceneRuntime.buildSceneKey(scene.Scope, ctx) if !ok { return ErrCantFindSession } + if scene.Entry == "" { + return ErrSceneEntryNotSet + } + if _, ok := scene.Steps[scene.Entry]; !ok { + return ErrSceneStepNotFound + } session := SceneSession{ Scene: scene.Name, Step: scene.Entry, } - return ctx.sceneRuntime.SetSession(key, session) + return ctx.sceneRuntime.setSession(key, session) } + +// EnterSceneStep enters the named scene at a specific step. func (ctx *MsgContext) EnterSceneStep(name, step string) error { - scene, ok := ctx.sceneRuntime.FindScene(name) + scene, ok := ctx.sceneRuntime.findScene(name) if !ok { return ErrSceneNotFound } @@ -671,7 +680,7 @@ func (ctx *MsgContext) EnterSceneStep(name, step string) error { return ErrSceneStepNotFound } - key, ok := ctx.sceneRuntime.BuildSceneKey(scene.Scope, ctx) + key, ok := ctx.sceneRuntime.buildSceneKey(scene.Scope, ctx) if !ok { return ErrCantFindSession } @@ -681,10 +690,12 @@ func (ctx *MsgContext) EnterSceneStep(name, step string) error { Step: step, } - return ctx.sceneRuntime.SetSession(key, session) + return ctx.sceneRuntime.setSession(key, session) } + +// ExitScene leaves the currently active scene for this context. func (ctx *MsgContext) ExitScene() error { - _, session, err := ctx.sceneRuntime.FindSceneSession(ctx) + _, session, err := ctx.sceneRuntime.findSceneSession(ctx) if err != nil { return err } @@ -692,15 +703,15 @@ func (ctx *MsgContext) ExitScene() error { return ErrNotInScene } - scene, ok := ctx.sceneRuntime.FindScene(session.Scene) + scene, ok := ctx.sceneRuntime.findScene(session.Scene) if !ok { return ErrSceneNotFound } - key, ok := ctx.sceneRuntime.BuildSceneKey(scene.Scope, ctx) + key, ok := ctx.sceneRuntime.buildSceneKey(scene.Scope, ctx) if !ok { return ErrCantFindSession } - return ctx.sceneRuntime.DeleteSession(key) + return ctx.sceneRuntime.deleteSession(key) } diff --git a/plugins.go b/plugins.go index a0c6834..3229a22 100644 --- a/plugins.go +++ b/plugins.go @@ -215,11 +215,18 @@ func (p *Plugin[T]) NewPayload(exec CommandExecutor[T], command string, args ... return cmd } +// AddScene registers a multi-step scene in the plugin. func (p *Plugin[T]) AddScene(scene *Scene[T]) *Plugin[T] { + if scene == nil { + return p + } scene.PluginName = p.name scene.setPluginName(p.name) + p.scenes[scene.Name] = scene return p } + +// NewScene creates, registers, and returns a new scene owned by the plugin. func (p *Plugin[T]) NewScene(name string) *Scene[T] { scene := NewScene[T](name) scene.setPluginName(p.name) diff --git a/scene.go b/scene.go index c9c545a..7b6263c 100644 --- a/scene.go +++ b/scene.go @@ -5,7 +5,10 @@ import ( "sync" ) +// SceneHandler handles a scene step, scene command, or fallback message. type SceneHandler[T any] func(ctx *SceneContext, db T) (SceneResult, error) + +// Scene defines a multi-step conversational flow. type Scene[T any] struct { Name string Scope SceneScope @@ -17,6 +20,7 @@ type Scene[T any] struct { message SceneHandler[T] } +// NewScene creates a new scene with user-chat scope by default. func NewScene[T any](name string) *Scene[T] { return &Scene[T]{ Name: name, @@ -27,10 +31,14 @@ func NewScene[T any](name string) *Scene[T] { message: nil, } } + +// SetScope changes how scene sessions are keyed and shared. func (s *Scene[T]) SetScope(scope SceneScope) *Scene[T] { s.Scope = scope return s } + +// SetEntry sets the initial step entered by MsgContext.EnterScene. func (s *Scene[T]) SetEntry(step string) *Scene[T] { s.Entry = step return s @@ -40,14 +48,19 @@ func (s *Scene[T]) setPluginName(name string) *Scene[T] { return s } +// OnStep registers a handler for a named scene step. func (s *Scene[T]) OnStep(step string, handler SceneHandler[T]) *Scene[T] { s.steps[step] = handler return s } + +// OnCommand registers a command handler active while the scene is running. func (s *Scene[T]) OnCommand(cmd string, handler SceneHandler[T]) *Scene[T] { s.commands[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 return s @@ -77,39 +90,42 @@ func (s *Scene[T]) executeMessage(ctx *SceneContext, db T) (SceneResult, bool, e return result, true, err } +// SceneSession stores the active scene state for one session key. type SceneSession struct { Scene string Step string Data []byte } -// SetData sets the session data. It is thread-safe and can be used to store any arbitrary data as a byte slice. +// SetData stores arbitrary opaque session data. func (s *SceneSession) SetData(data []byte) { s.Data = data } -// GetData retrieves the session data. It is thread-safe and returns the data as a byte slice. +// GetData returns the raw session data payload. func (s *SceneSession) GetData() []byte { return s.Data } + +// HasData reports whether the session has a non-empty data payload. func (s *SceneSession) HasData() bool { return len(s.Data) > 0 } -// ClearData clears the session data. It is thread-safe and sets the data to nil. +// ClearData removes any stored session data. func (s *SceneSession) ClearData() { s.Data = nil } -// BindData binds the session data to the provided struct. +// BindData unmarshals the stored JSON payload into v. func (s *SceneSession) BindData(v any) error { if len(s.Data) == 0 { - return nil // No data to bind, return nil error + return nil } return json.Unmarshal(s.Data, v) } -// SaveData saves the provided struct as JSON in the session data. +// SaveData marshals v as JSON and stores it in the session. func (s *SceneSession) SaveData(v any) error { data, err := json.Marshal(v) if err != nil { @@ -119,22 +135,27 @@ func (s *SceneSession) SaveData(v any) error { return nil } +// SessionStore persists scene sessions by key. type SessionStore interface { Get(key string) (SceneSession, error) Set(key string, session SceneSession) error Delete(key string) error } +// MemorySessionStore stores scene sessions in memory. type MemorySessionStore struct { store map[string]SceneSession mu sync.RWMutex } +// NewMemorySessionStore creates an empty in-memory session store. func NewMemorySessionStore() *MemorySessionStore { return &MemorySessionStore{ store: make(map[string]SceneSession), } } + +// Get returns the session stored under key, or the zero session when absent. func (s *MemorySessionStore) Get(key string) (SceneSession, error) { s.mu.RLock() defer s.mu.RUnlock() @@ -143,12 +164,16 @@ func (s *MemorySessionStore) Get(key string) (SceneSession, error) { } return SceneSession{}, nil } + +// Set stores session under key. func (s *MemorySessionStore) Set(key string, session SceneSession) error { s.mu.Lock() s.store[key] = session s.mu.Unlock() return nil } + +// Delete removes the session stored under key. func (s *MemorySessionStore) Delete(key string) error { s.mu.Lock() delete(s.store, key) @@ -156,35 +181,45 @@ func (s *MemorySessionStore) Delete(key string) error { return nil } +// SceneResult describes how scene execution should proceed after a handler returns. type SceneResult struct { Action SceneAction Next string } +// SceneAction controls how the bot updates scene state after a handler returns. type SceneAction int const ( + // SceneActionStay keeps the current scene and step active. SceneActionStay SceneAction = iota + // SceneActionNext moves the session to another named step. SceneActionNext + // SceneActionExit removes the current scene session. SceneActionExit + // SceneActionPass lets normal bot routing continue after the scene handler. SceneActionPass ) +// SceneScope defines how scene sessions are keyed. type SceneScope int const ( + // SceneScopeUser shares a scene across all chats for one user. SceneScopeUser SceneScope = iota + // SceneScopeChat shares a scene across all users in one chat. SceneScopeChat + // SceneScopeUserChat isolates a scene per user-chat pair. SceneScopeUserChat ) type sceneRuntime interface { - FindScene(name string) (*sceneMeta, bool) - GetSession(key string) (SceneSession, error) - SetSession(key string, session SceneSession) error - DeleteSession(key string) error - BuildSceneKey(scope SceneScope, ctx *MsgContext) (string, bool) - FindSceneSession(ctx *MsgContext) (string, SceneSession, error) + findScene(name string) (*sceneMeta, bool) + getSession(key string) (SceneSession, error) + setSession(key string, session SceneSession) error + deleteSession(key string) error + buildSceneKey(scope SceneScope, ctx *MsgContext) (string, bool) + findSceneSession(ctx *MsgContext) (string, SceneSession, error) } type sceneMeta struct { diff --git a/scene_context.go b/scene_context.go index 048a14c..a3ef20b 100644 --- a/scene_context.go +++ b/scene_context.go @@ -1,23 +1,41 @@ package laniakea +// SceneContext wraps MsgContext with scene session state for scene handlers. type SceneContext struct { *MsgContext sess SceneSession key string } +// Next advances the current scene to step. func (ctx *SceneContext) Next(step string) SceneResult { return SceneResult{ Action: SceneActionNext, Next: step, } } + +// Stay keeps the current scene step active. func (ctx *SceneContext) Stay() SceneResult { return SceneResult{Action: SceneActionStay} } + +// Exit leaves the current scene. func (ctx *SceneContext) Exit() SceneResult { return SceneResult{Action: SceneActionExit} } + +// Pass stops scene handling and lets normal routing continue. func (ctx *SceneContext) Pass() SceneResult { return SceneResult{Action: SceneActionPass} } + +// BindData unmarshals the current scene session payload into v. +func (ctx *SceneContext) BindData(v any) error { + return ctx.sess.BindData(v) +} + +// SaveData marshals v and stores it in the current scene session payload. +func (ctx *SceneContext) SaveData(v any) error { + return ctx.sess.SaveData(v) +} diff --git a/scene_handler.go b/scene_handler.go index 8fd5f95..de640a7 100644 --- a/scene_handler.go +++ b/scene_handler.go @@ -7,7 +7,7 @@ import ( ) func (bot *Bot[T]) tryHandleScene(ctx *MsgContext) (bool, error) { - key, session, err := bot.FindSceneSession(ctx) + key, session, err := bot.findSceneSession(ctx) if err != nil { if errors.Is(err, ErrCantFindSession) || errors.Is(err, ErrMessageNil) { return false, nil @@ -117,12 +117,25 @@ func (bot *Bot[T]) applySceneResult(scene *Scene[T], ctx *SceneContext, result S } } func buildSceneKey(scope SceneScope, ctx *MsgContext) (string, bool) { + if ctx == nil { + return "", false + } + switch scope { case SceneScopeUserChat: + if ctx.Msg == nil || ctx.Msg.Chat == nil || ctx.FromID == 0 { + return "", false + } return fmt.Sprintf("user_id:%d:chat_id:%d", ctx.FromID, ctx.Msg.Chat.ID), true case SceneScopeChat: + if ctx.Msg == nil || ctx.Msg.Chat == nil { + return "", false + } return fmt.Sprintf("chat_id:%d", ctx.Msg.Chat.ID), true case SceneScopeUser: + if ctx.FromID == 0 { + return "", false + } return fmt.Sprintf("user_id:%d", ctx.FromID), true default: return "", false diff --git a/scene_test.go b/scene_test.go new file mode 100644 index 0000000..75bcfb6 --- /dev/null +++ b/scene_test.go @@ -0,0 +1,174 @@ +package laniakea + +import ( + "context" + "errors" + "testing" + + "git.nix13.pw/scuroneko/laniakea/tgapi" + "git.nix13.pw/scuroneko/slog" +) + +func TestPluginAddSceneRegistersScene(t *testing.T) { + plugin := NewPlugin[NoDB]("wizard") + scene := NewScene[NoDB]("signup") + + plugin.AddScene(scene) + + if got, ok := plugin.scenes["signup"]; !ok || got != scene { + t.Fatalf("scene was not registered in plugin: ok=%v got=%p want=%p", ok, got, scene) + } + if scene.PluginName != "wizard" { + t.Fatalf("unexpected plugin name on scene: got %q want %q", scene.PluginName, "wizard") + } +} + +func TestBotAddPluginsPreservesScenesAndHandlesThem(t *testing.T) { + called := false + + plugin := NewPlugin[NoDB]("wizard") + plugin.NewScene("signup"). + SetEntry("start"). + OnStep("start", func(ctx *SceneContext, db NoDB) (SceneResult, error) { + called = true + if ctx.Text != "hello there" { + t.Fatalf("unexpected scene text: got %q want %q", ctx.Text, "hello there") + } + return ctx.Exit(), nil + }) + + bot := &Bot[NoDB]{ + logger: slog.CreateLogger(), + prefixes: []string{"/"}, + sessionStore: NewMemorySessionStore(), + sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser}, + } + bot.AddPlugins(plugin) + + sceneMeta, ok := bot.findScene("signup") + if !ok { + t.Fatal("expected scene metadata to be available after plugin registration") + } + if sceneMeta.Entry != "start" { + t.Fatalf("unexpected scene entry: got %q want %q", sceneMeta.Entry, "start") + } + + enterCtx := &MsgContext{ + Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: string(tgapi.ChatTypePrivate)}}, + FromID: 42, + sceneRuntime: bot, + } + if err := enterCtx.EnterScene("signup"); err != nil { + t.Fatalf("EnterScene returned error: %v", err) + } + + bot.handle(context.Background(), &tgapi.Update{ + UpdateID: 1, + Type: tgapi.UpdateTypeMessage, + Message: &tgapi.Message{ + MessageID: 7, + Text: "hello there", + Chat: &tgapi.Chat{ID: 100, Type: string(tgapi.ChatTypePrivate)}, + From: &tgapi.User{ID: 42}, + }, + }) + + if !called { + t.Fatal("expected scene step handler to be called") + } + + lookupCtx := &MsgContext{ + Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: string(tgapi.ChatTypePrivate)}}, + FromID: 42, + } + if _, session, err := bot.findSceneSession(lookupCtx); err == nil && session.Scene != "" { + t.Fatalf("expected scene session to be removed after exit, got %#v", session) + } +} + +func TestBuildSceneKeyRejectsMissingContextFields(t *testing.T) { + tests := []struct { + name string + scope SceneScope + ctx *MsgContext + }{ + { + name: "nil context", + scope: SceneScopeUserChat, + ctx: nil, + }, + { + name: "missing message for chat scope", + scope: SceneScopeChat, + ctx: &MsgContext{}, + }, + { + name: "missing from id for user scope", + scope: SceneScopeUser, + ctx: &MsgContext{}, + }, + { + name: "missing from id for user chat scope", + scope: SceneScopeUserChat, + ctx: &MsgContext{ + Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: string(tgapi.ChatTypePrivate)}}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if key, ok := buildSceneKey(tt.scope, tt.ctx); ok || key != "" { + t.Fatalf("expected invalid scene key, got key=%q ok=%v", key, ok) + } + }) + } +} + +func TestEnterSceneRejectsMissingEntryConfiguration(t *testing.T) { + t.Run("empty entry", func(t *testing.T) { + plugin := NewPlugin[NoDB]("wizard") + plugin.NewScene("signup") + + bot := &Bot[NoDB]{ + logger: slog.CreateLogger(), + sessionStore: NewMemorySessionStore(), + sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser}, + } + bot.AddPlugins(plugin) + + ctx := &MsgContext{ + Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: string(tgapi.ChatTypePrivate)}}, + FromID: 42, + sceneRuntime: bot, + } + + err := ctx.EnterScene("signup") + if !errors.Is(err, ErrSceneEntryNotSet) { + t.Fatalf("expected ErrSceneEntryNotSet, got %v", err) + } + }) + + t.Run("missing entry step", func(t *testing.T) { + plugin := NewPlugin[NoDB]("wizard") + plugin.NewScene("signup").SetEntry("start") + + bot := &Bot[NoDB]{ + logger: slog.CreateLogger(), + sessionStore: NewMemorySessionStore(), + sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser}, + } + bot.AddPlugins(plugin) + + ctx := &MsgContext{ + Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: string(tgapi.ChatTypePrivate)}}, + FromID: 42, + sceneRuntime: bot, + } + + err := ctx.EnterScene("signup") + if !errors.Is(err, ErrSceneStepNotFound) { + t.Fatalf("expected ErrSceneStepNotFound, got %v", err) + } + }) +}