Stabilize scene session skeleton
Hide internal scene runtime methods Refresh docs, tests, and changelog for scenes
This commit is contained in:
+1
-9
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+8
-7
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+21
-10
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+14
-1
@@ -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
|
||||
|
||||
+174
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user