wip: scene sessions

This commit is contained in:
2026-03-27 16:05:20 +03:00
parent 68e7529f16
commit 4f8d583b03
14 changed files with 909 additions and 173 deletions
+11
View File
@@ -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 repositorys 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.
+12
View File
@@ -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
+9 -128
View File
@@ -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`.
+38 -4
View File
@@ -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
}
+61
View File
@@ -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)
}
+14
View File
@@ -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 {
+42 -29
View File
@@ -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)
+87 -4
View File
@@ -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)
}
}
+172 -8
View File
@@ -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)
}
+101
View File
@@ -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(&notStruct); !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)}},
+14
View File
@@ -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] {
+195
View File
@@ -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{}
}
+23
View File
@@ -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}
}
+130
View File
@@ -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
}
}