From f74496a3e87b6c644bb402974dacf973e783532f Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Mon, 30 Mar 2026 00:12:24 +0300 Subject: [PATCH] Release v1.0.0-rc.12 Finalize scenes, typed arg binding, and request-scoped context plumbing Refresh docs, backlog state, and regression coverage for the rc.12 release --- CHANGELOG.md | 6 + README.md | 49 ++++++- README_RU.md | 49 ++++++- TODO.md | 14 +- bot.go | 16 ++- bot_opts.go | 2 +- bot_opts_test.go | 2 +- bot_scene.go | 3 - bot_test.go | 4 +- cmd_generator.go | 2 +- cmd_generator_test.go | 4 +- drafts.go | 2 +- drafts_test.go | 4 +- errors.go | 5 +- go.mod | 6 +- go.sum | 8 +- handler.go | 2 +- handler_test.go | 4 +- keyboard.go | 4 +- methods.go | 2 +- msg_context.go | 16 ++- msg_context_test.go | 4 +- plugins.go | 8 +- runners_test.go | 2 +- scene.go | 18 ++- scene_handler.go | 18 ++- scene_test.go | 298 +++++++++++++++++++++++++++++++++++++++- tgapi/api.go | 4 +- tgapi/messages_types.go | 2 +- tgapi/methods.go | 2 +- tgapi/uploader_api.go | 4 +- utils.go | 2 +- utils/multipart_test.go | 4 +- utils/utils.go | 2 +- utils/utils_test.go | 2 +- 35 files changed, 493 insertions(+), 81 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f639188..3f5ff97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,11 @@ - `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. +- 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`. @@ -33,6 +37,8 @@ ### 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 diff --git a/README.md b/README.md index 2350721..b550fd5 100644 --- a/README.md +++ b/README.md @@ -4,13 +4,13 @@ [![Go Version](https://img.shields.io/badge/Go-1.24+-00ADD8?logo=go&style=flat-square)](https://go.dev/) [![License: GPL-3.0](https://img.shields.io/badge/License-GPL%203.0-blue.svg?style=flat-square)](LICENSE) -![Gitea Release](https://img.shields.io/gitea/v/release/ScuroNeko/Laniakea?gitea_url=https%3A%2F%2Fgit.nix13.pw&sort=semver&display_name=release&style=flat-square&color=purple&link=https%3A%2F%2Fgit.nix13.pw%2FScuroNeko%2FLaniakea%2Freleases) +![Gitea Release](https://img.shields.io/gitea/v/release/ScuroNeko/Laniakea?gitea_url=https%3A%2F%2Fgit.scuroneko.dev&sort=semver&display_name=release&style=flat-square&color=purple&link=https%3A%2F%2Fgit.scuroneko.dev%2FScuroNeko%2FLaniakea%2Freleases) 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) diff --git a/README_RU.md b/README_RU.md index dbefd88..f038beb 100644 --- a/README_RU.md +++ b/README_RU.md @@ -4,13 +4,13 @@ [![Go Version](https://img.shields.io/badge/Go-1.24+-00ADD8?logo=go&style=flat-square)](https://go.dev/) [![License: GPL-3.0](https://img.shields.io/badge/License-GPL%203.0-blue.svg?style=flat-square)](LICENSE) -![Gitea Release](https://img.shields.io/gitea/v/release/ScuroNeko/Laniakea?gitea_url=https%3A%2F%2Fgit.nix13.pw&sort=semver&display_name=release&style=flat-square&color=purple&link=https%3A%2F%2Fgit.nix13.pw%2FScuroNeko%2FLaniakea%2Freleases) +![Gitea Release](https://img.shields.io/gitea/v/release/ScuroNeko/Laniakea?gitea_url=https%3A%2F%2Fgit.scuroneko.dev&sort=semver&display_name=release&style=flat-square&color=purple&link=https%3A%2F%2Fgit.scuroneko.dev%2FScuroNeko%2FLaniakea%2Freleases) Легковесная, простая в использовании и производительная обёртка для 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) diff --git a/TODO.md b/TODO.md index ba2122f..4d22f15 100644 --- a/TODO.md +++ b/TODO.md @@ -4,14 +4,20 @@ The framework backlog has moved to the wiki. Primary page: -- https://git.nix13.pw/ScuroNeko/Laniakea/wiki/Framework-Backlog +- https://git.scuroneko.dev/ScuroNeko/Laniakea/wiki/Framework-Backlog Russian page: -- https://git.nix13.pw/ScuroNeko/Laniakea/wiki/Framework-Backlog-RU +- https://git.scuroneko.dev/ScuroNeko/Laniakea/wiki/Framework-Backlog-RU -Current high-priority status: +Current priority split: -- `1. Conversation / Scene Model`: work in progress. +- `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. + +Completed former high-priority items: + +- `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`. diff --git a/bot.go b/bot.go index 305d0ed..8163819 100644 --- a/bot.go +++ b/bot.go @@ -12,10 +12,10 @@ import ( "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" ) @@ -350,6 +350,10 @@ func (bot *Bot[T]) GetDraftProvider() *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 } @@ -363,6 +367,10 @@ func (bot *Bot[T]) GetSessionStore() SessionStore { 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 diff --git a/bot_opts.go b/bot_opts.go index d10aba8..83e7413 100644 --- a/bot_opts.go +++ b/bot_opts.go @@ -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. diff --git a/bot_opts_test.go b/bot_opts_test.go index 4c5c7ed..7dc3340 100644 --- a/bot_opts_test.go +++ b/bot_opts_test.go @@ -4,7 +4,7 @@ import ( "reflect" "testing" - "git.nix13.pw/scuroneko/laniakea/tgapi" + "git.scuroneko.dev/scuroneko/laniakea/tgapi" ) func TestLoadOptsFromEnvIgnoresEmptyUpdateTypes(t *testing.T) { diff --git a/bot_scene.go b/bot_scene.go index e5c97ed..2fff340 100644 --- a/bot_scene.go +++ b/bot_scene.go @@ -35,9 +35,6 @@ func (bot *Bot[T]) findScene(name string) (*sceneMeta, bool) { func (bot *Bot[T]) findSceneSession(ctx *MsgContext) (string, SceneSession, error) { var zero SceneSession - if ctx.Msg == nil && ctx.FromID == 0 { - return "", zero, ErrMessageNil - } for _, scope := range bot.sceneScopePriority { key, ok := buildSceneKey(scope, ctx) diff --git a/bot_test.go b/bot_test.go index 8795daa..e1028fb 100644 --- a/bot_test.go +++ b/bot_test.go @@ -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) { diff --git a/cmd_generator.go b/cmd_generator.go index 2bf524a..cf8f572 100644 --- a/cmd_generator.go +++ b/cmd_generator.go @@ -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. diff --git a/cmd_generator_test.go b/cmd_generator_test.go index bbbca03..269ef0a 100644 --- a/cmd_generator_test.go +++ b/cmd_generator_test.go @@ -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) diff --git a/drafts.go b/drafts.go index 1e1c89a..d550c60 100644 --- a/drafts.go +++ b/drafts.go @@ -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. diff --git a/drafts_test.go b/drafts_test.go index 887e19a..3d79e03 100644 --- a/drafts_test.go +++ b/drafts_test.go @@ -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) { diff --git a/errors.go b/errors.go index 0ccfde9..3132155 100644 --- a/errors.go +++ b/errors.go @@ -24,7 +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 = errors.New("message is nil") + // 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. @@ -55,6 +56,8 @@ var ( 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 { diff --git a/go.mod b/go.mod index 7bfdeb5..0399919 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index 72b92c1..6297810 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/handler.go b/handler.go index 99a6759..e93c387 100644 --- a/handler.go +++ b/handler.go @@ -8,7 +8,7 @@ import ( "fmt" "strings" - "git.nix13.pw/scuroneko/laniakea/tgapi" + "git.scuroneko.dev/scuroneko/laniakea/tgapi" ) // ErrInvalidPayloadType is returned when callback payload encoding type is unknown. diff --git a/handler_test.go b/handler_test.go index d5a60e9..efed1cc 100644 --- a/handler_test.go +++ b/handler_test.go @@ -4,8 +4,8 @@ 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) { diff --git a/keyboard.go b/keyboard.go index ab19c67..fc5635e 100644 --- a/keyboard.go +++ b/keyboard.go @@ -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 ( diff --git a/methods.go b/methods.go index e10efba..2a55b77 100644 --- a/methods.go +++ b/methods.go @@ -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. diff --git a/msg_context.go b/msg_context.go index a334439..fd1c5fb 100644 --- a/msg_context.go +++ b/msg_context.go @@ -9,8 +9,8 @@ import ( "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. @@ -646,6 +646,10 @@ func (ctx *MsgContext) Context() context.Context { // 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 @@ -672,6 +676,10 @@ func (ctx *MsgContext) EnterScene(name string) error { // 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 @@ -695,6 +703,10 @@ func (ctx *MsgContext) EnterSceneStep(name, step string) error { // 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 diff --git a/msg_context_test.go b/msg_context_test.go index b7e9fa2..d69ddd4 100644 --- a/msg_context_test.go +++ b/msg_context_test.go @@ -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) { diff --git a/plugins.go b/plugins.go index 3229a22..75783aa 100644 --- a/plugins.go +++ b/plugins.go @@ -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. diff --git a/runners_test.go b/runners_test.go index 7e17d77..ec04a28 100644 --- a/runners_test.go +++ b/runners_test.go @@ -6,7 +6,7 @@ import ( "testing" "time" - "git.nix13.pw/scuroneko/slog" + "git.scuroneko.dev/scuroneko/slog" ) func TestExecRunnersRunsOnetimeSyncRunner(t *testing.T) { diff --git a/scene.go b/scene.go index 7b6263c..8e4132d 100644 --- a/scene.go +++ b/scene.go @@ -10,9 +10,13 @@ type SceneHandler[T any] func(ctx *SceneContext, db T) (SceneResult, error) // Scene defines a multi-step conversational flow. type Scene[T any] struct { - Name string - Scope SceneScope - Entry string // starting step + // 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] @@ -43,6 +47,7 @@ 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 @@ -92,9 +97,12 @@ func (s *Scene[T]) executeMessage(ctx *SceneContext, db T) (SceneResult, bool, e // 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 string - Data []byte + // 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. diff --git a/scene_handler.go b/scene_handler.go index de640a7..ef790b7 100644 --- a/scene_handler.go +++ b/scene_handler.go @@ -26,6 +26,9 @@ func (bot *Bot[T]) tryHandleScene(ctx *MsgContext) (bool, error) { if scene.PluginName != "" && scene.PluginName != plugin.name { continue } + if !plugin.executeMiddlewares(ctx, bot.dbContext) { + return false, nil + } sceneCtx := &SceneContext{ MsgContext: ctx, sess: session, @@ -40,10 +43,15 @@ func (bot *Bot[T]) executeScene(scene *Scene[T], ctx *SceneContext) (bool, error if ctx.MsgContext == nil || ctx.sess.Scene == "" { return false, nil } - text := ctx.Msg.Text - if text == "" { - text = ctx.Msg.Caption + + 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 != "" { @@ -89,7 +97,6 @@ func (bot *Bot[T]) applySceneResult(scene *Scene[T], ctx *SceneContext, result S return false, err } return true, nil - case SceneActionNext: if result.Next == "" { return false, ErrSceneStepNotFound @@ -102,16 +109,13 @@ func (bot *Bot[T]) applySceneResult(scene *Scene[T], ctx *SceneContext, result S 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 } diff --git a/scene_test.go b/scene_test.go index 75bcfb6..394becb 100644 --- a/scene_test.go +++ b/scene_test.go @@ -5,10 +5,28 @@ import ( "errors" "testing" - "git.nix13.pw/scuroneko/laniakea/tgapi" - "git.nix13.pw/scuroneko/slog" + "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") @@ -172,3 +190,279 @@ func TestEnterSceneRejectsMissingEntryConfiguration(t *testing.T) { } }) } + +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) + } + }) +} diff --git a/tgapi/api.go b/tgapi/api.go index 7a22dcb..e5ec1e9 100644 --- a/tgapi/api.go +++ b/tgapi/api.go @@ -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. diff --git a/tgapi/messages_types.go b/tgapi/messages_types.go index 237b156..353da19 100644 --- a/tgapi/messages_types.go +++ b/tgapi/messages_types.go @@ -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 { diff --git a/tgapi/methods.go b/tgapi/methods.go index d9789ae..a13f328 100644 --- a/tgapi/methods.go +++ b/tgapi/methods.go @@ -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. diff --git a/tgapi/uploader_api.go b/tgapi/uploader_api.go index 9692179..5012160 100644 --- a/tgapi/uploader_api.go +++ b/tgapi/uploader_api.go @@ -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 ( diff --git a/utils.go b/utils.go index 7e94ce2..0cb42ac 100644 --- a/utils.go +++ b/utils.go @@ -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. diff --git a/utils/multipart_test.go b/utils/multipart_test.go index 479ed05..a1dc522 100644 --- a/utils/multipart_test.go +++ b/utils/multipart_test.go @@ -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 { diff --git a/utils/utils.go b/utils/utils.go index 144f161..c665508 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -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. diff --git a/utils/utils_test.go b/utils/utils_test.go index 4df68fb..16e9a05 100644 --- a/utils/utils_test.go +++ b/utils/utils_test.go @@ -6,7 +6,7 @@ import ( "strings" "testing" - "git.nix13.pw/scuroneko/slog" + "git.scuroneko.dev/scuroneko/slog" ) func TestCreateFileLoggerWritesToConfiguredFile(t *testing.T) {