From 4f8d583b03ce3e633284c8c09f9074c8a63c8097 Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Fri, 27 Mar 2026 16:05:20 +0300 Subject: [PATCH] wip: scene sessions --- AGENTS.md | 11 +++ CHANGELOG.md | 12 +++ TODO.md | 137 ++----------------------------- bot.go | 42 +++++++++- bot_scene.go | 61 ++++++++++++++ errors.go | 14 ++++ handler.go | 71 +++++++++------- handler_test.go | 91 ++++++++++++++++++++- msg_context.go | 180 ++++++++++++++++++++++++++++++++++++++-- msg_context_test.go | 101 +++++++++++++++++++++++ plugins.go | 14 ++++ scene.go | 195 ++++++++++++++++++++++++++++++++++++++++++++ scene_context.go | 23 ++++++ scene_handler.go | 130 +++++++++++++++++++++++++++++ 14 files changed, 909 insertions(+), 173 deletions(-) create mode 100644 bot_scene.go create mode 100644 scene.go create mode 100644 scene_context.go create mode 100644 scene_handler.go diff --git a/AGENTS.md b/AGENTS.md index 8d591ee..ade1677 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,6 +28,12 @@ Review the codebase with focus on: - Keep English and Russian pages aligned in structure, major examples, and user-facing guidance. - If only one language can be updated safely in the current turn, explicitly say which language is lagging and why. +## Wiki and backlog workflow +- Treat the wiki as the primary place for large design ideas, architectural drafts, and framework backlog notes. +- If the agent identifies a substantial new concept or design direction, such as scenes, callback agents, a webhook model, or another framework-level abstraction, the agent must ask the user whether it should also formalize that idea as a draft wiki page. +- When the user agrees, prefer paired wiki pages such as `Page.md` and `Page-RU.md`, and clearly mark draft design pages with `DRAFT` when the API is not implemented or not yet stable. +- Keep `TODO.md`, the wiki backlog pages, and `CHANGELOG.md` aligned when framework-level items move between planned and completed states. + ## Go review expectations Check for: - bugs, fragile logic, invalid assumptions, nil handling issues, resource leaks; @@ -95,6 +101,11 @@ Prefer the repository’s documented commands. If multiple choices exist, use th - The agent must not guess the next version when that section is missing. - If the user-selected version does not match `utils/version.go`, the agent must warn about the mismatch and require the version file to be updated before proceeding. - Changelog entries must describe all user-visible behavior changes made in the turn, including API additions, fixes, behavior changes, and breaking changes. +- When a framework backlog item recorded in `TODO.md` is completed, the agent must also update the backlog status using the existing format: + 1. move the completed item into the top of the `Done` section; + 2. replace the numbered backlog label with a version tag, for example `1. Scene Model` becomes `[v2.0.0] Scene Model`; + 3. keep the item title and descriptive notes aligned with the corresponding `CHANGELOG.md` entry. +- The agent must treat `TODO.md` and `CHANGELOG.md` as linked records: a completed backlog item should not be left in one file as done and in the other as still pending or undocumented. ## Breaking changes policy - The agent must detect potential breaking changes before editing public APIs. diff --git a/CHANGELOG.md b/CHANGELOG.md index b371f70..ae55230 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ - `AnswerLong(...)`, `AnswerLongf(...)`, `KeyboardLong(...)`, and `SplitMessageText(...)` for explicit plain-text splitting of long replies without changing the semantics of existing single-message helpers. - Centralized library-level validation errors in `errors.go`, including `ErrEmptyMessage`, `ErrMessageTooLong`, `ErrCaptionTooLong`, and context/target validation sentinels. - `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`. ### Changed - `CommandExecutor` now returns `error`, and command, payload, and non-command update handlers now use centralized bot error handling for returned errors. @@ -17,19 +19,29 @@ - `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 - Message and caption validation now runs before Telegram API calls, rejecting empty messages, oversized message text, and oversized captions with stable sentinel errors. - Draft flushing and draft updates now reject oversized messages before sending invalid requests. - Callback payload decoding now optionally enforces strict type matching, while the default tolerant mode logs Base64-to-JSON decoding in debug mode and still accepts keyboard-local payload overrides. +- Positional argument binding now leaves missing trailing struct fields at zero values, joins the remaining arguments into the final string field, and returns clearer binding errors. +- Request-scoped contexts are now created per update handler execution and safely reused through `MsgContext.Context()` even for manually constructed test contexts. +- Command and payload handlers now have regression coverage for end-to-end typed argument binding through the normal routing path. ### Breaking Changes - `CommandExecutor[T]` changed from `func(ctx *MsgContext, db T)` to `func(ctx *MsgContext, db T) error`. - `Plugin.NewCommand(...)`, `Plugin.NewPayload(...)`, and `Plugin.AddUpdateHandler(...)` now require handlers with the new error-returning signature. +### Tests +- Added regression tests for `MsgContext.BindArgs(...)`, including scalar conversion, tail-string binding, zero-value trailing fields, invalid targets, unsupported field types, and end-to-end command/payload binding. + ## v1.0.0-rc.11 ### Fixed diff --git a/TODO.md b/TODO.md index 61cce1e..1038fc4 100644 --- a/TODO.md +++ b/TODO.md @@ -1,136 +1,17 @@ # TODO -This file tracks framework-level backlog items that are about missing concepts in the library itself, not just missing documentation. +The framework backlog has moved to the wiki. -## High-Priority Core Concepts +Primary page: -### 1. Conversation / Scene Model +- https://git.nix13.pw/ScuroNeko/Laniakea/wiki/Framework-Backlog -Current state: -- The framework is strong at handling a single update through commands, payloads, middleware, and update handlers. -- It already has useful lower-level building blocks such as `MsgContext`, drafts, payload routing, plugins, and update handlers. -- It does not yet provide a first-class concept for long-lived user interaction flows. +Russian page: -Why this matters: -- Many Telegram bots quickly move beyond isolated commands and need stateful multi-step flows. -- Real bots often need concepts like "wait for the user's next message", "user is currently on step 3 of 5", or "button press moves the user to the next scene state". -- Without a scene model, library users end up building their own mini-framework on top of Laniakea. +- https://git.nix13.pw/ScuroNeko/Laniakea/wiki/Framework-Backlog-RU -What is missing: -- A way to route updates to an active scene before normal command routing. -- A way to persist conversation state per user or per chat. -- A way to describe steps and transitions without hand-rolling state machines around middleware and storage. -- A way to enter, continue, cancel, and complete a conversation flow explicitly. -- A way to support modal chat flows where the user is "inside" a scene and ordinary text is treated as scene input until an explicit escape command exits the mode. +Current high-priority status: -Possible API direction: -- `Scene`, `Step`, and `SessionStore` concepts. -- `bot.AddScene(...)` or a dedicated scene registry. -- `ctx.Scene()`, `ctx.NextStep(...)`, `ctx.ExitScene()`, or similar state-transition helpers. -- Routing rule: active scene first, then normal command/payload flow if no scene claims the update. -- Storage-backed per-user or per-chat state with a clean interface for custom persistence. -- Scene-local escape and passthrough commands, so flows like `/startrp` can put a user into a dedicated chat mode where most messages go straight to the scene, while commands like `/exit` or a small whitelist still retain special meaning. - -Important design constraints: -- This should be additive and optional. -- It should not replace plugins, commands, or handlers as the normal framework entry points. -- It should work with existing middleware and `MsgContext` instead of introducing a second incompatible execution model. - -Practical target: -- Make stateful bot flows a first-class, framework-supported pattern instead of a userland convention. -- Cover both step-based forms and mode-based chat flows without forcing users to build custom routing layers around active sessions. - -### 2. Typed Handler Input Model - -Current state: -- Commands and payloads currently expose parsed text through `ctx.Text` and `ctx.Args`. -- `CommandArg` provides basic argument validation and shape checks. -- Handlers still do most non-trivial parsing manually. - -Why this matters: -- As bots grow, handlers often start with repetitive `ctx.Args` parsing boilerplate. -- Validation logic tends to spread across handlers instead of living in one predictable binding layer. -- The current model is simple and honest, but it does not help enough once commands become more structured. - -What is missing: -- A first-class way to bind command or payload arguments into a typed Go value. -- A framework-level pattern for conversion errors and validation errors beyond raw string handling. -- A low-friction way to move from positional arguments to a structured input object. - -Possible API direction: -- A lightweight binding API such as `ctx.BindArgs(&input)`. -- Or explicit typed command registration such as `NewCommandTyped(...)`. -- Positional mapping into structs, optional fields, basic conversion support, and integration with current validation flow. -- Unified binding and validation failures routed through the current centralized error path. - -Example of the kind of user code this should enable: - -```go -type BanInput struct { - UserID int - Reason string -} - -func ban(ctx *laniakea.MsgContext, db *App) error { - var input BanInput - if err := ctx.BindArgs(&input); err != nil { - return err - } - - return db.Ban(input.UserID, input.Reason) -} -``` - -Important design constraints: -- Avoid a reflection-heavy, magical subsystem. -- Keep the current `ctx.Args` model as the minimal baseline. -- Treat typed binding as an ergonomic layer on top of the current command model, not a replacement for it. - -Practical target: -- Remove repetitive parsing boilerplate while preserving the framework's explicit, Go-like feel. - -### 3. Request Context / Cancellation Model - -Current state: -- `RunWithContext(...)` controls bot runtime lifecycle and graceful shutdown. -- `tgapi` already supports context-aware methods. -- Regular handlers do not receive a first-class request-scoped `context.Context`. - -Why this matters: -- Handler business logic often needs cancellation-aware database calls, HTTP calls, or downstream service calls. -- The framework already has a good runtime cancellation story, but it does not flow naturally into user code inside handlers. -- In modern Go APIs, `context.Context` is a standard part of operational correctness. - -What is missing: -- A clean request-scoped context that follows each update through handler execution. -- A standard way for application code to stop work when the bot is shutting down or the update processing context is canceled. -- A direct bridge between bot lifecycle control and service-layer cancellation. - -Possible API direction: -- Prefer a non-breaking approach by exposing context through `MsgContext`, for example `ctx.Context()`. -- Build the context from the update-processing lifecycle so it is meaningful during graceful shutdown. -- Make it natural to pass that context into database methods, HTTP clients, and `tgapi.WithContext(...)` calls. - -Why this should probably not be a signature change: -- Changing handler signatures to accept `context.Context` directly would be a public breaking change. -- A `MsgContext` accessor would preserve compatibility while still giving handlers an idiomatic Go cancellation path. - -Practical target: -- Let handler code participate naturally in cancellation and graceful shutdown without forcing users to invent their own context plumbing. - -## Secondary Backlog - -- Webhook runtime model: the library has a solid polling model, but no first-class webhook execution model at the framework level. -- Service layer and dependency graph model: `DatabaseContext(T)` is intentionally minimal, but there is no stronger framework concept for application services or scoped dependencies. -- User-facing vs internal error model: the framework has a unified error flow, but it does not yet distinguish well between user-visible, internal-only, retryable, or silent errors. -- Authorization and policy model: middleware can implement auth and permissions, but there is no explicit framework concept for access policies, roles, or capability checks. -- Observability model: logging is strong, but metrics, tracing, and structured framework hooks are still missing as first-class concepts. -- Plugin composition contract: plugins are a good grouping unit, but there is no explicit model for plugin dependencies, shared capabilities, or composition contracts. -- Update schema contract: update handling exists, but there is no formal framework-level concept describing which `MsgContext` fields are guaranteed in which update kinds. -- Configuration freeze model: the framework already has real commit points like `AddPlugins(...)`, but this is still more of an implementation truth than an explicit top-level concept. - -## Suggested Priority - -1. Request context / cancellation model -2. Conversation / scene model -3. Typed handler input model +- `1. Conversation / Scene Model`: not implemented yet. +- `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 b76ad37..00753cc 100644 --- a/bot.go +++ b/bot.go @@ -4,7 +4,9 @@ import ( "context" "errors" "fmt" + "maps" "reflect" + "slices" "sort" "strings" "sync" @@ -102,6 +104,9 @@ type Bot[T DbContext] struct { l10n *L10n // Localization manager draftProvider *DraftProvider // Draft message builder + sessionStore SessionStore // Session store for scene management + sceneScopePriority []SceneScope + updateOffsetMu sync.Mutex updateOffset int // Last processed update ID updateTypes []tgapi.UpdateType // Types of updates to fetch @@ -174,6 +179,9 @@ func NewBot[T any](opts *BotOpts) (*Bot[T], error) { extraLoggers: make([]*slog.Logger, 0), l10n: &L10n{}, draftProvider: NewRandomDraftProvider(api), + + sessionStore: NewMemorySessionStore(), + sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser}, } // Add API and Uploader loggers to extraLoggers for unified output @@ -334,6 +342,34 @@ func (bot *Bot[T]) SetDraftProvider(p *DraftProvider) *Bot[T] { bot.draftProvider = p return bot } +func (bot *Bot[T]) GetDraftProvider() *DraftProvider { + return bot.draftProvider +} + +func (bot *Bot[T]) SetSettionStore(store SessionStore) *Bot[T] { + bot.sessionStore = store + return bot +} +func (bot *Bot[T]) GetSessionStore() SessionStore { + return bot.sessionStore +} + +func (bot *Bot[T]) SetSceneScopePriority(priority []SceneScope) *Bot[T] { + newPriority := make([]SceneScope, 0, 3) + for _, scope := range priority { + if slices.Index(newPriority, scope) >= 0 { + bot.logger.Warnln(fmt.Sprintf("duplicate scope %v in scene scope priority; ignoring duplicates", scope)) + continue + } + newPriority = append(newPriority, scope) + } + if len(newPriority) == 0 || len(newPriority) > 3 { + bot.logger.Warnln("scene scope priority must have 1 to 3 scopes; ignoring invalid input") + return bot + } + bot.sceneScopePriority = append([]SceneScope(nil), newPriority...) + return bot +} // DatabaseContext injects a database context into the bot. // This context is accessible to plugins and middleware via GetDBContext(). @@ -668,7 +704,7 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) error { for update := range bot.updateQueue { u := update // capture loop variable pool.Submit(func() { - bot.handle(u) + bot.handle(ctx, u) }) } pool.Stop() // Wait for all tasks to complete and stop the pool @@ -759,9 +795,7 @@ func clonePlugin[T DbContext](p *Plugin[T]) Plugin[T] { for name, command := range p.payloads { cloned.payloads[name] = cloneCommand(command) } - for t, handler := range p.handlers { - cloned.handlers[t] = handler - } + maps.Copy(cloned.handlers, p.handlers) return cloned } diff --git a/bot_scene.go b/bot_scene.go new file mode 100644 index 0000000..d8136eb --- /dev/null +++ b/bot_scene.go @@ -0,0 +1,61 @@ +package laniakea + +func (bot *Bot[T]) GetSession(key string) (SceneSession, error) { + return bot.sessionStore.Get(key) +} + +func (bot *Bot[T]) SetSession(key string, session SceneSession) error { + return bot.sessionStore.Set(key, session) +} + +func (bot *Bot[T]) DeleteSession(key string) error { + return bot.sessionStore.Delete(key) +} +func (bot *Bot[T]) FindScene(name string) (*sceneMeta, bool) { + for _, plugin := range bot.plugins { + scene, ok := plugin.scenes[name] + if !ok { + continue + } + + steps := make(map[string]struct{}, len(scene.steps)) + for step := range scene.steps { + steps[step] = struct{}{} + } + + return &sceneMeta{ + Name: scene.Name, + Scope: scene.Scope, + Entry: scene.Entry, + Steps: steps, + }, true + } + return nil, false +} + +func (bot *Bot[T]) FindSceneSession(ctx *MsgContext) (string, SceneSession, error) { + var zero SceneSession + if ctx.Msg == nil { + return "", zero, ErrMessageNil + } + + for _, scope := range bot.sceneScopePriority { + key, ok := buildSceneKey(scope, ctx) + if !ok { + continue + } + + session, err := bot.sessionStore.Get(key) + if err != nil { + return "", zero, err + } + if session.Scene != "" { + return key, session, nil + } + } + + return "", zero, ErrCantFindSession +} +func (bot *Bot[T]) BuildSceneKey(scope SceneScope, ctx *MsgContext) (string, bool) { + return buildSceneKey(scope, ctx) +} diff --git a/errors.go b/errors.go index 09d5425..40c4cbb 100644 --- a/errors.go +++ b/errors.go @@ -24,6 +24,7 @@ var ( ErrPayloadTypeMismatch = errors.New("payload type mismatch") // ErrDraftChatIDZero reports that a draft has no target chat ID. ErrDraftChatIDZero = errors.New("zero draft chat ID") + ErrMessageNil = errors.New("message is nil") // ErrMessageContextNil reports that an operation requires ctx.Msg but none is set. ErrMessageContextNil = errors.New("message context is nil") // ErrEditTargetMissing reports that an edit operation has no message target. @@ -36,6 +37,19 @@ var ( ErrAPIIsNil = errors.New("api is nil") // ErrMessageIDZero reports that an operation requires a non-zero message ID. ErrMessageIDZero = errors.New("message ID is zero") + // ErrBindArgsTargetNotPointer reports that BindArgs received a nil or non-pointer destination. + ErrBindArgsTargetNotPointer = errors.New("bind args: dst must be a non-nil pointer") + // ErrBindArgsTargetNotStruct reports that BindArgs received a pointer to a non-struct value. + ErrBindArgsTargetNotStruct = errors.New("bind args: dst must point to a struct") + // 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") ) func validateMessageText(text string) error { diff --git a/handler.go b/handler.go index 90f0126..dd9bcd0 100644 --- a/handler.go +++ b/handler.go @@ -1,6 +1,7 @@ package laniakea import ( + "context" "encoding/base64" "encoding/json" "errors" @@ -13,36 +14,50 @@ import ( // ErrInvalidPayloadType is returned when callback payload encoding type is unknown. var ErrInvalidPayloadType = errors.New("invalid payload type") -func (bot *Bot[T]) handle(u *tgapi.Update) { +func (bot *Bot[T]) handle(parentCtx context.Context, u *tgapi.Update) { defer func() { if r := recover(); r != nil { bot.logger.Errorln(fmt.Sprintf("panic in handle: %v", r)) } }() - ctx := &MsgContext{ + ctx, cancel := context.WithCancel(parentCtx) + defer cancel() + + msgCtx := &MsgContext{ Update: *u, Api: bot.api, Logger: bot.logger, errorTemplate: bot.errorTemplate, l10n: bot.l10n, draftProvider: bot.draftProvider, + sceneRuntime: bot, payloadType: bot.payloadType, + ctx: ctx, } - bot.prepareUpdateCtx(u, ctx) + bot.prepareUpdateCtx(u, msgCtx) for _, middleware := range bot.middlewares { - if !middleware.Execute(ctx, bot.dbContext) { + if !middleware.Execute(msgCtx, bot.dbContext) { return } } + sceneHandled, err := bot.tryHandleScene(msgCtx) + if err != nil { + bot.logger.Errorln(err) + return + } + if sceneHandled { + return + } + switch u.Type { case tgapi.UpdateTypeMessage, tgapi.UpdateTypeChannelPost: - bot.handleMessage(u, ctx) + bot.handleMessage(u, msgCtx) case tgapi.UpdateTypeCallbackQuery: - bot.handleCallback(u, ctx) + bot.handleCallback(u, msgCtx) default: - bot.handleUpdate(u, ctx) + bot.handleUpdate(u, msgCtx) } } @@ -65,30 +80,11 @@ func (bot *Bot[T]) handleMessage(update *tgapi.Update, ctx *MsgContext) { return } - text = strings.TrimSpace(text) - prefix, hasPrefix := bot.checkPrefixes(text) - if !hasPrefix { + prefix, cmd, args := bot.parseCommand(text) + if cmd == "" { return } - ctx.Prefix = prefix - ctx.Update = *update - - // Убираем префикс - text = strings.TrimSpace(text[len(prefix):]) - - // Извлекаем команду как первое слово - spaceIndex := strings.Index(text, " ") - var cmd string - var args string - - if spaceIndex == -1 { - cmd = text - args = "" - } else { - cmd = text[:spaceIndex] - args = strings.TrimSpace(text[spaceIndex:]) - } if strings.Contains(cmd, "@") { botUsername := bot.username @@ -269,12 +265,12 @@ func (bot *Bot[T]) prepareUpdateCtx(u *tgapi.Update, ctx *MsgContext) { ctx.From = from ctx.FromID = from.ID } - ctx.Update = *u } func (bot *Bot[T]) checkPrefixes(text string) (string, bool) { for _, prefix := range bot.prefixes { if prefix == "" { + bot.logger.Warnln("empty prefix is not allowed") continue } if strings.HasPrefix(text, prefix) { @@ -283,6 +279,23 @@ func (bot *Bot[T]) checkPrefixes(text string) (string, bool) { } return "", false } +func (bot *Bot[T]) parseCommand(text string) (prefix, cmd, args string) { + if prefix, hasPrefix := bot.checkPrefixes(text); hasPrefix { + text = strings.TrimSpace(text[len(prefix):]) + spaceIndex := strings.Index(text, " ") + var cmd string + var args string + if spaceIndex == -1 { + cmd = text + args = "" + } else { + cmd = text[:spaceIndex] + args = strings.TrimSpace(text[spaceIndex:]) + } + return prefix, cmd, args + } + return "", "", "" +} func encodeJsonPayload(d CallbackData) (string, error) { b, err := json.Marshal(d) diff --git a/handler_test.go b/handler_test.go index 485f656..d5a60e9 100644 --- a/handler_test.go +++ b/handler_test.go @@ -1,6 +1,7 @@ package laniakea import ( + "context" "testing" "git.nix13.pw/scuroneko/laniakea/tgapi" @@ -35,7 +36,7 @@ func TestBotMiddlewareReceivesLogger(t *testing.T) { }, } - bot.handle(&tgapi.Update{ + bot.handle(context.Background(), &tgapi.Update{ UpdateID: 1, Type: tgapi.UpdateTypePoll, Poll: &tgapi.Poll{ @@ -135,7 +136,7 @@ func TestHandleUpdateHandlersPopulateFromContext(t *testing.T) { plugins: []Plugin[NoDB]{clonePlugin(plugin)}, } - bot.handle(tt.update) + bot.handle(context.Background(), tt.update) if !called { t.Fatalf("expected update handler for %s to be called", tt.name) @@ -184,7 +185,7 @@ func TestHandleUpdateHandlersReceiveIsolatedContexts(t *testing.T) { }, } - bot.handle(&tgapi.Update{ + bot.handle(context.Background(), &tgapi.Update{ UpdateID: 3, Type: tgapi.UpdateTypeInlineQuery, InlineQuery: &tgapi.InlineQuery{ @@ -225,7 +226,7 @@ func TestHandleChannelPostCommandWithSenderChat(t *testing.T) { plugins: []Plugin[NoDB]{clonePlugin(plugin)}, } - bot.handle(&tgapi.Update{ + bot.handle(context.Background(), &tgapi.Update{ UpdateID: 10, Type: tgapi.UpdateTypeChannelPost, ChannelPost: &tgapi.Message{ @@ -240,3 +241,85 @@ func TestHandleChannelPostCommandWithSenderChat(t *testing.T) { t.Fatal("expected channel post command handler to be called") } } + +func TestCommandHandlerBindArgsEndToEnd(t *testing.T) { + type banInput struct { + UserID int + Reason string + } + + var got banInput + plugin := NewPlugin[NoDB]("test") + plugin.NewCommand(func(ctx *MsgContext, db NoDB) error { + return ctx.BindArgs(&got) + }, "ban", + NewCommandArg("user_id").SetValueType(CommandValueIntType).SetRequired(), + NewCommandArg("reason").SetRequired(), + ) + + bot := &Bot[NoDB]{ + logger: slog.CreateLogger(), + prefixes: []string{"/"}, + plugins: []Plugin[NoDB]{clonePlugin(plugin)}, + } + + bot.handle(context.Background(), &tgapi.Update{ + UpdateID: 11, + Type: tgapi.UpdateTypeMessage, + Message: &tgapi.Message{ + MessageID: 1, + Text: "/ban 42 too loud", + Chat: &tgapi.Chat{ID: 99, Type: string(tgapi.ChatTypePrivate)}, + }, + }) + + want := banInput{UserID: 42, Reason: "too loud"} + if got != want { + t.Fatalf("unexpected bound input: got %#v want %#v", got, want) + } +} + +func TestPayloadHandlerBindArgsEndToEnd(t *testing.T) { + type payloadInput struct { + ID int + Note string + } + + var got payloadInput + plugin := NewPlugin[NoDB]("test") + plugin.NewPayload(func(ctx *MsgContext, db NoDB) error { + return ctx.BindArgs(&got) + }, "approve", + NewCommandArg("id").SetValueType(CommandValueIntType).SetRequired(), + NewCommandArg("note").SetRequired(), + ) + + bot := &Bot[NoDB]{ + logger: slog.CreateLogger(), + payloadType: BotPayloadJson, + plugins: []Plugin[NoDB]{clonePlugin(plugin)}, + } + + data, err := encodeJsonPayload(CallbackData{ + Command: "approve", + Args: []string{"7", "looks", "good"}, + }) + if err != nil { + t.Fatalf("encodeJsonPayload returned error: %v", err) + } + + bot.handle(context.Background(), &tgapi.Update{ + UpdateID: 12, + Type: tgapi.UpdateTypeCallbackQuery, + CallbackQuery: &tgapi.CallbackQuery{ + ID: "cb-1", + Data: data, + From: tgapi.User{ID: 1}, + }, + }) + + want := payloadInput{ID: 7, Note: "looks good"} + if got != want { + t.Fatalf("unexpected bound payload input: got %#v want %#v", got, want) + } +} diff --git a/msg_context.go b/msg_context.go index eeeae9f..26f460b 100644 --- a/msg_context.go +++ b/msg_context.go @@ -4,6 +4,9 @@ import ( "context" "errors" "fmt" + "reflect" + "strconv" + "strings" "time" "git.nix13.pw/scuroneko/laniakea/tgapi" @@ -36,6 +39,9 @@ type MsgContext struct { l10n *L10n draftProvider *DraftProvider payloadType BotPayloadType + sceneRuntime sceneRuntime + + ctx context.Context } // AnswerMessage represents a message sent or edited via MsgContext. @@ -70,7 +76,7 @@ func (ctx *MsgContext) edit(messageId int, text string, keyboard *InlineKeyboard if keyboard != nil { params.ReplyMarkup = keyboard.Get() } - msg, _, err := ctx.Api.EditMessageText(params) + msg, _, err := ctx.Api.EditMessageTextWithContext(ctx.Context(), params) if err != nil { ctx.Logger.Errorln(err) return nil @@ -155,7 +161,7 @@ func (ctx *MsgContext) editPhotoText(messageId int, text string, kb *InlineKeybo params.ReplyMarkup = kb.Get() } - msg, _, err := ctx.Api.EditMessageCaption(params) + msg, _, err := ctx.Api.EditMessageCaptionWithContext(ctx.Context(), params) if err != nil { ctx.Logger.Errorln(err) return nil @@ -218,7 +224,7 @@ func (ctx *MsgContext) answer(text string, keyboard *InlineKeyboard, parseMode t params.DirectMessagesTopicID = ctx.Msg.DirectMessageTopic.TopicID } - msg, err := ctx.Api.SendMessage(params) + msg, err := ctx.Api.SendMessageWithContext(ctx.Context(), params) if err != nil { ctx.Logger.Errorln(err) return nil @@ -349,7 +355,7 @@ func (ctx *MsgContext) answerPhoto(photoId, text string, kb *InlineKeyboard, par params.DirectMessagesTopicID = int(ctx.Msg.DirectMessageTopic.TopicID) } - msg, err := ctx.Api.SendPhoto(params) + msg, err := ctx.Api.SendPhotoWithContext(ctx.Context(), params) if err != nil { ctx.Logger.Errorln(err) return nil @@ -405,7 +411,7 @@ func (ctx *MsgContext) delete(messageId int) { ctx.Logger.Errorln(ErrMessageContextNil) return } - _, err := ctx.Api.DeleteMessage(tgapi.DeleteMessageP{ + _, err := ctx.Api.DeleteMessageWithContext(ctx.Context(), tgapi.DeleteMessageP{ ChatID: ctx.Msg.Chat.ID, MessageID: messageId, }) @@ -431,7 +437,7 @@ func (ctx *MsgContext) answerCallbackQuery(url, text string, showAlert bool) { if len(ctx.CallbackQueryId) == 0 { return } - _, err := ctx.Api.AnswerCallbackQuery(tgapi.AnswerCallbackQueryP{ + _, err := ctx.Api.AnswerCallbackQueryWithContext(ctx.Context(), tgapi.AnswerCallbackQueryP{ CallbackQueryID: ctx.CallbackQueryId, Text: text, ShowAlert: showAlert, URL: url, }) @@ -464,7 +470,7 @@ func (ctx *MsgContext) SendAction(action tgapi.ChatActionType) { if ctx.Msg.MessageThreadID > 0 { params.MessageThreadID = ctx.Msg.MessageThreadID } - _, err := ctx.Api.SendChatAction(params) + _, err := ctx.Api.SendChatActionWithContext(ctx.Context(), params) if err != nil { ctx.Logger.Errorln(err) } @@ -500,7 +506,7 @@ func (ctx *MsgContext) newDraft(parseMode tgapi.ParseMode) *Draft { } if ctx.Api.Limiter != nil { - c, cancel := context.WithTimeout(context.Background(), 5*time.Second) + c, cancel := context.WithTimeout(ctx.Context(), 5*time.Second) defer cancel() if err := ctx.Api.Limiter.Wait(c, ctx.Msg.Chat.ID); err != nil { ctx.Logger.Errorln(err) @@ -540,3 +546,161 @@ func (ctx *MsgContext) Translate(key string) string { func (ctx *MsgContext) NewInlineKeyboard(maxRow int) *InlineKeyboard { return NewInlineKeyboard(ctx.payloadType, maxRow) } + +func bindPositional(args []string, dst any) error { + v := reflect.ValueOf(dst) + if v.Kind() != reflect.Pointer || v.IsNil() { + return ErrBindArgsTargetNotPointer + } + + v = v.Elem() + if v.Kind() != reflect.Struct { + return ErrBindArgsTargetNotStruct + } + + t := v.Type() + fields := make([]int, 0, v.NumField()) + + for i := 0; i < v.NumField(); i++ { + field := v.Field(i) + if !field.CanSet() { + continue + } + fields = append(fields, i) + } + + argIndex := 0 + for fieldPos, fieldIndex := range fields { + field := v.Field(fieldIndex) + fieldType := t.Field(fieldIndex) + + if argIndex >= len(args) { + // Leave trailing fields at their zero values when arguments run out. + break + } + + isLastBindableField := fieldPos == len(fields)-1 + + raw := args[argIndex] + if isLastBindableField && field.Kind() == reflect.String { + raw = strings.Join(args[argIndex:], " ") + } + + switch field.Kind() { + case reflect.String: + field.SetString(raw) + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + n, err := strconv.ParseInt(raw, 10, 64) + if err != nil { + return fmt.Errorf("%w: field %s: %v", ErrBindArgsConversion, fieldType.Name, err) + } + field.SetInt(n) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + n, err := strconv.ParseUint(raw, 10, 64) + if err != nil { + return fmt.Errorf("%w: field %s: %v", ErrBindArgsConversion, fieldType.Name, err) + } + field.SetUint(n) + case reflect.Float32, reflect.Float64: + f, err := strconv.ParseFloat(raw, 64) + if err != nil { + return fmt.Errorf("%w: field %s: %v", ErrBindArgsConversion, fieldType.Name, err) + } + field.SetFloat(f) + case reflect.Bool: + b, err := strconv.ParseBool(raw) + if err != nil { + return fmt.Errorf("%w: field %s: %v", ErrBindArgsConversion, fieldType.Name, err) + } + field.SetBool(b) + default: + return fmt.Errorf("%w: field %s: %s", ErrBindArgsUnsupportedFieldType, fieldType.Name, field.Kind()) + } + + if isLastBindableField && field.Kind() == reflect.String { + break + } + argIndex++ + } + + return nil +} + +// BindArgs binds positional command arguments from ctx.Args into dst. +// +// Exported struct fields are filled in declaration order. When fewer arguments +// are provided than fields, the remaining fields keep their zero values. If the +// final bindable field is a string, it receives the remaining arguments joined +// with spaces. +func (ctx *MsgContext) BindArgs(dst any) error { + return bindPositional(ctx.Args, dst) +} + +// Context returns the request-scoped context associated with the current update. +func (ctx *MsgContext) Context() context.Context { + if ctx.ctx == nil { + return context.Background() + } + return ctx.ctx +} + +func (ctx *MsgContext) EnterScene(name string) error { + scene, ok := ctx.sceneRuntime.FindScene(name) + if !ok { + return ErrSceneNotFound + } + + key, ok := ctx.sceneRuntime.BuildSceneKey(scene.Scope, ctx) + if !ok { + return ErrCantFindSession + } + + session := SceneSession{ + Scene: scene.Name, + Step: scene.Entry, + } + + return ctx.sceneRuntime.SetSession(key, session) +} +func (ctx *MsgContext) EnterSceneStep(name, step string) error { + scene, ok := ctx.sceneRuntime.FindScene(name) + if !ok { + return ErrSceneNotFound + } + if _, ok := scene.Steps[step]; !ok { + return ErrSceneStepNotFound + } + + key, ok := ctx.sceneRuntime.BuildSceneKey(scene.Scope, ctx) + if !ok { + return ErrCantFindSession + } + + session := SceneSession{ + Scene: scene.Name, + Step: step, + } + + return ctx.sceneRuntime.SetSession(key, session) +} +func (ctx *MsgContext) ExitScene() error { + _, session, err := ctx.sceneRuntime.FindSceneSession(ctx) + if err != nil { + return err + } + if session.Scene == "" { + return ErrNotInScene + } + + scene, ok := ctx.sceneRuntime.FindScene(session.Scene) + if !ok { + return ErrSceneNotFound + } + + key, ok := ctx.sceneRuntime.BuildSceneKey(scene.Scope, ctx) + if !ok { + return ErrCantFindSession + } + + return ctx.sceneRuntime.DeleteSession(key) +} diff --git a/msg_context_test.go b/msg_context_test.go index 299fa42..b7e9fa2 100644 --- a/msg_context_test.go +++ b/msg_context_test.go @@ -65,6 +65,107 @@ func TestAnswerPhotoIncludesDirectMessagesTopicID(t *testing.T) { } } +func TestBindArgsBindsScalarFields(t *testing.T) { + type input struct { + ID int + Active bool + Score float64 + Name string + } + + ctx := &MsgContext{Args: []string{"42", "true", "3.5", "Ada", "Lovelace"}} + var got input + + if err := ctx.BindArgs(&got); err != nil { + t.Fatalf("BindArgs returned error: %v", err) + } + + want := input{ + ID: 42, + Active: true, + Score: 3.5, + Name: "Ada Lovelace", + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("unexpected bound value: got %#v want %#v", got, want) + } +} + +func TestBindArgsLeavesTrailingFieldsZeroWhenArgsRunOut(t *testing.T) { + type input struct { + ID int + Reason string + Admin bool + } + + ctx := &MsgContext{Args: []string{"7"}} + var got input + + if err := ctx.BindArgs(&got); err != nil { + t.Fatalf("BindArgs returned error: %v", err) + } + + if got.ID != 7 { + t.Fatalf("unexpected ID: got %d want 7", got.ID) + } + if got.Reason != "" { + t.Fatalf("expected zero-value Reason, got %q", got.Reason) + } + if got.Admin { + t.Fatal("expected zero-value Admin") + } +} + +func TestBindArgsRejectsInvalidTargets(t *testing.T) { + ctx := &MsgContext{Args: []string{"1"}} + + if err := ctx.BindArgs(nil); !errors.Is(err, ErrBindArgsTargetNotPointer) { + t.Fatalf("expected ErrBindArgsTargetNotPointer for nil target, got %v", err) + } + + var notStruct int + if err := ctx.BindArgs(¬Struct); !errors.Is(err, ErrBindArgsTargetNotStruct) { + t.Fatalf("expected ErrBindArgsTargetNotStruct for non-struct target, got %v", err) + } +} + +func TestBindArgsReportsConversionFailures(t *testing.T) { + type input struct { + ID int + } + + ctx := &MsgContext{Args: []string{"oops"}} + var got input + + err := ctx.BindArgs(&got) + if err == nil { + t.Fatal("expected BindArgs to fail") + } + if !errors.Is(err, ErrBindArgsConversion) { + t.Fatalf("expected ErrBindArgsConversion, got %v", err) + } + if !strings.Contains(err.Error(), "field ID") { + t.Fatalf("expected field name in error, got %v", err) + } +} + +func TestBindArgsRejectsUnsupportedFieldTypes(t *testing.T) { + type input struct { + Tags []string + } + + ctx := &MsgContext{Args: []string{"tag"}} + var got input + + err := ctx.BindArgs(&got) + if err == nil { + t.Fatal("expected BindArgs to fail") + } + if !errors.Is(err, ErrBindArgsUnsupportedFieldType) { + t.Fatalf("expected ErrBindArgsUnsupportedFieldType, got %v", err) + } +} + func TestAnswerRejectsEmptyMessage(t *testing.T) { ctx := &MsgContext{ Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: string(tgapi.ChatTypePrivate)}}, diff --git a/plugins.go b/plugins.go index 656663c..a0c6834 100644 --- a/plugins.go +++ b/plugins.go @@ -161,6 +161,7 @@ type Plugin[T DbContext] struct { name string // Name of the plugin (e.g., "admin", "user") commands map[string]*Command[T] // Registered commands (triggered by message) payloads map[string]*Command[T] // Registered payloads (triggered by callback data) + scenes map[string]*Scene[T] // Optional scenes for multi-step interactions middlewares extypes.Slice[Middleware[T]] // Shared middlewares for all commands/payloads skipAutoCmd bool // If true, all commands in this plugin are excluded from auto-help logger *slog.Logger @@ -177,6 +178,7 @@ func NewPlugin[T DbContext](name string) *Plugin[T] { commands: make(map[string]*Command[T]), payloads: make(map[string]*Command[T]), middlewares: make(extypes.Slice[Middleware[T]], 0), + scenes: make(map[string]*Scene[T]), skipAutoCmd: false, logger: nil, handlers: make(map[tgapi.UpdateType]CommandExecutor[T]), @@ -213,6 +215,18 @@ func (p *Plugin[T]) NewPayload(exec CommandExecutor[T], command string, args ... return cmd } +func (p *Plugin[T]) AddScene(scene *Scene[T]) *Plugin[T] { + scene.PluginName = p.name + scene.setPluginName(p.name) + return p +} +func (p *Plugin[T]) NewScene(name string) *Scene[T] { + scene := NewScene[T](name) + scene.setPluginName(p.name) + p.AddScene(scene) + return scene +} + // AddUpdateHandler registers a handler for a non-command update type. // Message, channel post, and callback query updates stay on the command/payload flow. func (p *Plugin[T]) AddUpdateHandler(t tgapi.UpdateType, handler CommandExecutor[T]) *Plugin[T] { diff --git a/scene.go b/scene.go new file mode 100644 index 0000000..c9c545a --- /dev/null +++ b/scene.go @@ -0,0 +1,195 @@ +package laniakea + +import ( + "encoding/json" + "sync" +) + +type SceneHandler[T any] func(ctx *SceneContext, db T) (SceneResult, error) +type Scene[T any] struct { + Name string + Scope SceneScope + Entry string // starting step + PluginName string + + steps map[string]SceneHandler[T] + commands map[string]SceneHandler[T] + message SceneHandler[T] +} + +func NewScene[T any](name string) *Scene[T] { + return &Scene[T]{ + Name: name, + Scope: SceneScopeUserChat, + Entry: "", + steps: make(map[string]SceneHandler[T]), + commands: make(map[string]SceneHandler[T]), + message: nil, + } +} +func (s *Scene[T]) SetScope(scope SceneScope) *Scene[T] { + s.Scope = scope + return s +} +func (s *Scene[T]) SetEntry(step string) *Scene[T] { + s.Entry = step + return s +} +func (s *Scene[T]) setPluginName(name string) *Scene[T] { + s.PluginName = name + return s +} + +func (s *Scene[T]) OnStep(step string, handler SceneHandler[T]) *Scene[T] { + s.steps[step] = handler + return s +} +func (s *Scene[T]) OnCommand(cmd string, handler SceneHandler[T]) *Scene[T] { + s.commands[cmd] = handler + return s +} +func (s *Scene[T]) OnMessage(handler SceneHandler[T]) *Scene[T] { + s.message = handler + return s +} + +func (s *Scene[T]) executeCommand(cmd string, ctx *SceneContext, db T) (SceneResult, bool, error) { + handler, ok := s.commands[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 { + return SceneResult{}, false, nil + } + result, err := handler(ctx, db) + return result, true, err +} +func (s *Scene[T]) executeMessage(ctx *SceneContext, db T) (SceneResult, bool, error) { + if s.message == nil { + return SceneResult{}, false, nil + } + result, err := s.message(ctx, db) + return result, true, err +} + +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. +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. +func (s *SceneSession) GetData() []byte { + return s.Data +} +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. +func (s *SceneSession) ClearData() { + s.Data = nil +} + +// BindData binds the session data to the provided struct. +func (s *SceneSession) BindData(v any) error { + if len(s.Data) == 0 { + return nil // No data to bind, return nil error + } + return json.Unmarshal(s.Data, v) +} + +// SaveData saves the provided struct as JSON in the session data. +func (s *SceneSession) SaveData(v any) error { + data, err := json.Marshal(v) + if err != nil { + return err + } + s.Data = data + return nil +} + +type SessionStore interface { + Get(key string) (SceneSession, error) + Set(key string, session SceneSession) error + Delete(key string) error +} + +type MemorySessionStore struct { + store map[string]SceneSession + mu sync.RWMutex +} + +func NewMemorySessionStore() *MemorySessionStore { + return &MemorySessionStore{ + store: make(map[string]SceneSession), + } +} +func (s *MemorySessionStore) Get(key string) (SceneSession, error) { + s.mu.RLock() + defer s.mu.RUnlock() + if session, ok := s.store[key]; ok { + return session, nil + } + return SceneSession{}, nil +} +func (s *MemorySessionStore) Set(key string, session SceneSession) error { + s.mu.Lock() + s.store[key] = session + s.mu.Unlock() + return nil +} +func (s *MemorySessionStore) Delete(key string) error { + s.mu.Lock() + delete(s.store, key) + s.mu.Unlock() + return nil +} + +type SceneResult struct { + Action SceneAction + Next string +} + +type SceneAction int + +const ( + SceneActionStay SceneAction = iota + SceneActionNext + SceneActionExit + SceneActionPass +) + +type SceneScope int + +const ( + SceneScopeUser SceneScope = iota + SceneScopeChat + 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) +} + +type sceneMeta struct { + Name string + Scope SceneScope + Entry string + Steps map[string]struct{} +} diff --git a/scene_context.go b/scene_context.go new file mode 100644 index 0000000..048a14c --- /dev/null +++ b/scene_context.go @@ -0,0 +1,23 @@ +package laniakea + +type SceneContext struct { + *MsgContext + sess SceneSession + key string +} + +func (ctx *SceneContext) Next(step string) SceneResult { + return SceneResult{ + Action: SceneActionNext, + Next: step, + } +} +func (ctx *SceneContext) Stay() SceneResult { + return SceneResult{Action: SceneActionStay} +} +func (ctx *SceneContext) Exit() SceneResult { + return SceneResult{Action: SceneActionExit} +} +func (ctx *SceneContext) Pass() SceneResult { + return SceneResult{Action: SceneActionPass} +} diff --git a/scene_handler.go b/scene_handler.go new file mode 100644 index 0000000..8fd5f95 --- /dev/null +++ b/scene_handler.go @@ -0,0 +1,130 @@ +package laniakea + +import ( + "errors" + "fmt" + "strings" +) + +func (bot *Bot[T]) tryHandleScene(ctx *MsgContext) (bool, error) { + key, session, err := bot.FindSceneSession(ctx) + if err != nil { + if errors.Is(err, ErrCantFindSession) || errors.Is(err, ErrMessageNil) { + return false, nil + } + return false, err + } + if session.Scene == "" { + return false, nil + } + + for _, plugin := range bot.plugins { + scene, ok := plugin.scenes[session.Scene] + if !ok { + continue + } + if scene.PluginName != "" && scene.PluginName != plugin.name { + continue + } + sceneCtx := &SceneContext{ + MsgContext: ctx, + sess: session, + key: key, + } + return bot.executeScene(scene, sceneCtx) + } + return false, ErrSceneNotFound +} + +func (bot *Bot[T]) executeScene(scene *Scene[T], ctx *SceneContext) (bool, error) { + if ctx.MsgContext == nil || ctx.sess.Scene == "" { + return false, nil + } + text := ctx.Msg.Text + if text == "" { + text = ctx.Msg.Caption + } + text = strings.TrimSpace(text) + prefix, cmd, args := bot.parseCommand(text) + if cmd != "" { + ctx.Prefix = prefix + ctx.Text = args + ctx.Args = strings.Fields(args) + + res, matched, err := scene.executeCommand(cmd, ctx, bot.dbContext) + if err != nil { + return false, err + } + if matched { + return bot.applySceneResult(scene, ctx, res) + } + } + ctx.Text = text + ctx.Args = nil + ctx.Prefix = "" + if ctx.sess.Step != "" { + res, matched, err := scene.executeStep(ctx.sess.Step, ctx, bot.dbContext) + if err != nil { + return false, err + } + if matched { + return bot.applySceneResult(scene, ctx, res) + } + } + + res, matched, err := scene.executeMessage(ctx, bot.dbContext) + if err != nil { + return false, err + } + if matched { + return bot.applySceneResult(scene, ctx, res) + } + + return false, nil +} +func (bot *Bot[T]) applySceneResult(scene *Scene[T], ctx *SceneContext, result SceneResult) (bool, error) { + switch result.Action { + case SceneActionStay: + if err := bot.sessionStore.Set(ctx.key, ctx.sess); err != nil { + return false, err + } + return true, nil + + case SceneActionNext: + if result.Next == "" { + return false, ErrSceneStepNotFound + } + if _, ok := scene.steps[result.Next]; !ok { + return false, ErrSceneStepNotFound + } + ctx.sess.Step = result.Next + if err := bot.sessionStore.Set(ctx.key, ctx.sess); err != nil { + return false, err + } + return true, nil + + case SceneActionExit: + if err := bot.sessionStore.Delete(ctx.key); err != nil { + return false, err + } + return true, nil + + case SceneActionPass: + return false, nil + + default: + return false, nil + } +} +func buildSceneKey(scope SceneScope, ctx *MsgContext) (string, bool) { + switch scope { + case SceneScopeUserChat: + return fmt.Sprintf("user_id:%d:chat_id:%d", ctx.FromID, ctx.Msg.Chat.ID), true + case SceneScopeChat: + return fmt.Sprintf("chat_id:%d", ctx.Msg.Chat.ID), true + case SceneScopeUser: + return fmt.Sprintf("user_id:%d", ctx.FromID), true + default: + return "", false + } +}