REPOSITORY / ScuroNeko/Laniakea
Compare commits
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f74496a3e8
|
||
|
|
a4d70e1510
|
||
|
|
3ad9e48d71
|
||
|
|
4f8d583b03
|
@@ -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.
|
||||
|
||||
+17
-7
@@ -6,30 +6,40 @@
|
||||
- `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`.
|
||||
- Work-in-progress scene/session support, including plugin scene registration, scoped scene sessions, scene entry/exit APIs on `MsgContext`, default in-memory session storage, scene-local routing before normal command handling, and state helpers on `SceneContext`.
|
||||
|
||||
### Changed
|
||||
- `CommandExecutor` now returns `error`, and command, payload, and non-command update handlers now use centralized bot error handling for returned errors.
|
||||
- README and README_RU examples now use the new handler signature and document the long-message helpers.
|
||||
- README and README_RU now link to the project wiki, and the wiki now includes a page-priority tracker while content is being filled in.
|
||||
- `AGENTS.md` now requires every change to be recorded in `CHANGELOG.md`, enforces version alignment with `utils/version.go`, and blocks breaking changes without a major-version bump.
|
||||
- `AGENTS.md` now also defines a short commit-message format: one summary line plus up to three high-signal detail lines.
|
||||
- `AGENTS.md` now explicitly requires each commit-message detail line to be placed on its own new line.
|
||||
- `AGENTS.md` now also requires commit messages to be emitted as a plain multiline block instead of collapsed prose or list formatting.
|
||||
- `AGENTS.md` now requires new or expanded project documentation to be maintained in both English and Russian whenever reasonably possible.
|
||||
- `AGENTS.md` now requires all agent-created commits to be GPG-signed and to fail fast instead of falling back to unsigned commits when signing cannot be completed.
|
||||
- Added `TODO.md` to track missing framework-level concepts, with detailed notes for scenes, typed handler input, and request-scoped cancellation.
|
||||
- README and README_RU now document scenes, session scopes, scene state helpers, and `SceneActionPass` semantics.
|
||||
- `TODO.md` and the framework backlog pages now group the remaining framework work into explicit priority 1, 2, and 3 buckets.
|
||||
- Payload-type comments and docs now distinguish between the bot's default payload type and keyboard-local overrides.
|
||||
- Scene runtime sentinel errors now have explicit godoc comments.
|
||||
- Public scene structs now document their exported fields more explicitly.
|
||||
- `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.
|
||||
- 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.
|
||||
- Added scene regression tests for runtime guards, scene-local command handling, and `SceneActionPass` preserving session state.
|
||||
- Added scene regression tests for message fallback handling, user-scoped session lookup without `Msg`, and custom `SessionStore` error propagation.
|
||||
|
||||
## v1.0.0-rc.11
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
|
||||
[](https://go.dev/)
|
||||
[](LICENSE)
|
||||

|
||||

|
||||
|
||||
A lightweight, easy-to-use, and performant Telegram Bot API wrapper for Go. It simplifies bot development with a clean plugin system, middleware support, automatic command generation, and built-in rate limiting.
|
||||
|
||||
[На русском](README_RU.md)
|
||||
|
||||
[Wiki](https://git.nix13.pw/ScuroNeko/Laniakea/wiki)
|
||||
[Wiki](https://git.scuroneko.dev/ScuroNeko/Laniakea/wiki)
|
||||
|
||||
---
|
||||
|
||||
@@ -29,7 +29,7 @@ A lightweight, easy-to-use, and performant Telegram Bot API wrapper for Go. It s
|
||||
## 📦 Installation
|
||||
|
||||
```bash
|
||||
go get git.nix13.pw/scuroneko/laniakea
|
||||
go get git.scuroneko.dev/scuroneko/laniakea
|
||||
```
|
||||
|
||||
or
|
||||
@@ -47,7 +47,7 @@ package main
|
||||
import (
|
||||
"log"
|
||||
|
||||
"git.nix13.pw/scuroneko/laniakea" // Import the Laniakea library
|
||||
"git.scuroneko.dev/scuroneko/laniakea" // Import the Laniakea library
|
||||
)
|
||||
|
||||
// echo is a command handler function.
|
||||
@@ -182,6 +182,43 @@ if err != nil {
|
||||
bot.DatabaseContext(db)
|
||||
```
|
||||
|
||||
### Scenes and Sessions
|
||||
|
||||
Scenes model multi-step conversations inside a plugin. Each active scene is stored in a session keyed by scope, so you can isolate flows per user, per chat, or per user-chat pair.
|
||||
|
||||
```go
|
||||
plugin := laniakea.NewPlugin[MyDB]("signup")
|
||||
|
||||
plugin.NewScene("signup").
|
||||
SetScope(laniakea.SceneScopeUserChat).
|
||||
SetEntry("ask_name").
|
||||
OnStep("ask_name", func(ctx *laniakea.SceneContext, db MyDB) (laniakea.SceneResult, error) {
|
||||
if ctx.Text == "" {
|
||||
ctx.Answer("What is your name?")
|
||||
return ctx.Stay(), nil
|
||||
}
|
||||
|
||||
if err := ctx.SaveData(struct {
|
||||
Name string `json:"name"`
|
||||
}{Name: ctx.Text}); err != nil {
|
||||
return laniakea.SceneResult{}, err
|
||||
}
|
||||
|
||||
ctx.Answer("Nice to meet you.")
|
||||
return ctx.Next("done"), nil
|
||||
}).
|
||||
OnStep("done", func(ctx *laniakea.SceneContext, db MyDB) (laniakea.SceneResult, error) {
|
||||
return ctx.Exit(), nil
|
||||
})
|
||||
```
|
||||
|
||||
- Use `ctx.EnterScene("signup")` to enter the configured entry step.
|
||||
- Use `ctx.EnterSceneStep("signup", "done")` when you need an explicit starting step.
|
||||
- Return `ctx.Stay()`, `ctx.Next(step)`, `ctx.Exit()`, or `ctx.Pass()` from scene handlers to control flow.
|
||||
- `SceneActionPass` keeps the current session unchanged and continues normal bot routing.
|
||||
- Use `SceneContext.SaveData(...)` and `SceneContext.BindData(...)` for JSON session state.
|
||||
- Use `SceneScopeUser`, `SceneScopeChat`, or `SceneScopeUserChat` depending on how widely a conversation should be shared.
|
||||
|
||||
## 🧩 Middleware
|
||||
Middleware are functions that run before a command handler. They are perfect for cross-cutting concerns like logging, access control, rate limiting, or modifying the context.
|
||||
|
||||
@@ -247,9 +284,9 @@ func adminOnlyMiddleware(ctx *laniakea.MsgContext, db *MyDB) bool {
|
||||
This project is licensed under the GNU General Public License v3.0 — see the [LICENSE](LICENSE) file for details.
|
||||
|
||||
## 📚 Learn More
|
||||
[GoDoc](https://pkg.go.dev/git.nix13.pw/scuroneko/laniakea)
|
||||
[GoDoc](https://pkg.go.dev/git.scuroneko.dev/scuroneko/laniakea)
|
||||
|
||||
[Wiki](https://git.nix13.pw/ScuroNeko/Laniakea/wiki)
|
||||
[Wiki](https://git.scuroneko.dev/ScuroNeko/Laniakea/wiki)
|
||||
|
||||
[Telegram Bot API](https://core.telegram.org/bots/api)
|
||||
|
||||
|
||||
+43
-6
@@ -4,13 +4,13 @@
|
||||
|
||||
[](https://go.dev/)
|
||||
[](LICENSE)
|
||||

|
||||

|
||||
|
||||
Легковесная, простая в использовании и производительная обёртка для Telegram Bot API на Go. Она упрощает разработку ботов благодаря чистой системе плагинов, поддержке Middleware, автоматической генерации команд и встроенному рейтлимитеру.
|
||||
|
||||
[English](README.md)
|
||||
|
||||
[Wiki](https://git.nix13.pw/ScuroNeko/Laniakea/wiki)
|
||||
[Wiki](https://git.scuroneko.dev/ScuroNeko/Laniakea/wiki)
|
||||
|
||||
---
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
## 📦 Установка
|
||||
|
||||
```bash
|
||||
go get git.nix13.pw/scuroneko/laniakea
|
||||
go get git.scuroneko.dev/scuroneko/laniakea
|
||||
```
|
||||
|
||||
или
|
||||
@@ -48,7 +48,7 @@ package main
|
||||
import (
|
||||
"log"
|
||||
|
||||
"git.nix13.pw/scuroneko/laniakea" // Импортируем библиотеку Laniakea
|
||||
"git.scuroneko.dev/scuroneko/laniakea" // Импортируем библиотеку Laniakea
|
||||
)
|
||||
|
||||
// echo — это функция-обработчик команды.
|
||||
@@ -170,6 +170,43 @@ if err != nil {
|
||||
bot.DatabaseContext(db)
|
||||
```
|
||||
|
||||
### Сцены и сессии (Scenes and Sessions)
|
||||
|
||||
Сцены описывают многошаговые диалоги внутри плагина. Активная сцена хранится в session state, ключ которого зависит от scope, поэтому поток можно изолировать на пользователя, на чат или на пару пользователь-чат.
|
||||
|
||||
```go
|
||||
plugin := laniakea.NewPlugin[MyDB]("signup")
|
||||
|
||||
plugin.NewScene("signup").
|
||||
SetScope(laniakea.SceneScopeUserChat).
|
||||
SetEntry("ask_name").
|
||||
OnStep("ask_name", func(ctx *laniakea.SceneContext, db MyDB) (laniakea.SceneResult, error) {
|
||||
if ctx.Text == "" {
|
||||
ctx.Answer("Как тебя зовут?")
|
||||
return ctx.Stay(), nil
|
||||
}
|
||||
|
||||
if err := ctx.SaveData(struct {
|
||||
Name string `json:"name"`
|
||||
}{Name: ctx.Text}); err != nil {
|
||||
return laniakea.SceneResult{}, err
|
||||
}
|
||||
|
||||
ctx.Answer("Приятно познакомиться.")
|
||||
return ctx.Next("done"), nil
|
||||
}).
|
||||
OnStep("done", func(ctx *laniakea.SceneContext, db MyDB) (laniakea.SceneResult, error) {
|
||||
return ctx.Exit(), nil
|
||||
})
|
||||
```
|
||||
|
||||
- Используйте `ctx.EnterScene("signup")`, чтобы войти в entry step, настроенный у сцены.
|
||||
- Используйте `ctx.EnterSceneStep("signup", "done")`, если нужен явный стартовый step.
|
||||
- Из scene handler возвращайте `ctx.Stay()`, `ctx.Next(step)`, `ctx.Exit()` или `ctx.Pass()` для управления потоком.
|
||||
- `SceneActionPass` не меняет текущую session state и продолжает обычный routing бота.
|
||||
- Для JSON-состояния сцены используйте `SceneContext.SaveData(...)` и `SceneContext.BindData(...)`.
|
||||
- Выбирайте `SceneScopeUser`, `SceneScopeChat` или `SceneScopeUserChat` в зависимости от того, насколько широко должен разделяться диалог.
|
||||
|
||||
### tgapi: API и Uploader
|
||||
|
||||
В `tgapi` есть два клиента:
|
||||
@@ -243,9 +280,9 @@ func adminOnlyMiddleware(ctx *laniakea.MsgContext, db *MyDB) bool {
|
||||
Этот проект лицензирован под GNU General Public License v3.0 - подробности см. в файле [LICENSE](LICENSE).
|
||||
|
||||
## 📚 Дополнительная информация
|
||||
[GoDoc Laniakea](https://pkg.go.dev/git.nix13.pw/scuroneko/laniakea)
|
||||
[GoDoc Laniakea](https://pkg.go.dev/git.scuroneko.dev/scuroneko/laniakea)
|
||||
|
||||
[Wiki](https://git.nix13.pw/ScuroNeko/Laniakea/wiki)
|
||||
[Wiki](https://git.scuroneko.dev/ScuroNeko/Laniakea/wiki)
|
||||
|
||||
[Telegram Bot API](https://core.telegram.org/bots/api)
|
||||
|
||||
|
||||
@@ -1,136 +1,23 @@
|
||||
# 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.scuroneko.dev/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.scuroneko.dev/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 priority split:
|
||||
|
||||
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.
|
||||
- `Priority 1`: update schema contract, user-facing vs internal error model, configuration freeze model.
|
||||
- `Priority 2`: webhook runtime model, authorization and policy model, observability model.
|
||||
- `Priority 3`: service layer and dependency graph model, plugin composition contract.
|
||||
|
||||
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.
|
||||
Completed former high-priority items:
|
||||
|
||||
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`: completed in `v1.0.0-rc.12`.
|
||||
- `2. Typed Handler Input Model`: completed in `v1.0.0-rc.12`.
|
||||
- `3. Request Context / Cancellation Model`: completed in `v1.0.0-rc.12`.
|
||||
|
||||
@@ -4,16 +4,18 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"reflect"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.nix13.pw/scuroneko/extypes"
|
||||
"git.nix13.pw/scuroneko/laniakea/tgapi"
|
||||
"git.nix13.pw/scuroneko/laniakea/utils"
|
||||
"git.nix13.pw/scuroneko/slog"
|
||||
"git.scuroneko.dev/scuroneko/extypes"
|
||||
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
||||
"git.scuroneko.dev/scuroneko/laniakea/utils"
|
||||
"git.scuroneko.dev/scuroneko/slog"
|
||||
"github.com/alitto/pond/v2"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -335,6 +343,48 @@ func (bot *Bot[T]) SetDraftProvider(p *DraftProvider) *Bot[T] {
|
||||
return bot
|
||||
}
|
||||
|
||||
// GetDraftProvider returns the draft provider currently used by the bot.
|
||||
func (bot *Bot[T]) GetDraftProvider() *DraftProvider {
|
||||
return bot.draftProvider
|
||||
}
|
||||
|
||||
// SetSessionStore replaces the session store used for scene management.
|
||||
func (bot *Bot[T]) SetSessionStore(store SessionStore) *Bot[T] {
|
||||
if store == nil {
|
||||
bot.logger.Warn("SetSessionStore called with nil store; using default MemorySessionStore")
|
||||
return bot
|
||||
}
|
||||
bot.sessionStore = store
|
||||
return bot
|
||||
}
|
||||
|
||||
// GetSessionStore returns the session store used for scene management.
|
||||
func (bot *Bot[T]) GetSessionStore() SessionStore {
|
||||
return bot.sessionStore
|
||||
}
|
||||
|
||||
// SetSceneScopePriority sets the lookup order for resolving active scene sessions.
|
||||
func (bot *Bot[T]) SetSceneScopePriority(priority []SceneScope) *Bot[T] {
|
||||
newPriority := make([]SceneScope, 0, 3)
|
||||
for _, scope := range priority {
|
||||
if scope != SceneScopeUser && scope != SceneScopeChat && scope != SceneScopeUserChat {
|
||||
bot.logger.Warnln(fmt.Sprintf("invalid scene scope %v in priority list; ignoring", scope))
|
||||
continue
|
||||
}
|
||||
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().
|
||||
// For shared dependencies such as *sql.DB, prefer using a pointer type as T.
|
||||
@@ -668,7 +718,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
|
||||
@@ -746,6 +796,7 @@ func clonePlugin[T DbContext](p *Plugin[T]) Plugin[T] {
|
||||
name: p.name,
|
||||
commands: make(map[string]*Command[T], len(p.commands)),
|
||||
payloads: make(map[string]*Command[T], len(p.payloads)),
|
||||
scenes: make(map[string]*Scene[T], len(p.scenes)),
|
||||
middlewares: append(extypes.Slice[Middleware[T]](nil), p.middlewares...),
|
||||
skipAutoCmd: p.skipAutoCmd,
|
||||
logger: p.logger,
|
||||
@@ -759,9 +810,10 @@ 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
|
||||
for name, scene := range p.scenes {
|
||||
cloned.scenes[name] = cloneScene(scene)
|
||||
}
|
||||
maps.Copy(cloned.handlers, p.handlers)
|
||||
|
||||
return cloned
|
||||
}
|
||||
@@ -776,3 +828,22 @@ func cloneCommand[T DbContext](command *Command[T]) *Command[T] {
|
||||
cloned.middlewares = append(extypes.Slice[Middleware[T]](nil), command.middlewares...)
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func cloneScene[T DbContext](scene *Scene[T]) *Scene[T] {
|
||||
if scene == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
cloned := *scene
|
||||
cloned.steps = make(map[string]SceneHandler[T], len(scene.steps))
|
||||
cloned.commands = make(map[string]SceneHandler[T], len(scene.commands))
|
||||
|
||||
for name, handler := range scene.steps {
|
||||
cloned.steps[name] = handler
|
||||
}
|
||||
for name, handler := range scene.commands {
|
||||
cloned.commands[name] = handler
|
||||
}
|
||||
|
||||
return &cloned
|
||||
}
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.nix13.pw/scuroneko/laniakea/tgapi"
|
||||
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
||||
)
|
||||
|
||||
// BotOpts holds configuration options for initializing a Bot.
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"git.nix13.pw/scuroneko/laniakea/tgapi"
|
||||
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
||||
)
|
||||
|
||||
func TestLoadOptsFromEnvIgnoresEmptyUpdateTypes(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
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
|
||||
|
||||
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)
|
||||
}
|
||||
+2
-2
@@ -8,8 +8,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.nix13.pw/scuroneko/laniakea/tgapi"
|
||||
"git.nix13.pw/scuroneko/slog"
|
||||
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
||||
"git.scuroneko.dev/scuroneko/slog"
|
||||
)
|
||||
|
||||
func TestGetUpdateTypesReturnsCopy(t *testing.T) {
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.nix13.pw/scuroneko/laniakea/tgapi"
|
||||
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
||||
)
|
||||
|
||||
// CmdRegexp matches command names allowed for Telegram command registration.
|
||||
|
||||
@@ -10,8 +10,8 @@ import (
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"git.nix13.pw/scuroneko/laniakea/tgapi"
|
||||
"git.nix13.pw/scuroneko/slog"
|
||||
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
||||
"git.scuroneko.dev/scuroneko/slog"
|
||||
)
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"git.nix13.pw/scuroneko/laniakea/tgapi"
|
||||
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
||||
)
|
||||
|
||||
// Interface for generating unique draft IDs.
|
||||
|
||||
+2
-2
@@ -5,8 +5,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.nix13.pw/scuroneko/laniakea/tgapi"
|
||||
"git.nix13.pw/scuroneko/slog"
|
||||
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
||||
"git.scuroneko.dev/scuroneko/slog"
|
||||
)
|
||||
|
||||
func TestDraftFlushRequiresChatID(t *testing.T) {
|
||||
|
||||
@@ -24,6 +24,8 @@ 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 reports that a required message value is nil.
|
||||
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 +38,26 @@ 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 reports that no scene session matches the current context.
|
||||
ErrCantFindSession = errors.New("can't find session for this context")
|
||||
// ErrSceneNotFound reports that the requested scene is not registered.
|
||||
ErrSceneNotFound = errors.New("scene not found")
|
||||
// ErrSceneStepNotFound reports that the requested scene step is not registered.
|
||||
ErrSceneStepNotFound = errors.New("scene step not found")
|
||||
// ErrNotInScene reports that the current context has no active scene session.
|
||||
ErrNotInScene = errors.New("not in scene")
|
||||
// ErrSceneEntryNotSet reports that a scene has no configured entry step.
|
||||
ErrSceneEntryNotSet = errors.New("scene entry step not set")
|
||||
// ErrSceneRuntimeNil reports that scene APIs were used without an attached runtime.
|
||||
ErrSceneRuntimeNil = errors.New("scene runtime is nil")
|
||||
)
|
||||
|
||||
func validateMessageText(text string) error {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
module git.nix13.pw/scuroneko/laniakea
|
||||
module git.scuroneko.dev/scuroneko/laniakea
|
||||
|
||||
go 1.26
|
||||
|
||||
retract v1.0.0-rc.5
|
||||
|
||||
require (
|
||||
git.nix13.pw/scuroneko/extypes v1.2.2
|
||||
git.nix13.pw/scuroneko/slog v1.1.2
|
||||
git.scuroneko.dev/scuroneko/extypes v1.2.3
|
||||
git.scuroneko.dev/scuroneko/slog v1.1.3
|
||||
github.com/alitto/pond/v2 v2.7.0
|
||||
golang.org/x/time v0.15.0
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
git.nix13.pw/scuroneko/extypes v1.2.2 h1:N54c1ejrPs1yfIkvYuwqI7B1+8S9mDv2GqQA6sct4dk=
|
||||
git.nix13.pw/scuroneko/extypes v1.2.2/go.mod h1:b4XYk1OW1dVSiE2MT/OMuX/K/UItf1swytX6eroVYnk=
|
||||
git.nix13.pw/scuroneko/slog v1.1.2 h1:pl7tV5FN25Yso7sLYoOgBXi9+jLo5BDJHWmHlNPjpY0=
|
||||
git.nix13.pw/scuroneko/slog v1.1.2/go.mod h1:UcfRIHDqpVQHahBGM93awLDK8//AsAvOqBwwbWqMkjM=
|
||||
git.scuroneko.dev/scuroneko/extypes v1.2.3 h1:n7QsfTZEn9fJNZLXGH/LkNq4cADaRk+LTu6LNMv9y6s=
|
||||
git.scuroneko.dev/scuroneko/extypes v1.2.3/go.mod h1:MhYpXC6sloLOpoM2guf64eSOrz+ET/QJZ8toobc3Ors=
|
||||
git.scuroneko.dev/scuroneko/slog v1.1.3 h1:vI4GZykn8gDb6OJ2xq+KLcEk38M7O4e/z1kzpeRHEHw=
|
||||
git.scuroneko.dev/scuroneko/slog v1.1.3/go.mod h1:gnDap54sfZv3EuSyZd7fjOH46aLbDFpvtN2wgFcWkgE=
|
||||
github.com/alitto/pond/v2 v2.7.0 h1:c76L+yN916m/DRXjGCeUBHHu92uWnh/g1bwVk4zyyXg=
|
||||
github.com/alitto/pond/v2 v2.7.0/go.mod h1:xkjYEgQ05RSpWdfSd1nM3OVv7TBhLdy7rMp3+2Nq+yE=
|
||||
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||
|
||||
+45
-30
@@ -1,48 +1,63 @@
|
||||
package laniakea
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.nix13.pw/scuroneko/laniakea/tgapi"
|
||||
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
||||
)
|
||||
|
||||
// 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,14 @@ 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 == "" {
|
||||
if bot.logger != nil {
|
||||
bot.logger.Warnln("empty prefix is not allowed")
|
||||
}
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(text, prefix) {
|
||||
@@ -283,6 +281,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)
|
||||
|
||||
+89
-6
@@ -1,10 +1,11 @@
|
||||
package laniakea
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"git.nix13.pw/scuroneko/laniakea/tgapi"
|
||||
"git.nix13.pw/scuroneko/slog"
|
||||
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
||||
"git.scuroneko.dev/scuroneko/slog"
|
||||
)
|
||||
|
||||
func TestCheckPrefixesSkipsEmptyPrefixes(t *testing.T) {
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -3,8 +3,8 @@ package laniakea
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"git.nix13.pw/scuroneko/extypes"
|
||||
"git.nix13.pw/scuroneko/laniakea/tgapi"
|
||||
"git.scuroneko.dev/scuroneko/extypes"
|
||||
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"git.nix13.pw/scuroneko/laniakea/tgapi"
|
||||
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
||||
)
|
||||
|
||||
// Updates fetches new updates from Telegram API using long polling.
|
||||
|
||||
+197
-10
@@ -4,10 +4,13 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.nix13.pw/scuroneko/laniakea/tgapi"
|
||||
"git.nix13.pw/scuroneko/slog"
|
||||
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
||||
"git.scuroneko.dev/scuroneko/slog"
|
||||
)
|
||||
|
||||
// MsgContext holds the context for handling a Telegram message or callback query.
|
||||
@@ -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,184 @@ 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
|
||||
}
|
||||
|
||||
// EnterScene enters the named scene at its configured entry step.
|
||||
func (ctx *MsgContext) EnterScene(name string) error {
|
||||
if ctx.sceneRuntime == nil {
|
||||
return ErrSceneRuntimeNil
|
||||
}
|
||||
|
||||
scene, ok := ctx.sceneRuntime.findScene(name)
|
||||
if !ok {
|
||||
return ErrSceneNotFound
|
||||
}
|
||||
|
||||
key, ok := ctx.sceneRuntime.buildSceneKey(scene.Scope, ctx)
|
||||
if !ok {
|
||||
return ErrCantFindSession
|
||||
}
|
||||
if scene.Entry == "" {
|
||||
return ErrSceneEntryNotSet
|
||||
}
|
||||
if _, ok := scene.Steps[scene.Entry]; !ok {
|
||||
return ErrSceneStepNotFound
|
||||
}
|
||||
|
||||
session := SceneSession{
|
||||
Scene: scene.Name,
|
||||
Step: scene.Entry,
|
||||
}
|
||||
|
||||
return ctx.sceneRuntime.setSession(key, session)
|
||||
}
|
||||
|
||||
// EnterSceneStep enters the named scene at a specific step.
|
||||
func (ctx *MsgContext) EnterSceneStep(name, step string) error {
|
||||
if ctx.sceneRuntime == nil {
|
||||
return ErrSceneRuntimeNil
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// ExitScene leaves the currently active scene for this context.
|
||||
func (ctx *MsgContext) ExitScene() error {
|
||||
if ctx.sceneRuntime == nil {
|
||||
return ErrSceneRuntimeNil
|
||||
}
|
||||
|
||||
_, 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)
|
||||
}
|
||||
|
||||
+103
-2
@@ -9,8 +9,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.nix13.pw/scuroneko/laniakea/tgapi"
|
||||
"git.nix13.pw/scuroneko/slog"
|
||||
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
||||
"git.scuroneko.dev/scuroneko/slog"
|
||||
)
|
||||
|
||||
func TestAnswerPhotoIncludesDirectMessagesTopicID(t *testing.T) {
|
||||
@@ -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)}},
|
||||
|
||||
+25
-4
@@ -4,10 +4,10 @@ import (
|
||||
"errors"
|
||||
"regexp"
|
||||
|
||||
"git.nix13.pw/scuroneko/extypes"
|
||||
"git.nix13.pw/scuroneko/laniakea/tgapi"
|
||||
"git.nix13.pw/scuroneko/laniakea/utils"
|
||||
"git.nix13.pw/scuroneko/slog"
|
||||
"git.scuroneko.dev/scuroneko/extypes"
|
||||
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
||||
"git.scuroneko.dev/scuroneko/laniakea/utils"
|
||||
"git.scuroneko.dev/scuroneko/slog"
|
||||
)
|
||||
|
||||
// CommandValueType defines the expected type of command argument.
|
||||
@@ -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,25 @@ func (p *Plugin[T]) NewPayload(exec CommandExecutor[T], command string, args ...
|
||||
return cmd
|
||||
}
|
||||
|
||||
// AddScene registers a multi-step scene in the plugin.
|
||||
func (p *Plugin[T]) AddScene(scene *Scene[T]) *Plugin[T] {
|
||||
if scene == nil {
|
||||
return p
|
||||
}
|
||||
scene.PluginName = p.name
|
||||
scene.setPluginName(p.name)
|
||||
p.scenes[scene.Name] = scene
|
||||
return p
|
||||
}
|
||||
|
||||
// NewScene creates, registers, and returns a new scene owned by the plugin.
|
||||
func (p *Plugin[T]) NewScene(name string) *Scene[T] {
|
||||
scene := NewScene[T](name)
|
||||
scene.setPluginName(p.name)
|
||||
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] {
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.nix13.pw/scuroneko/slog"
|
||||
"git.scuroneko.dev/scuroneko/slog"
|
||||
)
|
||||
|
||||
func TestExecRunnersRunsOnetimeSyncRunner(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
package laniakea
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// SceneHandler handles a scene step, scene command, or fallback message.
|
||||
type SceneHandler[T any] func(ctx *SceneContext, db T) (SceneResult, error)
|
||||
|
||||
// Scene defines a multi-step conversational flow.
|
||||
type Scene[T any] struct {
|
||||
// Name identifies the scene in plugin registration and session state.
|
||||
Name string
|
||||
// Scope controls how active scene sessions are keyed and shared.
|
||||
Scope SceneScope
|
||||
// Entry names the first step used by MsgContext.EnterScene.
|
||||
Entry string
|
||||
// PluginName stores the owning plugin name for scene resolution.
|
||||
PluginName string
|
||||
|
||||
steps map[string]SceneHandler[T]
|
||||
commands map[string]SceneHandler[T]
|
||||
message SceneHandler[T]
|
||||
}
|
||||
|
||||
// NewScene creates a new scene with user-chat scope by default.
|
||||
func NewScene[T any](name string) *Scene[T] {
|
||||
return &Scene[T]{
|
||||
Name: name,
|
||||
Scope: SceneScopeUserChat,
|
||||
Entry: "",
|
||||
steps: make(map[string]SceneHandler[T]),
|
||||
commands: make(map[string]SceneHandler[T]),
|
||||
message: nil,
|
||||
}
|
||||
}
|
||||
|
||||
// SetScope changes how scene sessions are keyed and shared.
|
||||
func (s *Scene[T]) SetScope(scope SceneScope) *Scene[T] {
|
||||
s.Scope = scope
|
||||
return s
|
||||
}
|
||||
|
||||
// SetEntry sets the initial step entered by MsgContext.EnterScene.
|
||||
func (s *Scene[T]) SetEntry(step string) *Scene[T] {
|
||||
s.Entry = step
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Scene[T]) setPluginName(name string) *Scene[T] {
|
||||
s.PluginName = name
|
||||
return s
|
||||
}
|
||||
|
||||
// OnStep registers a handler for a named scene step.
|
||||
func (s *Scene[T]) OnStep(step string, handler SceneHandler[T]) *Scene[T] {
|
||||
s.steps[step] = handler
|
||||
return s
|
||||
}
|
||||
|
||||
// OnCommand registers a command handler active while the scene is running.
|
||||
func (s *Scene[T]) OnCommand(cmd string, handler SceneHandler[T]) *Scene[T] {
|
||||
s.commands[cmd] = handler
|
||||
return s
|
||||
}
|
||||
|
||||
// OnMessage registers a fallback handler used when no scene command or step matches.
|
||||
func (s *Scene[T]) OnMessage(handler SceneHandler[T]) *Scene[T] {
|
||||
s.message = handler
|
||||
return s
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// SceneSession stores the active scene state for one session key.
|
||||
type SceneSession struct {
|
||||
// Scene is the registered scene name for the active session.
|
||||
Scene string
|
||||
// Step is the current step name inside the active scene.
|
||||
Step string
|
||||
// Data stores opaque session payload bytes, typically JSON.
|
||||
Data []byte
|
||||
}
|
||||
|
||||
// SetData stores arbitrary opaque session data.
|
||||
func (s *SceneSession) SetData(data []byte) {
|
||||
s.Data = data
|
||||
}
|
||||
|
||||
// GetData returns the raw session data payload.
|
||||
func (s *SceneSession) GetData() []byte {
|
||||
return s.Data
|
||||
}
|
||||
|
||||
// HasData reports whether the session has a non-empty data payload.
|
||||
func (s *SceneSession) HasData() bool {
|
||||
return len(s.Data) > 0
|
||||
}
|
||||
|
||||
// ClearData removes any stored session data.
|
||||
func (s *SceneSession) ClearData() {
|
||||
s.Data = nil
|
||||
}
|
||||
|
||||
// BindData unmarshals the stored JSON payload into v.
|
||||
func (s *SceneSession) BindData(v any) error {
|
||||
if len(s.Data) == 0 {
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(s.Data, v)
|
||||
}
|
||||
|
||||
// SaveData marshals v as JSON and stores it in the session.
|
||||
func (s *SceneSession) SaveData(v any) error {
|
||||
data, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.Data = data
|
||||
return nil
|
||||
}
|
||||
|
||||
// SessionStore persists scene sessions by key.
|
||||
type SessionStore interface {
|
||||
Get(key string) (SceneSession, error)
|
||||
Set(key string, session SceneSession) error
|
||||
Delete(key string) error
|
||||
}
|
||||
|
||||
// MemorySessionStore stores scene sessions in memory.
|
||||
type MemorySessionStore struct {
|
||||
store map[string]SceneSession
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewMemorySessionStore creates an empty in-memory session store.
|
||||
func NewMemorySessionStore() *MemorySessionStore {
|
||||
return &MemorySessionStore{
|
||||
store: make(map[string]SceneSession),
|
||||
}
|
||||
}
|
||||
|
||||
// Get returns the session stored under key, or the zero session when absent.
|
||||
func (s *MemorySessionStore) Get(key string) (SceneSession, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
if session, ok := s.store[key]; ok {
|
||||
return session, nil
|
||||
}
|
||||
return SceneSession{}, nil
|
||||
}
|
||||
|
||||
// Set stores session under key.
|
||||
func (s *MemorySessionStore) Set(key string, session SceneSession) error {
|
||||
s.mu.Lock()
|
||||
s.store[key] = session
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes the session stored under key.
|
||||
func (s *MemorySessionStore) Delete(key string) error {
|
||||
s.mu.Lock()
|
||||
delete(s.store, key)
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// SceneResult describes how scene execution should proceed after a handler returns.
|
||||
type SceneResult struct {
|
||||
Action SceneAction
|
||||
Next string
|
||||
}
|
||||
|
||||
// SceneAction controls how the bot updates scene state after a handler returns.
|
||||
type SceneAction int
|
||||
|
||||
const (
|
||||
// SceneActionStay keeps the current scene and step active.
|
||||
SceneActionStay SceneAction = iota
|
||||
// SceneActionNext moves the session to another named step.
|
||||
SceneActionNext
|
||||
// SceneActionExit removes the current scene session.
|
||||
SceneActionExit
|
||||
// SceneActionPass lets normal bot routing continue after the scene handler.
|
||||
SceneActionPass
|
||||
)
|
||||
|
||||
// SceneScope defines how scene sessions are keyed.
|
||||
type SceneScope int
|
||||
|
||||
const (
|
||||
// SceneScopeUser shares a scene across all chats for one user.
|
||||
SceneScopeUser SceneScope = iota
|
||||
// SceneScopeChat shares a scene across all users in one chat.
|
||||
SceneScopeChat
|
||||
// SceneScopeUserChat isolates a scene per user-chat pair.
|
||||
SceneScopeUserChat
|
||||
)
|
||||
|
||||
type sceneRuntime interface {
|
||||
findScene(name string) (*sceneMeta, bool)
|
||||
getSession(key string) (SceneSession, error)
|
||||
setSession(key string, session SceneSession) error
|
||||
deleteSession(key string) error
|
||||
buildSceneKey(scope SceneScope, ctx *MsgContext) (string, bool)
|
||||
findSceneSession(ctx *MsgContext) (string, SceneSession, error)
|
||||
}
|
||||
|
||||
type sceneMeta struct {
|
||||
Name string
|
||||
Scope SceneScope
|
||||
Entry string
|
||||
Steps map[string]struct{}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package laniakea
|
||||
|
||||
// SceneContext wraps MsgContext with scene session state for scene handlers.
|
||||
type SceneContext struct {
|
||||
*MsgContext
|
||||
sess SceneSession
|
||||
key string
|
||||
}
|
||||
|
||||
// Next advances the current scene to step.
|
||||
func (ctx *SceneContext) Next(step string) SceneResult {
|
||||
return SceneResult{
|
||||
Action: SceneActionNext,
|
||||
Next: step,
|
||||
}
|
||||
}
|
||||
|
||||
// Stay keeps the current scene step active.
|
||||
func (ctx *SceneContext) Stay() SceneResult {
|
||||
return SceneResult{Action: SceneActionStay}
|
||||
}
|
||||
|
||||
// Exit leaves the current scene.
|
||||
func (ctx *SceneContext) Exit() SceneResult {
|
||||
return SceneResult{Action: SceneActionExit}
|
||||
}
|
||||
|
||||
// Pass stops scene handling and lets normal routing continue.
|
||||
func (ctx *SceneContext) Pass() SceneResult {
|
||||
return SceneResult{Action: SceneActionPass}
|
||||
}
|
||||
|
||||
// BindData unmarshals the current scene session payload into v.
|
||||
func (ctx *SceneContext) BindData(v any) error {
|
||||
return ctx.sess.BindData(v)
|
||||
}
|
||||
|
||||
// SaveData marshals v and stores it in the current scene session payload.
|
||||
func (ctx *SceneContext) SaveData(v any) error {
|
||||
return ctx.sess.SaveData(v)
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
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
|
||||
}
|
||||
if !plugin.executeMiddlewares(ctx, bot.dbContext) {
|
||||
return false, nil
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
var text string
|
||||
if ctx.Msg != 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) {
|
||||
if ctx == nil {
|
||||
return "", false
|
||||
}
|
||||
|
||||
switch scope {
|
||||
case SceneScopeUserChat:
|
||||
if ctx.Msg == nil || ctx.Msg.Chat == nil || ctx.FromID == 0 {
|
||||
return "", false
|
||||
}
|
||||
return fmt.Sprintf("user_id:%d:chat_id:%d", ctx.FromID, ctx.Msg.Chat.ID), true
|
||||
case SceneScopeChat:
|
||||
if ctx.Msg == nil || ctx.Msg.Chat == nil {
|
||||
return "", false
|
||||
}
|
||||
return fmt.Sprintf("chat_id:%d", ctx.Msg.Chat.ID), true
|
||||
case SceneScopeUser:
|
||||
if ctx.FromID == 0 {
|
||||
return "", false
|
||||
}
|
||||
return fmt.Sprintf("user_id:%d", ctx.FromID), true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
+468
@@ -0,0 +1,468 @@
|
||||
package laniakea
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
||||
"git.scuroneko.dev/scuroneko/slog"
|
||||
)
|
||||
|
||||
type failingSessionStore struct {
|
||||
getErr error
|
||||
setErr error
|
||||
deleteErr error
|
||||
}
|
||||
|
||||
func (s failingSessionStore) Get(key string) (SceneSession, error) {
|
||||
return SceneSession{}, s.getErr
|
||||
}
|
||||
|
||||
func (s failingSessionStore) Set(key string, session SceneSession) error {
|
||||
return s.setErr
|
||||
}
|
||||
|
||||
func (s failingSessionStore) Delete(key string) error {
|
||||
return s.deleteErr
|
||||
}
|
||||
|
||||
func TestPluginAddSceneRegistersScene(t *testing.T) {
|
||||
plugin := NewPlugin[NoDB]("wizard")
|
||||
scene := NewScene[NoDB]("signup")
|
||||
|
||||
plugin.AddScene(scene)
|
||||
|
||||
if got, ok := plugin.scenes["signup"]; !ok || got != scene {
|
||||
t.Fatalf("scene was not registered in plugin: ok=%v got=%p want=%p", ok, got, scene)
|
||||
}
|
||||
if scene.PluginName != "wizard" {
|
||||
t.Fatalf("unexpected plugin name on scene: got %q want %q", scene.PluginName, "wizard")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotAddPluginsPreservesScenesAndHandlesThem(t *testing.T) {
|
||||
called := false
|
||||
|
||||
plugin := NewPlugin[NoDB]("wizard")
|
||||
plugin.NewScene("signup").
|
||||
SetEntry("start").
|
||||
OnStep("start", func(ctx *SceneContext, db NoDB) (SceneResult, error) {
|
||||
called = true
|
||||
if ctx.Text != "hello there" {
|
||||
t.Fatalf("unexpected scene text: got %q want %q", ctx.Text, "hello there")
|
||||
}
|
||||
return ctx.Exit(), nil
|
||||
})
|
||||
|
||||
bot := &Bot[NoDB]{
|
||||
logger: slog.CreateLogger(),
|
||||
prefixes: []string{"/"},
|
||||
sessionStore: NewMemorySessionStore(),
|
||||
sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser},
|
||||
}
|
||||
bot.AddPlugins(plugin)
|
||||
|
||||
sceneMeta, ok := bot.findScene("signup")
|
||||
if !ok {
|
||||
t.Fatal("expected scene metadata to be available after plugin registration")
|
||||
}
|
||||
if sceneMeta.Entry != "start" {
|
||||
t.Fatalf("unexpected scene entry: got %q want %q", sceneMeta.Entry, "start")
|
||||
}
|
||||
|
||||
enterCtx := &MsgContext{
|
||||
Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: string(tgapi.ChatTypePrivate)}},
|
||||
FromID: 42,
|
||||
sceneRuntime: bot,
|
||||
}
|
||||
if err := enterCtx.EnterScene("signup"); err != nil {
|
||||
t.Fatalf("EnterScene returned error: %v", err)
|
||||
}
|
||||
|
||||
bot.handle(context.Background(), &tgapi.Update{
|
||||
UpdateID: 1,
|
||||
Type: tgapi.UpdateTypeMessage,
|
||||
Message: &tgapi.Message{
|
||||
MessageID: 7,
|
||||
Text: "hello there",
|
||||
Chat: &tgapi.Chat{ID: 100, Type: string(tgapi.ChatTypePrivate)},
|
||||
From: &tgapi.User{ID: 42},
|
||||
},
|
||||
})
|
||||
|
||||
if !called {
|
||||
t.Fatal("expected scene step handler to be called")
|
||||
}
|
||||
|
||||
lookupCtx := &MsgContext{
|
||||
Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: string(tgapi.ChatTypePrivate)}},
|
||||
FromID: 42,
|
||||
}
|
||||
if _, session, err := bot.findSceneSession(lookupCtx); err == nil && session.Scene != "" {
|
||||
t.Fatalf("expected scene session to be removed after exit, got %#v", session)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSceneKeyRejectsMissingContextFields(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
scope SceneScope
|
||||
ctx *MsgContext
|
||||
}{
|
||||
{
|
||||
name: "nil context",
|
||||
scope: SceneScopeUserChat,
|
||||
ctx: nil,
|
||||
},
|
||||
{
|
||||
name: "missing message for chat scope",
|
||||
scope: SceneScopeChat,
|
||||
ctx: &MsgContext{},
|
||||
},
|
||||
{
|
||||
name: "missing from id for user scope",
|
||||
scope: SceneScopeUser,
|
||||
ctx: &MsgContext{},
|
||||
},
|
||||
{
|
||||
name: "missing from id for user chat scope",
|
||||
scope: SceneScopeUserChat,
|
||||
ctx: &MsgContext{
|
||||
Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: string(tgapi.ChatTypePrivate)}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if key, ok := buildSceneKey(tt.scope, tt.ctx); ok || key != "" {
|
||||
t.Fatalf("expected invalid scene key, got key=%q ok=%v", key, ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnterSceneRejectsMissingEntryConfiguration(t *testing.T) {
|
||||
t.Run("empty entry", func(t *testing.T) {
|
||||
plugin := NewPlugin[NoDB]("wizard")
|
||||
plugin.NewScene("signup")
|
||||
|
||||
bot := &Bot[NoDB]{
|
||||
logger: slog.CreateLogger(),
|
||||
sessionStore: NewMemorySessionStore(),
|
||||
sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser},
|
||||
}
|
||||
bot.AddPlugins(plugin)
|
||||
|
||||
ctx := &MsgContext{
|
||||
Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: string(tgapi.ChatTypePrivate)}},
|
||||
FromID: 42,
|
||||
sceneRuntime: bot,
|
||||
}
|
||||
|
||||
err := ctx.EnterScene("signup")
|
||||
if !errors.Is(err, ErrSceneEntryNotSet) {
|
||||
t.Fatalf("expected ErrSceneEntryNotSet, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing entry step", func(t *testing.T) {
|
||||
plugin := NewPlugin[NoDB]("wizard")
|
||||
plugin.NewScene("signup").SetEntry("start")
|
||||
|
||||
bot := &Bot[NoDB]{
|
||||
logger: slog.CreateLogger(),
|
||||
sessionStore: NewMemorySessionStore(),
|
||||
sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser},
|
||||
}
|
||||
bot.AddPlugins(plugin)
|
||||
|
||||
ctx := &MsgContext{
|
||||
Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: string(tgapi.ChatTypePrivate)}},
|
||||
FromID: 42,
|
||||
sceneRuntime: bot,
|
||||
}
|
||||
|
||||
err := ctx.EnterScene("signup")
|
||||
if !errors.Is(err, ErrSceneStepNotFound) {
|
||||
t.Fatalf("expected ErrSceneStepNotFound, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSceneContextMethodsRequireRuntime(t *testing.T) {
|
||||
ctx := &MsgContext{}
|
||||
|
||||
if err := ctx.EnterScene("signup"); !errors.Is(err, ErrSceneRuntimeNil) {
|
||||
t.Fatalf("expected ErrSceneRuntimeNil from EnterScene, got %v", err)
|
||||
}
|
||||
if err := ctx.EnterSceneStep("signup", "start"); !errors.Is(err, ErrSceneRuntimeNil) {
|
||||
t.Fatalf("expected ErrSceneRuntimeNil from EnterSceneStep, got %v", err)
|
||||
}
|
||||
if err := ctx.ExitScene(); !errors.Is(err, ErrSceneRuntimeNil) {
|
||||
t.Fatalf("expected ErrSceneRuntimeNil from ExitScene, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSceneCommandHandlerRunsBeforeStep(t *testing.T) {
|
||||
sceneCommandCalled := false
|
||||
stepCalled := false
|
||||
|
||||
plugin := NewPlugin[NoDB]("wizard")
|
||||
plugin.NewScene("signup").
|
||||
SetEntry("start").
|
||||
OnStep("start", func(ctx *SceneContext, db NoDB) (SceneResult, error) {
|
||||
stepCalled = true
|
||||
return ctx.Stay(), nil
|
||||
}).
|
||||
OnCommand("cancel", func(ctx *SceneContext, db NoDB) (SceneResult, error) {
|
||||
sceneCommandCalled = true
|
||||
if ctx.Prefix != "/" {
|
||||
t.Fatalf("unexpected prefix: got %q want /", ctx.Prefix)
|
||||
}
|
||||
if ctx.Text != "right now" {
|
||||
t.Fatalf("unexpected scene command text: got %q want %q", ctx.Text, "right now")
|
||||
}
|
||||
if len(ctx.Args) != 2 || ctx.Args[0] != "right" || ctx.Args[1] != "now" {
|
||||
t.Fatalf("unexpected scene command args: %#v", ctx.Args)
|
||||
}
|
||||
return ctx.Exit(), nil
|
||||
})
|
||||
|
||||
bot := &Bot[NoDB]{
|
||||
logger: slog.CreateLogger(),
|
||||
prefixes: []string{"/"},
|
||||
sessionStore: NewMemorySessionStore(),
|
||||
sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser},
|
||||
}
|
||||
bot.AddPlugins(plugin)
|
||||
|
||||
enterCtx := &MsgContext{
|
||||
Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: string(tgapi.ChatTypePrivate)}},
|
||||
FromID: 42,
|
||||
sceneRuntime: bot,
|
||||
}
|
||||
if err := enterCtx.EnterScene("signup"); err != nil {
|
||||
t.Fatalf("EnterScene returned error: %v", err)
|
||||
}
|
||||
|
||||
bot.handle(context.Background(), &tgapi.Update{
|
||||
UpdateID: 2,
|
||||
Type: tgapi.UpdateTypeMessage,
|
||||
Message: &tgapi.Message{
|
||||
MessageID: 8,
|
||||
Text: "/cancel right now",
|
||||
Chat: &tgapi.Chat{ID: 100, Type: string(tgapi.ChatTypePrivate)},
|
||||
From: &tgapi.User{ID: 42},
|
||||
},
|
||||
})
|
||||
|
||||
if !sceneCommandCalled {
|
||||
t.Fatal("expected scene command handler to be called")
|
||||
}
|
||||
if stepCalled {
|
||||
t.Fatal("expected scene command to short-circuit the scene step")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScenePassDoesNotPersistSessionData(t *testing.T) {
|
||||
commandCalled := false
|
||||
|
||||
plugin := NewPlugin[NoDB]("wizard")
|
||||
plugin.NewCommand(func(ctx *MsgContext, db NoDB) error {
|
||||
commandCalled = true
|
||||
return nil
|
||||
}, "ping")
|
||||
plugin.NewScene("signup").
|
||||
SetEntry("start").
|
||||
OnStep("start", func(ctx *SceneContext, db NoDB) (SceneResult, error) {
|
||||
if err := ctx.SaveData(struct {
|
||||
Value string `json:"value"`
|
||||
}{Value: "changed"}); err != nil {
|
||||
t.Fatalf("SaveData returned error: %v", err)
|
||||
}
|
||||
return ctx.Pass(), nil
|
||||
})
|
||||
|
||||
bot := &Bot[NoDB]{
|
||||
logger: slog.CreateLogger(),
|
||||
prefixes: []string{"/"},
|
||||
sessionStore: NewMemorySessionStore(),
|
||||
sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser},
|
||||
}
|
||||
bot.AddPlugins(plugin)
|
||||
|
||||
enterCtx := &MsgContext{
|
||||
Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: string(tgapi.ChatTypePrivate)}},
|
||||
FromID: 42,
|
||||
sceneRuntime: bot,
|
||||
}
|
||||
if err := enterCtx.EnterScene("signup"); err != nil {
|
||||
t.Fatalf("EnterScene returned error: %v", err)
|
||||
}
|
||||
|
||||
key, ok := buildSceneKey(SceneScopeUserChat, &MsgContext{
|
||||
Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: string(tgapi.ChatTypePrivate)}},
|
||||
FromID: 42,
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("expected scene key to be built")
|
||||
}
|
||||
|
||||
before, err := bot.sessionStore.Get(key)
|
||||
if err != nil {
|
||||
t.Fatalf("Get before handle returned error: %v", err)
|
||||
}
|
||||
if before.HasData() {
|
||||
t.Fatalf("expected empty session data before handle, got %#v", before)
|
||||
}
|
||||
|
||||
bot.handle(context.Background(), &tgapi.Update{
|
||||
UpdateID: 3,
|
||||
Type: tgapi.UpdateTypeMessage,
|
||||
Message: &tgapi.Message{
|
||||
MessageID: 9,
|
||||
Text: "/ping",
|
||||
Chat: &tgapi.Chat{ID: 100, Type: string(tgapi.ChatTypePrivate)},
|
||||
From: &tgapi.User{ID: 42},
|
||||
},
|
||||
})
|
||||
|
||||
if !commandCalled {
|
||||
t.Fatal("expected normal command routing to continue after SceneActionPass")
|
||||
}
|
||||
|
||||
after, err := bot.sessionStore.Get(key)
|
||||
if err != nil {
|
||||
t.Fatalf("Get after handle returned error: %v", err)
|
||||
}
|
||||
if after.Scene != "signup" || after.Step != "start" {
|
||||
t.Fatalf("unexpected session after pass: %#v", after)
|
||||
}
|
||||
if after.HasData() {
|
||||
t.Fatalf("expected SceneActionPass to leave session data unchanged, got %#v", after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSceneMessageFallbackRunsWhenNoCommandOrStepMatch(t *testing.T) {
|
||||
fallbackCalled := false
|
||||
|
||||
plugin := NewPlugin[NoDB]("wizard")
|
||||
plugin.NewScene("signup").
|
||||
SetEntry("start").
|
||||
OnStep("start", func(ctx *SceneContext, db NoDB) (SceneResult, error) {
|
||||
return ctx.Stay(), nil
|
||||
}).
|
||||
OnMessage(func(ctx *SceneContext, db NoDB) (SceneResult, error) {
|
||||
fallbackCalled = true
|
||||
if ctx.Text != "hello fallback" {
|
||||
t.Fatalf("unexpected fallback text: got %q want %q", ctx.Text, "hello fallback")
|
||||
}
|
||||
return ctx.Exit(), nil
|
||||
})
|
||||
|
||||
bot := &Bot[NoDB]{
|
||||
logger: slog.CreateLogger(),
|
||||
prefixes: []string{"/"},
|
||||
sessionStore: NewMemorySessionStore(),
|
||||
sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser},
|
||||
}
|
||||
bot.AddPlugins(plugin)
|
||||
|
||||
enterCtx := &MsgContext{
|
||||
Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: string(tgapi.ChatTypePrivate)}},
|
||||
FromID: 42,
|
||||
sceneRuntime: bot,
|
||||
}
|
||||
if err := enterCtx.EnterScene("signup"); err != nil {
|
||||
t.Fatalf("EnterScene returned error: %v", err)
|
||||
}
|
||||
|
||||
key, ok := buildSceneKey(SceneScopeUserChat, &MsgContext{
|
||||
Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: string(tgapi.ChatTypePrivate)}},
|
||||
FromID: 42,
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("expected scene key to be built")
|
||||
}
|
||||
if err := bot.sessionStore.Set(key, SceneSession{Scene: "signup", Step: "unknown"}); err != nil {
|
||||
t.Fatalf("Set returned error: %v", err)
|
||||
}
|
||||
|
||||
bot.handle(context.Background(), &tgapi.Update{
|
||||
UpdateID: 4,
|
||||
Type: tgapi.UpdateTypeMessage,
|
||||
Message: &tgapi.Message{
|
||||
MessageID: 10,
|
||||
Text: "hello fallback",
|
||||
Chat: &tgapi.Chat{ID: 100, Type: string(tgapi.ChatTypePrivate)},
|
||||
From: &tgapi.User{ID: 42},
|
||||
},
|
||||
})
|
||||
|
||||
if !fallbackCalled {
|
||||
t.Fatal("expected scene fallback handler to be called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindSceneSessionSupportsUserScopeWithoutMessage(t *testing.T) {
|
||||
bot := &Bot[NoDB]{
|
||||
logger: slog.CreateLogger(),
|
||||
sessionStore: NewMemorySessionStore(),
|
||||
sceneScopePriority: []SceneScope{SceneScopeUser, SceneScopeChat, SceneScopeUserChat},
|
||||
}
|
||||
|
||||
if err := bot.sessionStore.Set("user_id:42", SceneSession{Scene: "signup", Step: "start"}); err != nil {
|
||||
t.Fatalf("Set returned error: %v", err)
|
||||
}
|
||||
|
||||
key, session, err := bot.findSceneSession(&MsgContext{FromID: 42})
|
||||
if err != nil {
|
||||
t.Fatalf("findSceneSession returned error: %v", err)
|
||||
}
|
||||
if key != "user_id:42" {
|
||||
t.Fatalf("unexpected session key: got %q want %q", key, "user_id:42")
|
||||
}
|
||||
if session.Scene != "signup" || session.Step != "start" {
|
||||
t.Fatalf("unexpected session: %#v", session)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSceneStoreErrorsPropagate(t *testing.T) {
|
||||
getErr := errors.New("get failed")
|
||||
setErr := errors.New("set failed")
|
||||
|
||||
t.Run("find scene session get error", func(t *testing.T) {
|
||||
bot := &Bot[NoDB]{
|
||||
logger: slog.CreateLogger(),
|
||||
sessionStore: failingSessionStore{getErr: getErr},
|
||||
sceneScopePriority: []SceneScope{SceneScopeUser},
|
||||
}
|
||||
|
||||
_, _, err := bot.findSceneSession(&MsgContext{FromID: 42})
|
||||
if !errors.Is(err, getErr) {
|
||||
t.Fatalf("expected getErr, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("apply scene result set error", func(t *testing.T) {
|
||||
scene := NewScene[NoDB]("signup").OnStep("start", func(ctx *SceneContext, db NoDB) (SceneResult, error) {
|
||||
return ctx.Stay(), nil
|
||||
})
|
||||
bot := &Bot[NoDB]{
|
||||
logger: slog.CreateLogger(),
|
||||
sessionStore: failingSessionStore{setErr: setErr},
|
||||
sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser},
|
||||
}
|
||||
|
||||
_, err := bot.applySceneResult(scene, &SceneContext{
|
||||
MsgContext: &MsgContext{},
|
||||
sess: SceneSession{Scene: "signup", Step: "start"},
|
||||
key: "user_id:42:chat_id:100",
|
||||
}, SceneResult{Action: SceneActionStay})
|
||||
if !errors.Is(err, setErr) {
|
||||
t.Fatalf("expected setErr, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
+2
-2
@@ -9,8 +9,8 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.nix13.pw/scuroneko/laniakea/utils"
|
||||
"git.nix13.pw/scuroneko/slog"
|
||||
"git.scuroneko.dev/scuroneko/laniakea/utils"
|
||||
"git.scuroneko.dev/scuroneko/slog"
|
||||
)
|
||||
|
||||
// APIOpts holds configuration options for initializing the Telegram API client.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package tgapi
|
||||
|
||||
import "git.nix13.pw/scuroneko/extypes"
|
||||
import "git.scuroneko.dev/scuroneko/extypes"
|
||||
|
||||
// MessageID represents a message identifier wrapper returned by some API methods.
|
||||
type MessageID struct {
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"git.nix13.pw/scuroneko/laniakea/utils"
|
||||
"git.scuroneko.dev/scuroneko/laniakea/utils"
|
||||
)
|
||||
|
||||
// UpdateParams holds parameters for the getUpdates method.
|
||||
|
||||
@@ -10,8 +10,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.nix13.pw/scuroneko/laniakea/utils"
|
||||
"git.nix13.pw/scuroneko/slog"
|
||||
"git.scuroneko.dev/scuroneko/laniakea/utils"
|
||||
"git.scuroneko.dev/scuroneko/slog"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -3,7 +3,7 @@ package laniakea
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"git.nix13.pw/scuroneko/laniakea/utils"
|
||||
"git.scuroneko.dev/scuroneko/laniakea/utils"
|
||||
)
|
||||
|
||||
// Ptr returns a pointer to v.
|
||||
|
||||
@@ -6,8 +6,8 @@ import (
|
||||
"mime/multipart"
|
||||
"testing"
|
||||
|
||||
"git.nix13.pw/scuroneko/laniakea/tgapi"
|
||||
"git.nix13.pw/scuroneko/laniakea/utils"
|
||||
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
||||
"git.scuroneko.dev/scuroneko/laniakea/utils"
|
||||
)
|
||||
|
||||
type multipartEncodeParams struct {
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ package utils
|
||||
import (
|
||||
"os"
|
||||
|
||||
"git.nix13.pw/scuroneko/slog"
|
||||
"git.scuroneko.dev/scuroneko/slog"
|
||||
)
|
||||
|
||||
// GetLoggerLevel returns DEBUG when DEBUG=true in env, otherwise FATAL.
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.nix13.pw/scuroneko/slog"
|
||||
"git.scuroneko.dev/scuroneko/slog"
|
||||
)
|
||||
|
||||
func TestCreateFileLoggerWritesToConfiguredFile(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user