REPOSITORY / ScuroNeko/Laniakea

Compare commits

DIFF REPOSITORY

Compare commits

...
6 Commits
Author SHA1 Message Date
ScuroNeko a818174fbf add observer runtime model
Instrument command, payload, update, scene, runner, polling, and policy flows
Add observer regression coverage and mark observability backlog item done
2026-03-31 13:50:21 +03:00
ScuroNeko 140f3397b2 add authorization policies
Introduce first-class policy helpers and composition through middleware

Normalize chat context for more updates and add regression coverage

Document the completed backlog item in changelog, TODO, and wiki
2026-03-30 18:17:28 +03:00
ScuroNeko e2444752c2 rename app data API
Align docs, examples, and tests with AppData and NoData

Update handler, scene, runner, and plugin generics to the new naming

Ignore .codex and record the cleanup in the changelog
2026-03-30 16:43:59 +03:00
ScuroNeko 66eb72cb3c Finalize rc.13 framework contracts
Formalize update routing, config freeze, and error visibility
Add regression coverage and sync backlog records
2026-03-30 01:01:13 +03:00
ScuroNeko f74496a3e8 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
2026-03-30 00:12:24 +03:00
ScuroNeko a4d70e1510 Stabilize scene session skeleton
Hide internal scene runtime methods
Refresh docs, tests, and changelog for scenes
2026-03-28 12:58:05 +00:00
50 changed files with 4401 additions and 910 deletions
+1
View File
@@ -3,3 +3,4 @@
.vscode/ .vscode/
test/ test/
.codex/ .codex/
.codex
+42 -9
View File
@@ -1,5 +1,40 @@
# Changelog # Changelog
## v1.0.0-rc.13
### Added
- `AsUserError(...)`, `AsInternalError(...)`, `IsUserError(...)`, and `IsInternalError(...)` for explicitly marking centralized handler errors as user-visible or internal-only without breaking the existing default error flow.
- `Policy[T]`, `RequirePolicy(...)`, and built-in chat and callback policy helpers for expressing reusable authorization rules through the existing middleware pipeline.
- `Bot.UsePolicy(...)` and `Plugin.UsePolicy(...)` as shorthand for registering policies as middleware.
- `AllPolicies(...)`, `AnyPolicy(...)`, and `NotPolicy(...)` for composing reusable authorization rules without introducing a second execution pipeline.
### Changed
- Bot configuration mutators now treat the bot as configuration-frozen after the first run begins and ignore late mutation attempts for bot-level config such as prefixes, payload defaults, plugins, middleware, runners, localization, scene session wiring, and database context injection.
- `MsgContext` godoc and field comments now describe the normalized update contract more explicitly, including when `Msg`, `From`, callback target fields, `Text`, and `Args` are expected to be populated.
- `MsgContext` normalization now also carries `Chat` and `ChatID` for more Telegram update kinds, allowing policy and update handlers to rely on normalized chat identity outside message-only flows.
- `MsgContext.Error(...)` and returned handler errors now suppress the automatic user reply when the error is explicitly marked with `AsInternalError(...)`, while keeping the previous user-visible default for unclassified errors.
- Godoc, README examples, and regression-test naming now consistently describe the shared generic dependency model as app data, including `NoData` and `SetAppData(...)`.
- Observer configuration now treats `SetObserver(nil)` as clearing instrumentation instead of leaving the previous observer attached.
- Observer lifecycle events now cover generic update handlers and scene command, step, and message-fallback handlers with logical handler names and durations.
- `RequirePolicy(...)` now emits `PolicyCheckedEvent` for both passed and denied policy decisions.
- Scene command, step, and message-fallback flows now emit observer `ErrorEvent`s with scene-specific handler kinds and logical handler names.
- Scene transition observer events now use the same transition payload for scene command, step, and message-fallback flows.
- Observer error emission now also covers generic update handlers, callback payload decode failures, runner failures, and polling retries, including dedicated runner and polling handler kinds in `ErrorEvent`.
- `TODO.md` and the framework backlog pages now mark the observability model as completed for `v1.0.0-rc.13`.
- `tgapi.Chat.Type` now uses the typed `tgapi.ChatType` enum in public DTOs and tests instead of raw string casts.
### Tests
- Added regression coverage for the bot configuration freeze model, including ignored post-run mutations for core bot configuration methods and late registration paths.
- Added table-driven update-contract coverage for `prepareUpdateCtx(...)`, including message-backed, callback-backed, user-backed, and no-user update kinds.
- Added regression tests for policy middleware blocking, built-in private-chat policy decisions, normalized chat identity, and admin checks that use normalized `ChatID` and `FromID`.
- Added regression tests for policy composition semantics, including all-of, any-of, and deny inversion with preserved internal failures.
- Added regression tests for `SetObserver(...)`, `GetObserver()`, and clearing the observer with `SetObserver(nil)`.
- Added observer regression tests for generic update-handler errors, callback payload decode failures, runner failure events, and polling retry emission.
- Added observer regression tests for update and scene handler lifecycle events and `PolicyCheckedEvent` emission.
- Added regression tests proving that `edited_message` and `edited_channel_post` stay out of command routing and continue through generic update handlers.
- Added callback-routing regression tests for both chat-message and inline-message callback targets, including `CallbackQueryId`, `CallbackMsgId`, `InlineMsgId`, and payload-argument guarantees.
- Added regression tests for the new error-visibility model in both message and callback flows, including silent internal-only errors and explicit user-visible callback replies.
## v1.0.0-rc.12 ## v1.0.0-rc.12
### Added ### Added
@@ -8,23 +43,19 @@
- `Bot.GetPayloadType()`, `InlineKeyboard.GetPayloadType()`, and optional strict payload decoding via `BotOpts.StrictPayloadType` / `Bot.SetStrictPayloadType(...)`. - `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. - `MsgContext.BindArgs(...)` for binding positional command arguments into exported struct fields.
- Binding sentinels `ErrBindArgsTargetNotPointer`, `ErrBindArgsTargetNotStruct`, `ErrBindArgsUnsupportedFieldType`, and `ErrBindArgsConversion`. - 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 ### Changed
- `CommandExecutor` now returns `error`, and command, payload, and non-command update handlers now use centralized bot error handling for returned errors. - `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 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 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. - README and README_RU now document scenes, session scopes, scene state helpers, and `SceneActionPass` semantics.
- `AGENTS.md` now also defines a short commit-message format: one summary line plus up to three high-signal detail lines. - `TODO.md` and the framework backlog pages now group the remaining framework work into explicit priority 1, 2, and 3 buckets.
- `AGENTS.md` now explicitly requires each commit-message detail line to be placed on its own new line.
- `AGENTS.md` now also requires commit messages to be emitted as a plain multiline block instead of collapsed prose or list formatting.
- `AGENTS.md` now requires new or expanded project documentation to be maintained in both English and Russian whenever reasonably possible.
- `AGENTS.md` now requires all agent-created commits to be GPG-signed and to fail fast instead of falling back to unsigned commits when signing cannot be completed.
- `AGENTS.md` now also links the wiki backlog flow more tightly to `TODO.md` and `CHANGELOG.md`, requiring draft-wiki confirmation for large new ideas and synchronized completion records for backlog items.
- Added `TODO.md` to track missing framework-level concepts, with detailed notes for scenes, typed handler input, and request-scoped cancellation.
- Payload-type comments and docs now distinguish between the bot's default payload type and keyboard-local overrides. - 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.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. - `MsgContext` reply, edit, callback, delete, action, and draft-limiter paths now use the context accessor instead of reaching into raw internal state.
- `TODO.md` is now a short pointer file, while the detailed framework backlog lives in the wiki as `Framework-Backlog` / `Framework-Backlog-RU`.
- Version constants were bumped to `v1.0.0-rc.12`. - Version constants were bumped to `v1.0.0-rc.12`.
### Fixed ### Fixed
@@ -41,6 +72,8 @@
### Tests ### 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 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 ## v1.0.0-rc.11
+59 -22
View File
@@ -4,13 +4,13 @@
[![Go Version](https://img.shields.io/badge/Go-1.24+-00ADD8?logo=go&style=flat-square)](https://go.dev/) [![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) [![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. 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) [На русском](README_RU.md)
[Wiki](https://git.nix13.pw/ScuroNeko/Laniakea/wiki) [Wiki](https://git.scuroneko.dev/ScuroNeko/Laniakea/wiki)
--- ---
@@ -21,15 +21,15 @@ A lightweight, easy-to-use, and performant Telegram Bot API wrapper for Go. It s
* **Middleware Support:** Run code before or after commands (e.g., logging, access control). * **Middleware Support:** Run code before or after commands (e.g., logging, access control).
* **Automatic Command Generation:** Generate help and command lists automatically. * **Automatic Command Generation:** Generate help and command lists automatically.
* **Built-in Rate Limiting:** Protect your bot from hitting Telegram API limits (supports `retry_after` handling). * **Built-in Rate Limiting:** Protect your bot from hitting Telegram API limits (supports `retry_after` handling).
* **Context-Aware:** Pass custom database or state contexts to your handlers. * **Context-Aware:** Pass custom application data or state contexts to your handlers.
* **Fluent Interface:** Chain methods for clean configuration (e.g., `bot.ErrorTemplate(...).AddPlugins(...)`). * **Configurable API:** Mix `Set...` and `Add...` helpers to configure bots clearly (for example, `bot.SetErrorTemplate(...).AddPlugins(...)`).
--- ---
## 📦 Installation ## 📦 Installation
```bash ```bash
go get git.nix13.pw/scuroneko/laniakea go get git.scuroneko.dev/scuroneko/laniakea
``` ```
or or
@@ -47,14 +47,14 @@ package main
import ( import (
"log" "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. // echo is a command handler function.
// It receives two parameters: // It receives two parameters:
// - ctx: the message context (contains info about the message, sender, chat, etc.) // - ctx: the message context (contains info about the message, sender, chat, etc.)
// - db: your custom database context (here we use NoDB, a placeholder for no database) // - data: your shared application data (here we use NoData, a placeholder for no shared data)
func echo(ctx *laniakea.MsgContext, db laniakea.NoDB) error { func echo(ctx *laniakea.MsgContext, data laniakea.NoData) error {
// Answer the user with the text they sent, without any command prefix. // Answer the user with the text they sent, without any command prefix.
// ctx.Text contains the user's message with the command part stripped off. // ctx.Text contains the user's message with the command part stripped off.
ctx.Answer(ctx.Text) // User input WITHOUT command ctx.Answer(ctx.Text) // User input WITHOUT command
@@ -66,8 +66,8 @@ func main() {
opts := &laniakea.BotOpts{Token: "TOKEN"} opts := &laniakea.BotOpts{Token: "TOKEN"}
// 2. Initialize a new bot instance. // 2. Initialize a new bot instance.
// We use laniakea.NoDB as the database context type (no database needed for this example). // We use laniakea.NoData as the application data type (no shared data needed for this example).
bot, err := laniakea.NewBot[laniakea.NoDB](opts) bot, err := laniakea.NewBot[laniakea.NoData](opts)
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
@@ -76,7 +76,7 @@ func main() {
// 3. Create a new plugin named "ping". // 3. Create a new plugin named "ping".
// Plugins help group related commands and middlewares. // Plugins help group related commands and middlewares.
p := laniakea.NewPlugin[laniakea.NoDB]("ping") p := laniakea.NewPlugin[laniakea.NoData]("ping")
// 4. Add a command to the plugin. // 4. Add a command to the plugin.
// p.NewCommand(echo, "echo") creates a command that triggers the 'echo' function on the "/echo" command. // p.NewCommand(echo, "echo") creates a command that triggers the 'echo' function on the "/echo" command.
@@ -84,15 +84,15 @@ func main() {
// 5. Add another command using an anonymous function (closure). // 5. Add another command using an anonymous function (closure).
// This command simply replies "Pong" when the user sends "/ping". // This command simply replies "Pong" when the user sends "/ping".
p.AddCommand(p.NewCommand(func(ctx *laniakea.MsgContext, db laniakea.NoDB) error { p.AddCommand(p.NewCommand(func(ctx *laniakea.MsgContext, data laniakea.NoData) error {
ctx.Answer("Pong") ctx.Answer("Pong")
return nil return nil
}, "ping")) }, "ping"))
// 6. Configure the bot with a custom error template and add the plugin. // 6. Configure the bot with a custom error template and add the plugin.
// ErrorTemplate sets a format string for errors (where %s will be replaced by the actual error). // SetErrorTemplate sets a format string for errors (where %s will be replaced by the actual error).
// AddPlugins(p) registers our "ping" plugin with the bot. // AddPlugins(p) registers our "ping" plugin with the bot.
bot = bot.ErrorTemplate("Error\n\n%s").AddPlugins(p) bot = bot.SetErrorTemplate("Error\n\n%s").AddPlugins(p)
// 7. Automatically generate commands like /start, /help, and a list of all registered commands. // 7. Automatically generate commands like /start, /help, and a list of all registered commands.
// This is optional but very useful for most bots. // This is optional but very useful for most bots.
@@ -109,11 +109,11 @@ func main() {
### How It Works ### How It Works
1. `BotOpts`: Holds configuration like the API token. 1. `BotOpts`: Holds configuration like the API token.
2. `NewBot[T]`: Creates a bot instance. The type parameter T allows you to pass a custom database context (e.g., *sql.DB) that will be available in all handlers. Use laniakea.NoDB if you don't need it. 2. `NewBot[T]`: Creates a bot instance. The type parameter T allows you to pass custom shared application data (for example, *sql.DB or a service container) that will be available in all handlers. Use laniakea.NoData if you don't need it.
3. `NewPlugin`: Creates a logical group for commands and middlewares. 3. `NewPlugin`: Creates a logical group for commands and middlewares.
4. `AddCommand`: Registers a command. The first argument is the handler function (`func(*MsgContext, T) error`), the second is the command name (without the slash). 4. `AddCommand`: Registers a command. The first argument is the handler function (`func(*MsgContext, T) error`), the second is the command name (without the slash).
5. **Handler Functions**: Receive *MsgContext (message details, methods like Answer) and your custom database context T, and return an error for centralized error handling. 5. **Handler Functions**: Receive *MsgContext (message details, methods like Answer) and your custom application data T, and return an error for centralized error handling.
6. `ErrorTemplate`: Sets a template for error messages. The %s placeholder is replaced by the actual error. 6. `SetErrorTemplate`: Sets a template for error messages. The %s placeholder is replaced by the actual error.
7. `AutoGenerateCommands`: Registers plugin-defined commands with Telegram across the supported scopes. 7. `AutoGenerateCommands`: Registers plugin-defined commands with Telegram across the supported scopes.
8. `Run()`: Starts the bot's update polling loop and returns an error if startup or polling fails. 8. `Run()`: Starts the bot's update polling loop and returns an error if startup or polling fails.
9. A `Bot` instance is single-use. After `Run()` or `RunWithContext()` returns, create a new bot instance for the next session. 9. A `Bot` instance is single-use. After `Run()` or `RunWithContext()` returns, create a new bot instance for the next session.
@@ -168,9 +168,9 @@ This split keeps method intent explicit: JSON-only calls go through `API`, file
For advanced cases, `tgapi.NewRequest(...)` and `tgapi.NewUploaderRequest(...)` remain public as low-level escape hatches. They are intentionally less safe than method-specific helpers: callers must supply the correct Telegram method name and compatible request/response types themselves. For advanced cases, `tgapi.NewRequest(...)` and `tgapi.NewUploaderRequest(...)` remain public as low-level escape hatches. They are intentionally less safe than method-specific helpers: callers must supply the correct Telegram method name and compatible request/response types themselves.
### Database Context ### App Data
The `T` in `NewBot[T]` is a powerful feature. You can pass any type, but shared dependencies such as database pools should usually use a pointer type. The `T` in `NewBot[T]` is a powerful feature. You can pass any type, but shared dependencies such as database pools, service containers, or API clients should usually use a pointer type.
```go ```go
type MyDB struct { /* ... */ } type MyDB struct { /* ... */ }
@@ -179,9 +179,46 @@ bot, err := laniakea.NewBot[*MyDB](opts)
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
bot.DatabaseContext(db) bot.SetAppData(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
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. 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. This project is licensed under the GNU General Public License v3.0 — see the [LICENSE](LICENSE) file for details.
## 📚 Learn More ## 📚 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) [Telegram Bot API](https://core.telegram.org/bots/api)
+59 -22
View File
@@ -4,13 +4,13 @@
[![Go Version](https://img.shields.io/badge/Go-1.24+-00ADD8?logo=go&style=flat-square)](https://go.dev/) [![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) [![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, автоматической генерации команд и встроенному рейтлимитеру. Легковесная, простая в использовании и производительная обёртка для Telegram Bot API на Go. Она упрощает разработку ботов благодаря чистой системе плагинов, поддержке Middleware, автоматической генерации команд и встроенному рейтлимитеру.
[English](README.md) [English](README.md)
[Wiki](https://git.nix13.pw/ScuroNeko/Laniakea/wiki) [Wiki](https://git.scuroneko.dev/ScuroNeko/Laniakea/wiki)
--- ---
@@ -22,15 +22,15 @@
* **Поддержка промежуточных слоёв (Middleware):** Выполняйте код до или после команд (например, логирование, проверка доступа). * **Поддержка промежуточных слоёв (Middleware):** Выполняйте код до или после команд (например, логирование, проверка доступа).
* **Автоматическая генерация команд:** Генерируйте справку и списки команд автоматически. * **Автоматическая генерация команд:** Генерируйте справку и списки команд автоматически.
* **Встроенный ограничитель запросов (Rate Limiter):** Защитите бота от превышения лимитов Telegram API (с обработкой `retry_after`). * **Встроенный ограничитель запросов (Rate Limiter):** Защитите бота от превышения лимитов Telegram API (с обработкой `retry_after`).
* **Контекст данных:** Передавайте свой контекст базы данных или состояния в обработчики. * **Контекст данных:** Передавайте общие данные приложения или state в обработчики.
* **Текучий интерфейс (Fluent Interface):** Стройте цепочки методов для чистой конфигурации (например, `bot.ErrorTemplate(...).AddPlugins(...)`). * **Настраиваемый API:** Комбинируйте `Set...` и `Add...` helper-методы для понятной конфигурации, например `bot.SetErrorTemplate(...).AddPlugins(...)`.
--- ---
## 📦 Установка ## 📦 Установка
```bash ```bash
go get git.nix13.pw/scuroneko/laniakea go get git.scuroneko.dev/scuroneko/laniakea
``` ```
или или
@@ -48,14 +48,14 @@ package main
import ( import (
"log" "log"
"git.nix13.pw/scuroneko/laniakea" // Импортируем библиотеку Laniakea "git.scuroneko.dev/scuroneko/laniakea" // Импортируем библиотеку Laniakea
) )
// echo — это функция-обработчик команды. // echo — это функция-обработчик команды.
// Она получает два параметра: // Она получает два параметра:
// - ctx: контекст сообщения (содержит информацию о сообщении, отправителе, чате и т.д.) // - ctx: контекст сообщения (содержит информацию о сообщении, отправителе, чате и т.д.)
// - db: ваш пользовательский контекст базы данных (здесь мы используем NoDB — заглушку) // - data: ваши общие данные приложения (здесь мы используем NoData — заглушку без общих зависимостей)
func echo(ctx *laniakea.MsgContext, db laniakea.NoDB) error { func echo(ctx *laniakea.MsgContext, data laniakea.NoData) error {
// Отвечаем пользователю текстом, который он прислал, без префикса команды. // Отвечаем пользователю текстом, который он прислал, без префикса команды.
// ctx.Text содержит сообщение пользователя, из которого удалена часть с командой. // ctx.Text содержит сообщение пользователя, из которого удалена часть с командой.
ctx.Answer(ctx.Text) // Ввод пользователя БЕЗ команды ctx.Answer(ctx.Text) // Ввод пользователя БЕЗ команды
@@ -67,8 +67,8 @@ func main() {
opts := &laniakea.BotOpts{Token: "TOKEN"} opts := &laniakea.BotOpts{Token: "TOKEN"}
// 2. Инициализируем новый экземпляр бота. // 2. Инициализируем новый экземпляр бота.
// Используем laniakea.NoDB как тип контекста базы данных (база не нужна для примера). // Используем laniakea.NoData как тип данных приложения (общие зависимости не нужны для примера).
bot, err := laniakea.NewBot[laniakea.NoDB](opts) bot, err := laniakea.NewBot[laniakea.NoData](opts)
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
@@ -77,7 +77,7 @@ func main() {
// 3. Создаём новый плагин с именем "ping". // 3. Создаём новый плагин с именем "ping".
// Плагины помогают группировать связанные команды и промежуточные обработчики. // Плагины помогают группировать связанные команды и промежуточные обработчики.
p := laniakea.NewPlugin[laniakea.NoDB]("ping") p := laniakea.NewPlugin[laniakea.NoData]("ping")
// 4. Добавляем команду в плагин. // 4. Добавляем команду в плагин.
// p.NewCommand(echo, "echo") создаёт команду, которая вызывает функцию 'echo' по команде "/echo". // p.NewCommand(echo, "echo") создаёт команду, которая вызывает функцию 'echo' по команде "/echo".
@@ -85,15 +85,15 @@ func main() {
// 5. Добавляем ещё одну команду, используя анонимную функцию (замыкание). // 5. Добавляем ещё одну команду, используя анонимную функцию (замыкание).
// Эта команда просто отвечает "Pong", когда пользователь отправляет "/ping". // Эта команда просто отвечает "Pong", когда пользователь отправляет "/ping".
p.AddCommand(p.NewCommand(func(ctx *laniakea.MsgContext, db laniakea.NoDB) error { p.AddCommand(p.NewCommand(func(ctx *laniakea.MsgContext, data laniakea.NoData) error {
ctx.Answer("Pong") ctx.Answer("Pong")
return nil return nil
}, "ping")) }, "ping"))
// 6. Настраиваем бота: задаём шаблон ошибки и добавляем плагин. // 6. Настраиваем бота: задаём шаблон ошибки и добавляем плагин.
// ErrorTemplate устанавливает формат для сообщений об ошибках (где %s будет заменён на текст ошибки). // SetErrorTemplate устанавливает формат для сообщений об ошибках (где %s будет заменён на текст ошибки).
// AddPlugins(p) регистрирует наш плагин "ping" в боте. // AddPlugins(p) регистрирует наш плагин "ping" в боте.
bot = bot.ErrorTemplate("Ошибка\n\n%s").AddPlugins(p) bot = bot.SetErrorTemplate("Ошибка\n\n%s").AddPlugins(p)
// 7. Автоматически генерируем команды, такие как /start, /help и список всех зарегистрированных команд. // 7. Автоматически генерируем команды, такие как /start, /help и список всех зарегистрированных команд.
// Это необязательно, но очень полезно для большинства ботов. // Это необязательно, но очень полезно для большинства ботов.
@@ -110,11 +110,11 @@ func main() {
### Как это работает ### Как это работает
1. `BotOpts`: Содержит конфигурацию, например, токен API. 1. `BotOpts`: Содержит конфигурацию, например, токен API.
2. `NewBot[T]`: Создаёт экземпляр бота. Параметр типа T позволяет передать пользовательский контекст базы данных (например, *sql.DB), который будет доступен во всех обработчиках. Используйте laniakea.NoDB, если он не нужен. 2. `NewBot[T]`: Создаёт экземпляр бота. Параметр типа T позволяет передать общие данные приложения (например, *sql.DB или контейнер сервисов), которые будут доступны во всех обработчиках. Используйте laniakea.NoData, если они не нужны.
3. `NewPlugin`: Создаёт логическую группу для команд и Middleware. 3. `NewPlugin`: Создаёт логическую группу для команд и Middleware.
4. `AddCommand`: Регистрирует команду. Первый аргумент — функция-обработчик (`func(*MsgContext, T) error`), второй — имя команды (без слеша). 4. `AddCommand`: Регистрирует команду. Первый аргумент — функция-обработчик (`func(*MsgContext, T) error`), второй — имя команды (без слеша).
5. **Функции-обработчики**: Получают *MsgContext (детали сообщения, методы типа Answer) и ваш контекст базы данных T, а ошибку возвращают для централизованной обработки. 5. **Функции-обработчики**: Получают *MsgContext (детали сообщения, методы типа Answer) и ваши данные приложения типа T, а ошибку возвращают для централизованной обработки.
6. `ErrorTemplate`: Устанавливает шаблон для сообщений об ошибках. Плейсхолдер %s заменяется на текст ошибки. 6. `SetErrorTemplate`: Устанавливает шаблон для сообщений об ошибках. Плейсхолдер %s заменяется на текст ошибки.
7. `AutoGenerateCommands`: Регистрирует команды из плагинов в Telegram для поддерживаемых scope. 7. `AutoGenerateCommands`: Регистрирует команды из плагинов в Telegram для поддерживаемых scope.
8. `Run()`: Запускает цикл опроса обновлений бота и возвращает ошибку, если старт или polling завершился неуспешно. 8. `Run()`: Запускает цикл опроса обновлений бота и возвращает ошибку, если старт или polling завершился неуспешно.
9. Экземпляр `Bot` одноразовый. После завершения `Run()` или `RunWithContext()` для следующего запуска создавайте новый бот. 9. Экземпляр `Bot` одноразовый. После завершения `Run()` или `RunWithContext()` для следующего запуска создавайте новый бот.
@@ -157,8 +157,8 @@ func myHandler(ctx *laniakea.MsgContext, db *MyDB) error {
- Поля: `Text`, `Args`, `From`, `FromID`, `Msg`, `InlineMsgId`, `CallbackQueryId` и другие. - Поля: `Text`, `Args`, `From`, `FromID`, `Msg`, `InlineMsgId`, `CallbackQueryId` и другие.
- И много других методов и полей! - И много других методов и полей!
### Контекст базы данных (Database Context) ### App Data
Параметр типа `T` в `NewBot[T]` — мощная функция. Вы можете передать любой тип, но для разделяемых зависимостей вроде пула соединений с БД обычно стоит использовать pointer type. Параметр типа `T` в `NewBot[T]` — мощная возможность. Вы можете передать любой тип, но для разделяемых зависимостей вроде пула соединений с БД, контейнера сервисов или API-клиента обычно стоит использовать pointer type.
```go ```go
type MyDB struct { /* ... */ } type MyDB struct { /* ... */ }
@@ -167,9 +167,46 @@ bot, err := laniakea.NewBot[*MyDB](opts)
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
bot.DatabaseContext(db) bot.SetAppData(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: API и Uploader
В `tgapi` есть два клиента: В `tgapi` есть два клиента:
@@ -243,9 +280,9 @@ func adminOnlyMiddleware(ctx *laniakea.MsgContext, db *MyDB) bool {
Этот проект лицензирован под GNU General Public License v3.0 - подробности см. в файле [LICENSE](LICENSE). Этот проект лицензирован под 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) [Telegram Bot API](https://core.telegram.org/bots/api)
+16 -6
View File
@@ -4,14 +4,24 @@ The framework backlog has moved to the wiki.
Primary page: Primary page:
- https://git.nix13.pw/ScuroNeko/Laniakea/wiki/Framework-Backlog - https://git.scuroneko.dev/ScuroNeko/Laniakea/wiki/Framework-Backlog
Russian page: 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`: not implemented yet. - `Priority 2`: service layer and dependency graph model.
- `2. Typed Handler Input Model`: completed in `v1.0.0-rc.12`. - `Partial`: webhook runtime model, plugin composition contract.
- `3. Request Context / Cancellation Model`: completed in `v1.0.0-rc.12`.
Completed former high-priority items:
- `[v1.0.0-rc.13] Observability model`: added first-class `Observer` events for update, command, payload, scene, policy, runner, polling, and centralized error flows, with safe event dispatch and regression coverage for the new runtime hooks.
- `[v1.0.0-rc.13] Authorization and policy model`: added first-class `Policy[T]`, middleware integration through `RequirePolicy(...)`, plugin and bot policy registration helpers, built-in Telegram-aware policies, and composable `AllPolicies(...)`, `AnyPolicy(...)`, and `NotPolicy(...)` helpers with regression coverage.
- `[v1.0.0-rc.13] Update schema contract`: documented and tested the normalized `MsgContext` update-routing contract, including routing categories and per-update field guarantees.
- `[v1.0.0-rc.13] User-facing vs internal error model`: added explicit user-visible vs internal-only error markers and updated centralized handler error routing accordingly.
- `[v1.0.0-rc.13] Configuration freeze model`: formalized bot configuration freeze after first run, documented lifecycle commit points, and added regression coverage for ignored late mutations.
- `[v1.0.0-rc.12] Conversation / Scene Model`.
- `[v1.0.0-rc.12] Typed Handler Input Model`.
- `[v1.0.0-rc.12] Request Context / Cancellation Model`.
+115 -351
View File
@@ -6,21 +6,22 @@ import (
"fmt" "fmt"
"maps" "maps"
"reflect" "reflect"
"slices"
"sort"
"strings" "strings"
"sync" "sync"
"time" "time"
"git.nix13.pw/scuroneko/extypes" "git.scuroneko.dev/scuroneko/extypes"
"git.nix13.pw/scuroneko/laniakea/tgapi" "git.scuroneko.dev/scuroneko/laniakea/tgapi"
"git.nix13.pw/scuroneko/laniakea/utils" "git.scuroneko.dev/scuroneko/laniakea/utils"
"git.nix13.pw/scuroneko/slog" "git.scuroneko.dev/scuroneko/slog"
"github.com/alitto/pond/v2" "github.com/alitto/pond/v2"
) )
// DbContext is the generic dependency type injected into bots, plugins, and handlers. // AppData is the generic shared application data type injected into bots,
// Use it for shared application state such as database handles or service containers. // plugins, and handlers.
//
// Use it for long-lived shared dependencies such as database handles, service
// containers, API clients, or immutable configuration snapshots.
// //
// Example: // Example:
// //
@@ -30,18 +31,22 @@ import (
// if err != nil { // if err != nil {
// return err // return err
// } // }
// bot.DatabaseContext(myDB) // bot.SetAppData(myDB)
// //
// Use NoDB if no database is needed. // Use NoData if no shared application data is needed.
type DbContext any type AppData any
// NoDB is a placeholder type for bots that do not use a database. // NoData is a placeholder type for bots that do not use shared application
// Use Bot[NoDB] to indicate no dependency injection is required. // data.
type NoDB struct{ DbContext } //
// Use Bot[NoData] to indicate no shared dependency injection is required.
type NoData struct{ AppData }
// DbLogger is a function type that returns a slog.LoggerWriter for database logging. // AppDataLogger builds a slog.LoggerWriter from injected application data.
// Used to inject database-specific log output (e.g., SQL queries, ORM events). //
type DbLogger[T DbContext] func(db T) slog.LoggerWriter // Use it when shared application data exposes a log sink or adapter that should
// receive framework logs.
type AppDataLogger[T AppData] func(data T) slog.LoggerWriter
// BotPayloadType defines the serialization format for callback data payloads. // BotPayloadType defines the serialization format for callback data payloads.
type BotPayloadType string type BotPayloadType string
@@ -78,7 +83,7 @@ var (
// //
// Runtime accessors are safe for concurrent use. Configure the bot before Run. // Runtime accessors are safe for concurrent use. Configure the bot before Run.
// A Bot is single-use: after Run or RunWithContext returns, create a new Bot for the next session. // A Bot is single-use: after Run or RunWithContext returns, create a new Bot for the next session.
type Bot[T DbContext] struct { type Bot[T AppData] struct {
token string token string
debug bool debug bool
errorTemplate string errorTemplate string
@@ -98,11 +103,13 @@ type Bot[T DbContext] struct {
api *tgapi.API // Telegram API client api *tgapi.API // Telegram API client
uploader *tgapi.Uploader // File uploader uploader *tgapi.Uploader // File uploader
dbContext T // Injected database context l10n *L10n // Localization manager
hasDBContext bool draftProvider *DraftProvider // Draft message builder
warnedValueDB bool observer Observer // Optional event observer for instrumentation
l10n *L10n // Localization manager
draftProvider *DraftProvider // Draft message builder appData T // Injected application data
hasAppData bool
warnedValueData bool
sessionStore SessionStore // Session store for scene management sessionStore SessionStore // Session store for scene management
sceneScopePriority []SceneScope sceneScopePriority []SceneScope
@@ -118,6 +125,18 @@ type Bot[T DbContext] struct {
ran bool ran bool
} }
func (bot *Bot[T]) configMutable(method string) bool {
bot.runStateMu.Lock()
defer bot.runStateMu.Unlock()
if !bot.ran {
return true
}
if bot.logger != nil {
bot.logger.Warnln(fmt.Sprintf("%s called after bot configuration was frozen; ignoring", method))
}
return false
}
// NewBot creates and initializes a new Bot instance using the provided BotOpts. // NewBot creates and initializes a new Bot instance using the provided BotOpts.
// //
// Automatically: // Automatically:
@@ -262,38 +281,6 @@ func (bot *Bot[T]) CloseRemote(ctx context.Context) error {
return nil return nil
} }
// Internal logger setup for the bot and optional request logger.
func (bot *Bot[T]) initLoggers(opts *BotOpts) {
level := slog.FATAL
if opts.Debug {
level = slog.DEBUG
}
bot.logger = utils.CreateLogger("BOT", level)
if opts.WriteToFile {
path := fmt.Sprintf("%s/main.log", strings.TrimRight(opts.LoggerBasePath, "/"))
logger, err := utils.CreateFileLogger("BOT", level, path)
if err != nil {
bot.logger.Errorln(err)
} else {
bot.logger = logger
}
}
if opts.UseRequestLogger {
bot.RequestLogger = utils.CreateLogger("REQUESTS", level)
if opts.WriteToFile {
path := fmt.Sprintf("%s/requests.log", strings.TrimRight(opts.LoggerBasePath, "/"))
logger, err := utils.CreateFileLogger("REQUESTS", level, path)
if err != nil {
bot.logger.Errorln(err)
} else {
bot.RequestLogger = logger
}
}
}
}
// GetUpdateOffset returns the current update offset (thread-safe). // GetUpdateOffset returns the current update offset (thread-safe).
func (bot *Bot[T]) GetUpdateOffset() int { func (bot *Bot[T]) GetUpdateOffset() int {
bot.updateOffsetMu.Lock() bot.updateOffsetMu.Lock()
@@ -308,18 +295,9 @@ func (bot *Bot[T]) SetUpdateOffset(offset int) {
bot.updateOffset = offset bot.updateOffset = offset
} }
// GetUpdateTypes returns the list of update types the bot is configured to receive.
func (bot *Bot[T]) GetUpdateTypes() []tgapi.UpdateType {
return append([]tgapi.UpdateType(nil), bot.updateTypes...)
}
// GetLogger returns the main bot logger. // GetLogger returns the main bot logger.
func (bot *Bot[T]) GetLogger() *slog.Logger { return bot.logger } func (bot *Bot[T]) GetLogger() *slog.Logger { return bot.logger }
// GetDBContext returns the injected database context.
// If DatabaseContext was not called, it returns the zero value of T.
func (bot *Bot[T]) GetDBContext() T { return bot.dbContext }
// GetLoggerLevel returns the effective log level derived from the bot's debug // GetLoggerLevel returns the effective log level derived from the bot's debug
// flag. // flag.
func (bot *Bot[T]) GetLoggerLevel() slog.LogLevel { func (bot *Bot[T]) GetLoggerLevel() slog.LogLevel {
@@ -336,281 +314,6 @@ func (bot *Bot[T]) L10n(lang, key string) string {
return bot.l10n.Translate(lang, key) return bot.l10n.Translate(lang, key)
} }
// SetDraftProvider replaces the default DraftProvider with a custom one.
// Useful for using LinearDraftIdGenerator to persist draft IDs across restarts.
func (bot *Bot[T]) SetDraftProvider(p *DraftProvider) *Bot[T] {
bot.draftProvider = p
return bot
}
func (bot *Bot[T]) GetDraftProvider() *DraftProvider {
return bot.draftProvider
}
func (bot *Bot[T]) SetSettionStore(store SessionStore) *Bot[T] {
bot.sessionStore = store
return bot
}
func (bot *Bot[T]) GetSessionStore() SessionStore {
return bot.sessionStore
}
func (bot *Bot[T]) SetSceneScopePriority(priority []SceneScope) *Bot[T] {
newPriority := make([]SceneScope, 0, 3)
for _, scope := range priority {
if slices.Index(newPriority, scope) >= 0 {
bot.logger.Warnln(fmt.Sprintf("duplicate scope %v in scene scope priority; ignoring duplicates", scope))
continue
}
newPriority = append(newPriority, scope)
}
if len(newPriority) == 0 || len(newPriority) > 3 {
bot.logger.Warnln("scene scope priority must have 1 to 3 scopes; ignoring invalid input")
return bot
}
bot.sceneScopePriority = append([]SceneScope(nil), newPriority...)
return bot
}
// DatabaseContext injects a database context into the bot.
// This context is accessible to plugins and middleware via GetDBContext().
// For shared dependencies such as *sql.DB, prefer using a pointer type as T.
// Value-typed contexts are supported, but the bot warns once because handlers
// receive T by value.
func (bot *Bot[T]) DatabaseContext(ctx T) *Bot[T] {
if !bot.warnedValueDB && shouldWarnOnValueDBContext[T]() && bot.logger != nil {
bot.logger.Warnln("database context uses a value type; shared dependencies should usually use a pointer type as T")
bot.warnedValueDB = true
}
bot.dbContext = ctx
bot.hasDBContext = true
return bot
}
// UpdateTypes sets the list of update types the bot will request from Telegram.
// Overwrites any previously set types.
func (bot *Bot[T]) UpdateTypes(t ...tgapi.UpdateType) *Bot[T] {
bot.updateTypes = make([]tgapi.UpdateType, 0)
bot.updateTypes = append(bot.updateTypes, t...)
return bot
}
// SetPayloadType sets the default payload encoding type used for callback data.
// JSON stores payload as a string: `{"cmd":"command","args":[...]}`.
// Base64 stores the same JSON encoded as a Base64URL string.
// InlineKeyboard.SetPayloadType may override this value for an individual keyboard.
func (bot *Bot[T]) SetPayloadType(t BotPayloadType) *Bot[T] {
bot.payloadType = t
return bot
}
// GetPayloadType returns the bot's default callback payload encoding type.
func (bot *Bot[T]) GetPayloadType() BotPayloadType { return bot.payloadType }
// SetStrictPayloadType enables or disables strict callback payload decoding.
// When enabled, callback payloads must match the bot's default payload type.
func (bot *Bot[T]) SetStrictPayloadType(strict bool) *Bot[T] {
bot.strictPayloadType = strict
return bot
}
// AddUpdateType adds one or more update types to the list.
// Does not overwrite existing types.
func (bot *Bot[T]) AddUpdateType(t ...tgapi.UpdateType) *Bot[T] {
bot.updateTypes = append(bot.updateTypes, t...)
return bot
}
// AddPrefixes adds one or more command prefixes (e.g., "/", "!").
// Must have at least one prefix before Run().
func (bot *Bot[T]) AddPrefixes(prefixes ...string) *Bot[T] {
bot.prefixes = append(bot.prefixes, prefixes...)
return bot
}
// ErrorTemplate sets the format string for error messages sent to users.
// Use "%s" to insert the error message.
// Example: "❌ Error: %s" → "❌ Error: Command not found".
func (bot *Bot[T]) ErrorTemplate(s string) *Bot[T] {
bot.errorTemplate = s
return bot
}
// Debug enables or disables debug logging.
func (bot *Bot[T]) Debug(debug bool) *Bot[T] {
bot.debug = debug
level := slog.FATAL
if debug {
level = slog.DEBUG
}
bot.logger.Level(level)
if bot.RequestLogger != nil {
bot.RequestLogger.Level(level)
}
for _, p := range bot.plugins {
if p.logger == nil {
continue
}
p.logger.Level(level)
}
return bot
}
// AddPlugins registers one or more plugins.
// Plugins are executed in registration order unless filtered by middleware.
//
// Registration is a commit point for plugin configuration. The Bot stores
// plugin metadata internally, so plugins must be fully configured before they
// are passed here. Post-registration mutation through the original *Plugin is
// not a supported API, even if some changes appear to work due to shared maps.
func (bot *Bot[T]) AddPlugins(plugin ...*Plugin[T]) *Bot[T] {
level := bot.GetLoggerLevel()
for _, p := range plugin {
if p == nil {
if bot.logger != nil {
bot.logger.Warn("nil plugin skipped")
}
continue
}
cloned := clonePlugin(p)
if cloned.logger == nil {
cloned.logger = utils.CreateLogger(cloned.name, level)
}
bot.plugins = append(bot.plugins, cloned)
if bot.logger != nil {
bot.logger.Debugln(fmt.Sprintf("plugins with name \"%s\" registered", cloned.name))
}
}
return bot
}
// AddMiddleware registers one or more middleware handlers.
//
// Middleware are executed in order of increasing .order value before plugins.
// If two middleware have the same order, they are sorted lexicographically by name.
//
// Middleware can:
// - Modify or reject updates before they reach plugins
// - Inject context (e.g., user auth state, rate limit status)
// - Log, validate, or transform incoming data
//
// Example:
//
// bot.AddMiddleware(authMiddleware, rateLimitMiddleware)
//
// Middleware with an empty name are skipped with a warning.
func (bot *Bot[T]) AddMiddleware(middleware ...Middleware[T]) *Bot[T] {
for _, m := range middleware {
if m.name == "" {
bot.logger.Warnln("middleware must have a non-empty name")
continue
}
bot.middlewares = append(bot.middlewares, m)
bot.logger.Debugln(fmt.Sprintf("middleware with name \"%s\" registered", m.name))
}
// Stable sort by order (ascending), then by name (lexicographic)
sort.Slice(bot.middlewares, func(i, j int) bool {
first := bot.middlewares[i]
second := bot.middlewares[j]
if first.order != second.order {
return first.order < second.order
}
return first.name < second.name
})
return bot
}
// AddRunner registers a background runner to execute concurrently with the bot.
//
// Runners are goroutines that run independently of update processing.
// Common use cases:
// - Periodic cleanup (e.g., expiring drafts, clearing temp files)
// - Metrics collection or health checks
// - Scheduled tasks (e.g., daily announcements)
//
// Runners are started immediately after Bot.Run() is called.
//
// Example:
//
// bot.AddRunner(cleanupRunner)
//
// Runners with an empty name are skipped with a warning.
func (bot *Bot[T]) AddRunner(runner Runner[T]) *Bot[T] {
if runner.name == "" {
bot.logger.Warnln("runner must have a non-empty name")
return bot
}
bot.runners = append(bot.runners, runner)
bot.logger.Debugln(fmt.Sprintf("runner with name \"%s\" registered", runner.name))
return bot
}
// AddL10n sets the localization (i18n) provider for the bot.
//
// The L10n instance must be pre-populated with translations.
// Translations are accessed via Bot.L10n(lang, key).
//
// Example:
//
// l10n := l10n.New()
// l10n.Add("en", "hello", "Hello!")
// l10n.Add("es", "hello", "¡Hola!")
// bot.AddL10n(l10n)
//
// Replaces any previously set L10n instance.
func (bot *Bot[T]) AddL10n(l *L10n) *Bot[T] {
if l == nil {
bot.logger.Warn("AddL10n called with nil L10n; localization will be disabled")
return bot
}
bot.l10n = l
return bot
}
// AddDatabaseLoggerWriter adds a database logger writer to all loggers.
//
// The writer will receive logs from:
// - Main bot logger
// - Request logger (if enabled)
// - API and Uploader loggers
// - Already registered plugin loggers
//
// Call this after AddPlugins if plugin loggers should also receive the writer.
// Plugins registered later do not automatically inherit previously added
// database writers; call AddDatabaseLoggerWriter again after adding them.
//
// Example:
//
// bot.AddDatabaseLoggerWriter(func(db *MyDB) slog.LoggerWriter {
// return db.QueryLogger()
// })
func (bot *Bot[T]) AddDatabaseLoggerWriter(writer DbLogger[T]) *Bot[T] {
if !bot.hasDBContext {
bot.logger.Warnln("database context is not set; skipping database logger writer")
return bot
}
if isNilValue(bot.dbContext) {
bot.logger.Warnln("database context is nil; skipping database logger writer")
return bot
}
w := writer(bot.dbContext)
bot.logger.AddWriter(w)
if bot.RequestLogger != nil {
bot.RequestLogger.AddWriter(w)
}
for _, l := range bot.extraLoggers {
l.AddWriter(w)
}
for _, p := range bot.plugins {
if p.logger != nil {
p.logger.AddWriter(w)
}
}
return bot
}
// RunWithContext starts the bot with a given context for graceful shutdown. // RunWithContext starts the bot with a given context for graceful shutdown.
// //
// This is the main entry point for bot execution. It: // This is the main entry point for bot execution. It:
@@ -627,14 +330,6 @@ func (bot *Bot[T]) AddDatabaseLoggerWriter(writer DbLogger[T]) *Bot[T] {
// RunWithContext does not close API, uploader, or logger resources on return. // RunWithContext does not close API, uploader, or logger resources on return.
// The caller must invoke Close after RunWithContext finishes. // The caller must invoke Close after RunWithContext finishes.
// //
// Example:
//
// ctx, cancel := context.WithCancel(context.Background())
// go bot.RunWithContext(ctx)
// // ... later ...
// cancel() // triggers graceful shutdown
// _ = bot.Close()
//
// A Bot is single-use. After RunWithContext returns, later calls return ErrBotAlreadyRun. // A Bot is single-use. After RunWithContext returns, later calls return ErrBotAlreadyRun.
func (bot *Bot[T]) RunWithContext(ctx context.Context) error { func (bot *Bot[T]) RunWithContext(ctx context.Context) error {
if len(bot.prefixes) == 0 { if len(bot.prefixes) == 0 {
@@ -662,6 +357,7 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) error {
close(bot.updateQueue) close(bot.updateQueue)
}() }()
retryDelay := time.Duration(0) retryDelay := time.Duration(0)
retryCount := 0
for { for {
select { select {
case <-ctx.Done(): case <-ctx.Done():
@@ -674,6 +370,19 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) error {
} }
bot.logger.Errorln("failed to fetch updates:", err) bot.logger.Errorln("failed to fetch updates:", err)
retryDelay = nextPollRetryDelay(retryDelay) retryDelay = nextPollRetryDelay(retryDelay)
retryCount++
bot.safeEmitEvent(ctx, PollingRetryEvent{
Attempt: retryCount,
Delay: retryDelay,
Err: err,
})
bot.safeEmitEvent(ctx, ErrorEvent{
Plugin: "bot",
HandlerKind: HandlerPollingKind,
HandlerName: "getUpdates",
Err: err,
UserFacing: false,
})
timer := time.NewTimer(retryDelay) timer := time.NewTimer(retryDelay)
select { select {
case <-ctx.Done(): case <-ctx.Done():
@@ -686,6 +395,7 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) error {
continue continue
} }
retryDelay = 0 retryDelay = 0
retryCount = 0
for _, update := range updates { for _, update := range updates {
u := update // copy loop variable to avoid race condition u := update // copy loop variable to avoid race condition
@@ -723,6 +433,37 @@ func (bot *Bot[T]) Run() error {
return bot.RunWithContext(context.Background()) return bot.RunWithContext(context.Background())
} }
func (bot *Bot[T]) initLoggers(opts *BotOpts) {
level := slog.FATAL
if opts.Debug {
level = slog.DEBUG
}
bot.logger = utils.CreateLogger("BOT", level)
if opts.WriteToFile {
path := fmt.Sprintf("%s/main.log", strings.TrimRight(opts.LoggerBasePath, "/"))
logger, err := utils.CreateFileLogger("BOT", level, path)
if err != nil {
bot.logger.Errorln(err)
} else {
bot.logger = logger
}
}
if opts.UseRequestLogger {
bot.RequestLogger = utils.CreateLogger("REQUESTS", level)
if opts.WriteToFile {
path := fmt.Sprintf("%s/requests.log", strings.TrimRight(opts.LoggerBasePath, "/"))
logger, err := utils.CreateFileLogger("REQUESTS", level, path)
if err != nil {
bot.logger.Errorln(err)
} else {
bot.RequestLogger = logger
}
}
}
}
func (bot *Bot[T]) beginRun() error { func (bot *Bot[T]) beginRun() error {
bot.runStateMu.Lock() bot.runStateMu.Lock()
defer bot.runStateMu.Unlock() defer bot.runStateMu.Unlock()
@@ -764,9 +505,9 @@ func isNilValue[T any](v T) bool {
} }
} }
func shouldWarnOnValueDBContext[T any]() bool { func shouldWarnOnValueAppData[T any]() bool {
t := reflect.TypeFor[T]() t := reflect.TypeFor[T]()
if t == reflect.TypeFor[NoDB]() { if t == reflect.TypeFor[NoData]() {
return false return false
} }
switch t.Kind() { switch t.Kind() {
@@ -777,11 +518,12 @@ func shouldWarnOnValueDBContext[T any]() bool {
} }
} }
func clonePlugin[T DbContext](p *Plugin[T]) Plugin[T] { func clonePlugin[T AppData](p *Plugin[T]) Plugin[T] {
cloned := Plugin[T]{ cloned := Plugin[T]{
name: p.name, name: p.name,
commands: make(map[string]*Command[T], len(p.commands)), commands: make(map[string]*Command[T], len(p.commands)),
payloads: make(map[string]*Command[T], len(p.payloads)), 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...), middlewares: append(extypes.Slice[Middleware[T]](nil), p.middlewares...),
skipAutoCmd: p.skipAutoCmd, skipAutoCmd: p.skipAutoCmd,
logger: p.logger, logger: p.logger,
@@ -795,12 +537,15 @@ func clonePlugin[T DbContext](p *Plugin[T]) Plugin[T] {
for name, command := range p.payloads { for name, command := range p.payloads {
cloned.payloads[name] = cloneCommand(command) cloned.payloads[name] = cloneCommand(command)
} }
for name, scene := range p.scenes {
cloned.scenes[name] = cloneScene(scene)
}
maps.Copy(cloned.handlers, p.handlers) maps.Copy(cloned.handlers, p.handlers)
return cloned return cloned
} }
func cloneCommand[T DbContext](command *Command[T]) *Command[T] { func cloneCommand[T AppData](command *Command[T]) *Command[T] {
if command == nil { if command == nil {
return nil return nil
} }
@@ -810,3 +555,22 @@ func cloneCommand[T DbContext](command *Command[T]) *Command[T] {
cloned.middlewares = append(extypes.Slice[Middleware[T]](nil), command.middlewares...) cloned.middlewares = append(extypes.Slice[Middleware[T]](nil), command.middlewares...)
return &cloned return &cloned
} }
func cloneScene[T AppData](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
}
+224
View File
@@ -0,0 +1,224 @@
package laniakea
import (
"fmt"
"slices"
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
"git.scuroneko.dev/scuroneko/slog"
)
// AddPrefixes adds one or more command prefixes (e.g., "/", "!").
// Must have at least one prefix before Run().
func (bot *Bot[T]) AddPrefixes(prefixes ...string) *Bot[T] {
if !bot.configMutable("AddPrefixes") {
return bot
}
bot.prefixes = append(bot.prefixes, prefixes...)
return bot
}
// SetDraftProvider replaces the default DraftProvider with a custom one.
// Useful for using LinearDraftIdGenerator to persist draft IDs across restarts.
func (bot *Bot[T]) SetDraftProvider(p *DraftProvider) *Bot[T] {
if !bot.configMutable("SetDraftProvider") {
return bot
}
bot.draftProvider = p
return bot
}
// GetDraftProvider returns the draft provider currently used by the bot.
func (bot *Bot[T]) GetDraftProvider() *DraftProvider {
return bot.draftProvider
}
// SetObserver sets an event observer for instrumentation.
func (bot *Bot[T]) SetObserver(observer Observer) *Bot[T] {
if !bot.configMutable("SetObserver") {
return bot
}
if observer == nil {
if bot.logger != nil {
bot.logger.Warn("SetObserver called with nil observer; instrumentation will be disabled")
}
bot.observer = nil
return bot
}
bot.observer = observer
return bot
}
// GetObserver returns the bot's event observer, or nil if no observer is set.
func (bot *Bot[T]) GetObserver() Observer {
return bot.observer
}
// SetSessionStore replaces the session store used for scene management.
func (bot *Bot[T]) SetSessionStore(store SessionStore) *Bot[T] {
if !bot.configMutable("SetSessionStore") {
return bot
}
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] {
if !bot.configMutable("SetSceneScopePriority") {
return bot
}
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
}
// SetAppData injects shared application data into the bot.
//
// The data is accessible to commands, payload handlers, middleware, scenes,
// and runners through the generic type parameter T.
//
// For shared dependencies such as *sql.DB, prefer using a pointer type as T.
// Value-typed application data is supported, but the bot warns once because
// handlers receive T by value.
func (bot *Bot[T]) SetAppData(ctx T) *Bot[T] {
if !bot.configMutable("SetAppData") {
return bot
}
if !bot.warnedValueData && shouldWarnOnValueAppData[T]() && bot.logger != nil {
bot.logger.Warnln("app data uses a value type; shared dependencies should usually use a pointer type as T")
bot.warnedValueData = true
}
bot.appData = ctx
bot.hasAppData = true
return bot
}
// GetAppData returns the injected application data.
// If SetAppData was not called, it returns the zero value of T.
func (bot *Bot[T]) GetAppData() T { return bot.appData }
// SetUpdateTypes sets the list of update types the bot will request from Telegram.
// Overwrites any previously set types.
func (bot *Bot[T]) SetUpdateTypes(t ...tgapi.UpdateType) *Bot[T] {
if !bot.configMutable("UpdateTypes") {
return bot
}
bot.updateTypes = make([]tgapi.UpdateType, 0)
bot.updateTypes = append(bot.updateTypes, t...)
return bot
}
// AddUpdateType adds one or more update types to the list.
// Does not overwrite existing types.
func (bot *Bot[T]) AddUpdateType(t ...tgapi.UpdateType) *Bot[T] {
if !bot.configMutable("AddUpdateType") {
return bot
}
bot.updateTypes = append(bot.updateTypes, t...)
return bot
}
// GetUpdateTypes returns the list of update types the bot is configured to receive.
func (bot *Bot[T]) GetUpdateTypes() []tgapi.UpdateType {
return append([]tgapi.UpdateType(nil), bot.updateTypes...)
}
// SetPayloadType sets the default payload encoding type used for callback data.
// JSON stores payload as a string: `{"cmd":"command","args":[...]}`.
// Base64 stores the same JSON encoded as a Base64URL string.
// InlineKeyboard.SetPayloadType may override this value for an individual keyboard.
func (bot *Bot[T]) SetPayloadType(t BotPayloadType) *Bot[T] {
if !bot.configMutable("SetPayloadType") {
return bot
}
bot.payloadType = t
return bot
}
// GetPayloadType returns the bot's default callback payload encoding type.
func (bot *Bot[T]) GetPayloadType() BotPayloadType { return bot.payloadType }
// SetStrictPayloadType enables or disables strict callback payload decoding.
// When enabled, callback payloads must match the bot's default payload type.
func (bot *Bot[T]) SetStrictPayloadType(strict bool) *Bot[T] {
if !bot.configMutable("SetStrictPayloadType") {
return bot
}
bot.strictPayloadType = strict
return bot
}
// SetErrorTemplate sets the format string for error messages sent to users.
// Use "%s" to insert the error message.
// Example: "❌ Error: %s" → "❌ Error: Command not found".
func (bot *Bot[T]) SetErrorTemplate(s string) *Bot[T] {
if !bot.configMutable("ErrorTemplate") {
return bot
}
bot.errorTemplate = s
return bot
}
// SetDebug enables or disables debug logging.
func (bot *Bot[T]) SetDebug(debug bool) *Bot[T] {
bot.debug = debug
level := slog.FATAL
if debug {
level = slog.DEBUG
}
bot.logger.Level(level)
if bot.RequestLogger != nil {
bot.RequestLogger.Level(level)
}
for _, p := range bot.plugins {
if p.logger == nil {
continue
}
p.logger.Level(level)
}
return bot
}
// SetL10n sets the localization (i18n) provider for the bot.
//
// The L10n instance must be pre-populated with translations.
// Translations are accessed via Bot.L10n(lang, key).
//
// Replaces any previously set L10n instance.
func (bot *Bot[T]) SetL10n(l *L10n) *Bot[T] {
if !bot.configMutable("SetL10n") {
return bot
}
if l == nil {
bot.logger.Warn("SetL10n called with nil L10n; localization will be disabled")
return bot
}
bot.l10n = l
return bot
}
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"strconv" "strconv"
"strings" "strings"
"git.nix13.pw/scuroneko/laniakea/tgapi" "git.scuroneko.dev/scuroneko/laniakea/tgapi"
) )
// BotOpts holds configuration options for initializing a Bot. // BotOpts holds configuration options for initializing a Bot.
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"reflect" "reflect"
"testing" "testing"
"git.nix13.pw/scuroneko/laniakea/tgapi" "git.scuroneko.dev/scuroneko/laniakea/tgapi"
) )
func TestLoadOptsFromEnvIgnoresEmptyUpdateTypes(t *testing.T) { func TestLoadOptsFromEnvIgnoresEmptyUpdateTypes(t *testing.T) {
+156
View File
@@ -0,0 +1,156 @@
package laniakea
import (
"fmt"
"sort"
"git.scuroneko.dev/scuroneko/laniakea/utils"
)
// AddPlugins registers one or more plugins.
// Plugins are executed in registration order unless filtered by middleware.
//
// Registration is a commit point for plugin configuration. The Bot stores
// plugin metadata internally, so plugins must be fully configured before they
// are passed here. Post-registration mutation through the original *Plugin is
// not a supported API, even if some changes appear to work due to shared maps.
func (bot *Bot[T]) AddPlugins(plugin ...*Plugin[T]) *Bot[T] {
if !bot.configMutable("AddPlugins") {
return bot
}
level := bot.GetLoggerLevel()
for _, p := range plugin {
if p == nil {
if bot.logger != nil {
bot.logger.Warn("nil plugin skipped")
}
continue
}
cloned := clonePlugin(p)
if cloned.logger == nil {
cloned.logger = utils.CreateLogger(cloned.name, level)
}
bot.plugins = append(bot.plugins, cloned)
if bot.logger != nil {
bot.logger.Debugln(fmt.Sprintf("plugins with name \"%s\" registered", cloned.name))
}
}
return bot
}
// AddMiddleware registers one or more middleware handlers.
//
// Middleware are executed in order of increasing .order value before plugins.
// If two middleware have the same order, they are sorted lexicographically by name.
//
// Middleware can:
// - Modify or reject updates before they reach plugins
// - Inject context (e.g., user auth state, rate limit status)
// - Log, validate, or transform incoming data
//
// Example:
//
// bot.AddMiddleware(authMiddleware, rateLimitMiddleware)
//
// Middleware with an empty name are skipped with a warning.
func (bot *Bot[T]) AddMiddleware(middleware ...Middleware[T]) *Bot[T] {
if !bot.configMutable("AddMiddleware") {
return bot
}
for _, m := range middleware {
if m.name == "" {
bot.logger.Warnln("middleware must have a non-empty name")
continue
}
bot.middlewares = append(bot.middlewares, m)
bot.logger.Debugln(fmt.Sprintf("middleware with name \"%s\" registered", m.name))
}
// Stable sort by order (ascending), then by name (lexicographic)
sort.Slice(bot.middlewares, func(i, j int) bool {
first := bot.middlewares[i]
second := bot.middlewares[j]
if first.order != second.order {
return first.order < second.order
}
return first.name < second.name
})
return bot
}
// UsePolicy registers a Policy as a bot-level middleware.
func (bot *Bot[T]) UsePolicy(name string, policy Policy[T]) *Bot[T] {
mw := RequirePolicy(name, policy)
return bot.AddMiddleware(mw)
}
// AddRunner registers a background runner to execute concurrently with the bot.
//
// Runners are goroutines that run independently of update processing.
// Common use cases:
// - Periodic cleanup (e.g., expiring drafts, clearing temp files)
// - Metrics collection or health checks
// - Scheduled tasks (e.g., daily announcements)
//
// Runners are started immediately after Bot.Run() is called.
//
// Example:
//
// bot.AddRunner(cleanupRunner)
//
// Runners with an empty name are skipped with a warning.
func (bot *Bot[T]) AddRunner(runner Runner[T]) *Bot[T] {
if !bot.configMutable("AddRunner") {
return bot
}
if runner.name == "" {
bot.logger.Warnln("runner must have a non-empty name")
return bot
}
bot.runners = append(bot.runners, runner)
bot.logger.Debugln(fmt.Sprintf("runner with name \"%s\" registered", runner.name))
return bot
}
// AddAppDataLoggerWriter adds an app-data-backed logger writer to all loggers.
//
// The writer will receive logs from:
// - Main bot logger
// - Request logger (if enabled)
// - API and Uploader loggers
// - Already registered plugin loggers
//
// Call this after AddPlugins if plugin loggers should also receive the writer.
// Plugins registered later do not automatically inherit previously added
// writers; call AddAppDataLoggerWriter again after adding them.
//
// Example:
//
// bot.AddAppDataLoggerWriter(func(data *MyAppData) slog.LoggerWriter {
// return data.QueryLogger()
// })
func (bot *Bot[T]) AddAppDataLoggerWriter(writer AppDataLogger[T]) *Bot[T] {
if !bot.hasAppData {
bot.logger.Warnln("app data is not set; skipping app-data logger writer")
return bot
}
if isNilValue(bot.appData) {
bot.logger.Warnln("app data is nil; skipping app-data logger writer")
return bot
}
w := writer(bot.appData)
bot.logger.AddWriter(w)
if bot.RequestLogger != nil {
bot.RequestLogger.AddWriter(w)
}
for _, l := range bot.extraLoggers {
l.AddWriter(w)
}
for _, p := range bot.plugins {
if p.logger != nil {
p.logger.AddWriter(w)
}
}
return bot
}
+7 -9
View File
@@ -1,17 +1,17 @@
package laniakea package laniakea
func (bot *Bot[T]) GetSession(key string) (SceneSession, error) { func (bot *Bot[T]) getSession(key string) (SceneSession, error) {
return bot.sessionStore.Get(key) return bot.sessionStore.Get(key)
} }
func (bot *Bot[T]) SetSession(key string, session SceneSession) error { func (bot *Bot[T]) setSession(key string, session SceneSession) error {
return bot.sessionStore.Set(key, session) return bot.sessionStore.Set(key, session)
} }
func (bot *Bot[T]) DeleteSession(key string) error { func (bot *Bot[T]) deleteSession(key string) error {
return bot.sessionStore.Delete(key) return bot.sessionStore.Delete(key)
} }
func (bot *Bot[T]) FindScene(name string) (*sceneMeta, bool) { func (bot *Bot[T]) findScene(name string) (*sceneMeta, bool) {
for _, plugin := range bot.plugins { for _, plugin := range bot.plugins {
scene, ok := plugin.scenes[name] scene, ok := plugin.scenes[name]
if !ok { if !ok {
@@ -33,11 +33,8 @@ func (bot *Bot[T]) FindScene(name string) (*sceneMeta, bool) {
return nil, false return nil, false
} }
func (bot *Bot[T]) FindSceneSession(ctx *MsgContext) (string, SceneSession, error) { func (bot *Bot[T]) findSceneSession(ctx *MsgContext) (string, SceneSession, error) {
var zero SceneSession var zero SceneSession
if ctx.Msg == nil {
return "", zero, ErrMessageNil
}
for _, scope := range bot.sceneScopePriority { for _, scope := range bot.sceneScopePriority {
key, ok := buildSceneKey(scope, ctx) key, ok := buildSceneKey(scope, ctx)
@@ -56,6 +53,7 @@ func (bot *Bot[T]) FindSceneSession(ctx *MsgContext) (string, SceneSession, erro
return "", zero, ErrCantFindSession return "", zero, ErrCantFindSession
} }
func (bot *Bot[T]) BuildSceneKey(scope SceneScope, ctx *MsgContext) (string, bool) {
func (bot *Bot[T]) buildSceneKey(scope SceneScope, ctx *MsgContext) (string, bool) {
return buildSceneKey(scope, ctx) return buildSceneKey(scope, ctx)
} }
+376 -38
View File
@@ -3,17 +3,51 @@ package laniakea
import ( import (
"context" "context"
"errors" "errors"
"io"
"net/http"
"path/filepath" "path/filepath"
"reflect" "reflect"
"strings"
"testing" "testing"
"time" "time"
"git.nix13.pw/scuroneko/laniakea/tgapi" "git.scuroneko.dev/scuroneko/laniakea/tgapi"
"git.nix13.pw/scuroneko/slog" "git.scuroneko.dev/scuroneko/slog"
) )
type pollingRoundTripFunc func(*http.Request) (*http.Response, error)
func (f pollingRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
type pollingRetryObserver struct {
recordingObserver
cancel context.CancelFunc
}
func (o *pollingRetryObserver) OnPollingRetry(ctx context.Context, ev PollingRetryEvent) {
o.recordingObserver.OnPollingRetry(ctx, ev)
if o.cancel != nil {
o.cancel()
}
}
type testObserver struct{}
func (testObserver) OnReceiveUpdate(context.Context, UpdateReceivedEvent) {}
func (testObserver) OnHandledUpdate(context.Context, UpdateHandledEvent) {}
func (testObserver) OnHandlerStarted(context.Context, HandlerStartedEvent) {}
func (testObserver) OnHandlerFinished(context.Context, HandlerFinishedEvent) {
}
func (testObserver) OnSceneTransition(context.Context, SceneTransitionEvent) {}
func (testObserver) OnPolicyChecked(context.Context, PolicyCheckedEvent) {}
func (testObserver) OnRunnerFinished(context.Context, RunnerFinishedEvent) {}
func (testObserver) OnPollingRetry(context.Context, PollingRetryEvent) {}
func (testObserver) OnError(context.Context, ErrorEvent) {}
func TestGetUpdateTypesReturnsCopy(t *testing.T) { func TestGetUpdateTypesReturnsCopy(t *testing.T) {
bot := &Bot[NoDB]{updateTypes: []tgapi.UpdateType{tgapi.UpdateTypeMessage}} bot := &Bot[NoData]{updateTypes: []tgapi.UpdateType{tgapi.UpdateTypeMessage}}
got := bot.GetUpdateTypes() got := bot.GetUpdateTypes()
got[0] = tgapi.UpdateTypeCallbackQuery got[0] = tgapi.UpdateTypeCallbackQuery
@@ -24,17 +58,17 @@ func TestGetUpdateTypesReturnsCopy(t *testing.T) {
} }
func TestAddPluginsSnapshotsConfiguration(t *testing.T) { func TestAddPluginsSnapshotsConfiguration(t *testing.T) {
bot := &Bot[NoDB]{logger: slog.CreateLogger()} bot := &Bot[NoData]{logger: slog.CreateLogger()}
plugin := NewPlugin[NoDB]("demo") plugin := NewPlugin[NoData]("demo")
cmd := plugin.NewCommand(func(ctx *MsgContext, db NoDB) error { return nil }, "start") cmd := plugin.NewCommand(func(ctx *MsgContext, db NoData) error { return nil }, "start")
plugin.AddMiddleware(NewMiddleware("base", func(ctx *MsgContext, db NoDB) bool { return true })) plugin.AddMiddleware(NewMiddleware("base", func(ctx *MsgContext, db NoData) bool { return true }))
bot.AddPlugins(plugin) bot.AddPlugins(plugin)
cmd.SetDescription("mutated after registration") cmd.SetDescription("mutated after registration")
plugin.NewCommand(func(ctx *MsgContext, db NoDB) error { return nil }, "late") plugin.NewCommand(func(ctx *MsgContext, db NoData) error { return nil }, "late")
plugin.AddMiddleware(NewMiddleware("late", func(ctx *MsgContext, db NoDB) bool { return true })) plugin.AddMiddleware(NewMiddleware("late", func(ctx *MsgContext, db NoData) bool { return true }))
registered := bot.plugins[0] registered := bot.plugins[0]
if _, exists := registered.commands["late"]; exists { if _, exists := registered.commands["late"]; exists {
@@ -49,7 +83,7 @@ func TestAddPluginsSnapshotsConfiguration(t *testing.T) {
} }
func TestBotPayloadTypeConfiguration(t *testing.T) { func TestBotPayloadTypeConfiguration(t *testing.T) {
bot := &Bot[NoDB]{payloadType: BotPayloadBase64} bot := &Bot[NoData]{payloadType: BotPayloadBase64}
if got := bot.GetPayloadType(); got != BotPayloadBase64 { if got := bot.GetPayloadType(); got != BotPayloadBase64 {
t.Fatalf("unexpected initial payload type: %q", got) t.Fatalf("unexpected initial payload type: %q", got)
@@ -65,8 +99,8 @@ func TestBotPayloadTypeConfiguration(t *testing.T) {
} }
func TestAddPluginsSkipsNilPlugin(t *testing.T) { func TestAddPluginsSkipsNilPlugin(t *testing.T) {
bot := &Bot[NoDB]{logger: slog.CreateLogger()} bot := &Bot[NoData]{logger: slog.CreateLogger()}
plugin := NewPlugin[NoDB]("demo") plugin := NewPlugin[NoData]("demo")
bot.AddPlugins(nil, plugin) bot.AddPlugins(nil, plugin)
@@ -79,7 +113,7 @@ func TestAddPluginsSkipsNilPlugin(t *testing.T) {
} }
func TestInitLoggersFallsBackToStdoutLoggerOnFileError(t *testing.T) { func TestInitLoggersFallsBackToStdoutLoggerOnFileError(t *testing.T) {
bot := &Bot[NoDB]{} bot := &Bot[NoData]{}
bot.initLoggers(&BotOpts{ bot.initLoggers(&BotOpts{
Debug: true, Debug: true,
@@ -122,39 +156,39 @@ func TestNextPollRetryDelay(t *testing.T) {
} }
} }
func TestAddDatabaseLoggerWriterSkipsWhenDBContextIsUnset(t *testing.T) { func TestAddDatabaseLoggerWriterSkipsWhenAppDataIsUnset(t *testing.T) {
bot := &Bot[NoDB]{logger: slog.CreateLogger()} bot := &Bot[NoData]{logger: slog.CreateLogger()}
called := false called := false
bot.AddDatabaseLoggerWriter(func(db NoDB) slog.LoggerWriter { bot.AddAppDataLoggerWriter(func(db NoData) slog.LoggerWriter {
called = true called = true
return nil return nil
}) })
if called { if called {
t.Fatal("expected database logger writer to be skipped when db context is unset") t.Fatal("expected app-data logger writer to be skipped when app data is unset")
} }
} }
func TestAddDatabaseLoggerWriterSkipsWhenDBContextIsNil(t *testing.T) { func TestAddDatabaseLoggerWriterSkipsWhenAppDataIsNil(t *testing.T) {
type testDB struct{} type testDB struct{}
bot := &Bot[*testDB]{logger: slog.CreateLogger()} bot := &Bot[*testDB]{logger: slog.CreateLogger()}
var db *testDB var db *testDB
bot.DatabaseContext(db) bot.SetAppData(db)
called := false called := false
bot.AddDatabaseLoggerWriter(func(db *testDB) slog.LoggerWriter { bot.AddAppDataLoggerWriter(func(db *testDB) slog.LoggerWriter {
called = true called = true
return nil return nil
}) })
if called { if called {
t.Fatal("expected database logger writer to be skipped when db context is nil") t.Fatal("expected app-data logger writer to be skipped when app data is nil")
} }
} }
func TestShouldWarnOnValueDBContext(t *testing.T) { func TestShouldWarnOnValueAppData(t *testing.T) {
type testDB struct{} type testDB struct{}
type dbIface interface{ Ping() error } type dbIface interface{ Ping() error }
@@ -163,36 +197,64 @@ func TestShouldWarnOnValueDBContext(t *testing.T) {
got bool got bool
want bool want bool
}{ }{
{name: "NoDB", got: shouldWarnOnValueDBContext[NoDB](), want: false}, {name: "NoData", got: shouldWarnOnValueAppData[NoData](), want: false},
{name: "pointer", got: shouldWarnOnValueDBContext[*testDB](), want: false}, {name: "pointer", got: shouldWarnOnValueAppData[*testDB](), want: false},
{name: "interface", got: shouldWarnOnValueDBContext[dbIface](), want: false}, {name: "interface", got: shouldWarnOnValueAppData[dbIface](), want: false},
{name: "map", got: shouldWarnOnValueDBContext[map[string]int](), want: false}, {name: "map", got: shouldWarnOnValueAppData[map[string]int](), want: false},
{name: "struct", got: shouldWarnOnValueDBContext[testDB](), want: true}, {name: "struct", got: shouldWarnOnValueAppData[testDB](), want: true},
{name: "int", got: shouldWarnOnValueDBContext[int](), want: true}, {name: "int", got: shouldWarnOnValueAppData[int](), want: true},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
if tt.got != tt.want { if tt.got != tt.want {
t.Fatalf("shouldWarnOnValueDBContext = %v, want %v", tt.got, tt.want) t.Fatalf("shouldWarnOnValueAppData = %v, want %v", tt.got, tt.want)
} }
}) })
} }
} }
func TestDatabaseContextMarksValueWarningOnce(t *testing.T) { func TestSetAppDataMarksValueWarningOnce(t *testing.T) {
type testDB struct{} type testDB struct{}
bot := &Bot[testDB]{logger: slog.CreateLogger()} bot := &Bot[testDB]{logger: slog.CreateLogger()}
bot.DatabaseContext(testDB{}) bot.SetAppData(testDB{})
if !bot.warnedValueDB { if !bot.warnedValueData {
t.Fatal("expected value-typed database context to mark warning state") t.Fatal("expected value-typed app data to mark warning state")
} }
ptrBot := &Bot[*testDB]{logger: slog.CreateLogger()} ptrBot := &Bot[*testDB]{logger: slog.CreateLogger()}
ptrBot.DatabaseContext(&testDB{}) ptrBot.SetAppData(&testDB{})
if ptrBot.warnedValueDB { if ptrBot.warnedValueData {
t.Fatal("did not expect pointer-typed database context to mark warning state") t.Fatal("did not expect pointer-typed app data to mark warning state")
}
}
func TestSetObserverAndGetObserver(t *testing.T) {
bot := &Bot[NoData]{logger: slog.CreateLogger()}
observer := testObserver{}
if got := bot.GetObserver(); got != nil {
t.Fatalf("expected nil observer by default, got %#v", got)
}
bot.SetObserver(observer)
if got := bot.GetObserver(); got == nil {
t.Fatal("expected observer to be stored")
}
}
func TestSetObserverNilClearsObserver(t *testing.T) {
bot := &Bot[NoData]{logger: slog.CreateLogger()}
bot.SetObserver(testObserver{})
if bot.GetObserver() == nil {
t.Fatal("expected observer to be set")
}
bot.SetObserver(nil)
if got := bot.GetObserver(); got != nil {
t.Fatalf("expected nil observer after clearing, got %#v", got)
} }
} }
@@ -200,10 +262,10 @@ func TestRunWithContextRejectsSecondRun(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
cancel() cancel()
bot := &Bot[NoDB]{ bot := &Bot[NoData]{
logger: slog.CreateLogger(), logger: slog.CreateLogger(),
prefixes: []string{"/"}, prefixes: []string{"/"},
plugins: []Plugin[NoDB]{{name: "demo"}}, plugins: []Plugin[NoData]{{name: "demo"}},
updateQueue: make(chan *tgapi.Update, 1), updateQueue: make(chan *tgapi.Update, 1),
maxWorkers: 1, maxWorkers: 1,
} }
@@ -215,3 +277,279 @@ func TestRunWithContextRejectsSecondRun(t *testing.T) {
t.Fatalf("expected ErrBotAlreadyRun on second run, got %v", err) t.Fatalf("expected ErrBotAlreadyRun on second run, got %v", err)
} }
} }
func TestRunWithContextEmitsPollingRetryAndErrorEvents(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
observer := &pollingRetryObserver{cancel: cancel}
client := &http.Client{
Transport: pollingRoundTripFunc(func(r *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(`{"ok":false,"error_code":500,"description":"boom"}`)),
}, nil
}),
}
api := tgapi.NewAPI(
tgapi.NewAPIOpts("token").
SetAPIUrl("http://example.invalid").
SetHTTPClient(client),
)
defer func() {
_ = api.Close()
}()
bot := &Bot[NoData]{
logger: slog.CreateLogger(),
api: api,
prefixes: []string{"/"},
plugins: []Plugin[NoData]{{name: "demo"}},
updateQueue: make(chan *tgapi.Update, 1),
maxWorkers: 1,
observer: observer,
}
if err := bot.RunWithContext(ctx); err != nil {
t.Fatalf("RunWithContext returned error: %v", err)
}
if len(observer.retries) != 1 {
t.Fatalf("expected one polling retry event, got %d", len(observer.retries))
}
if got := observer.retries[0]; got.Attempt != 1 || got.Delay <= 0 || got.Err == nil {
t.Fatalf("unexpected polling retry event: %#v", got)
}
if len(observer.errors) != 1 {
t.Fatalf("expected one polling error event, got %d", len(observer.errors))
}
if got := observer.errors[0]; got.HandlerKind != HandlerPollingKind || got.HandlerName != "getUpdates" || got.Plugin != "bot" || got.Err == nil || got.UserFacing {
t.Fatalf("unexpected polling error event: %#v", got)
}
}
func TestBotConfigurationFreezesAfterRunStarts(t *testing.T) {
type testDB struct{ Name string }
makeBot := func() *Bot[*testDB] {
return &Bot[*testDB]{
logger: slog.CreateLogger(),
prefixes: []string{"/"},
updateTypes: []tgapi.UpdateType{tgapi.UpdateTypeMessage},
payloadType: BotPayloadBase64,
strictPayloadType: false,
errorTemplate: "%s",
l10n: &L10n{},
draftProvider: &DraftProvider{},
sessionStore: NewMemorySessionStore(),
sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser},
}
}
tests := []struct {
name string
check func(t *testing.T, bot *Bot[*testDB])
}{
{
name: "SetAppData",
check: func(t *testing.T, bot *Bot[*testDB]) {
original := &testDB{Name: "before"}
bot.SetAppData(original)
if err := bot.beginRun(); err != nil {
t.Fatalf("beginRun returned error: %v", err)
}
t.Cleanup(bot.finishRun)
later := &testDB{Name: "after"}
bot.SetAppData(later)
if bot.appData != original {
t.Fatal("SetAppData mutated after configuration freeze")
}
},
},
{
name: "UpdateTypes",
check: func(t *testing.T, bot *Bot[*testDB]) {
original := append([]tgapi.UpdateType(nil), bot.updateTypes...)
if err := bot.beginRun(); err != nil {
t.Fatalf("beginRun returned error: %v", err)
}
t.Cleanup(bot.finishRun)
bot.SetUpdateTypes(tgapi.UpdateTypePoll)
if !reflect.DeepEqual(bot.updateTypes, original) {
t.Fatalf("UpdateTypes mutated after configuration freeze: got %v want %v", bot.updateTypes, original)
}
},
},
{
name: "AddUpdateType",
check: func(t *testing.T, bot *Bot[*testDB]) {
original := append([]tgapi.UpdateType(nil), bot.updateTypes...)
if err := bot.beginRun(); err != nil {
t.Fatalf("beginRun returned error: %v", err)
}
t.Cleanup(bot.finishRun)
bot.AddUpdateType(tgapi.UpdateTypePoll)
if !reflect.DeepEqual(bot.updateTypes, original) {
t.Fatalf("AddUpdateType mutated after configuration freeze: got %v want %v", bot.updateTypes, original)
}
},
},
{
name: "SetPayloadType",
check: func(t *testing.T, bot *Bot[*testDB]) {
if err := bot.beginRun(); err != nil {
t.Fatalf("beginRun returned error: %v", err)
}
t.Cleanup(bot.finishRun)
bot.SetPayloadType(BotPayloadJson)
if bot.payloadType != BotPayloadBase64 {
t.Fatalf("payloadType mutated after configuration freeze: got %q want %q", bot.payloadType, BotPayloadBase64)
}
},
},
{
name: "SetStrictPayloadType",
check: func(t *testing.T, bot *Bot[*testDB]) {
if err := bot.beginRun(); err != nil {
t.Fatalf("beginRun returned error: %v", err)
}
t.Cleanup(bot.finishRun)
bot.SetStrictPayloadType(true)
if bot.strictPayloadType {
t.Fatal("strictPayloadType mutated after configuration freeze")
}
},
},
{
name: "AddPrefixes",
check: func(t *testing.T, bot *Bot[*testDB]) {
original := append([]string(nil), bot.prefixes...)
if err := bot.beginRun(); err != nil {
t.Fatalf("beginRun returned error: %v", err)
}
t.Cleanup(bot.finishRun)
bot.AddPrefixes("!")
if !reflect.DeepEqual(bot.prefixes, original) {
t.Fatalf("prefixes mutated after configuration freeze: got %v want %v", bot.prefixes, original)
}
},
},
{
name: "ErrorTemplate",
check: func(t *testing.T, bot *Bot[*testDB]) {
if err := bot.beginRun(); err != nil {
t.Fatalf("beginRun returned error: %v", err)
}
t.Cleanup(bot.finishRun)
bot.SetErrorTemplate("changed")
if bot.errorTemplate != "%s" {
t.Fatalf("errorTemplate mutated after configuration freeze: got %q want %q", bot.errorTemplate, "%s")
}
},
},
{
name: "SetDraftProvider",
check: func(t *testing.T, bot *Bot[*testDB]) {
original := bot.draftProvider
if err := bot.beginRun(); err != nil {
t.Fatalf("beginRun returned error: %v", err)
}
t.Cleanup(bot.finishRun)
bot.SetDraftProvider(&DraftProvider{})
if bot.draftProvider != original {
t.Fatal("draftProvider mutated after configuration freeze")
}
},
},
{
name: "SetSessionStore",
check: func(t *testing.T, bot *Bot[*testDB]) {
original := bot.sessionStore
if err := bot.beginRun(); err != nil {
t.Fatalf("beginRun returned error: %v", err)
}
t.Cleanup(bot.finishRun)
bot.SetSessionStore(NewMemorySessionStore())
if bot.sessionStore != original {
t.Fatal("sessionStore mutated after configuration freeze")
}
},
},
{
name: "SetSceneScopePriority",
check: func(t *testing.T, bot *Bot[*testDB]) {
original := append([]SceneScope(nil), bot.sceneScopePriority...)
if err := bot.beginRun(); err != nil {
t.Fatalf("beginRun returned error: %v", err)
}
t.Cleanup(bot.finishRun)
bot.SetSceneScopePriority([]SceneScope{SceneScopeUser})
if !reflect.DeepEqual(bot.sceneScopePriority, original) {
t.Fatalf("sceneScopePriority mutated after configuration freeze: got %v want %v", bot.sceneScopePriority, original)
}
},
},
{
name: "AddL10n",
check: func(t *testing.T, bot *Bot[*testDB]) {
original := bot.l10n
if err := bot.beginRun(); err != nil {
t.Fatalf("beginRun returned error: %v", err)
}
t.Cleanup(bot.finishRun)
bot.SetL10n(&L10n{})
if bot.l10n != original {
t.Fatal("l10n mutated after configuration freeze")
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tt.check(t, makeBot())
})
}
}
func TestAddPluginsAndRuntimeRegistrationsNoOpAfterRunStarts(t *testing.T) {
bot := &Bot[NoData]{
logger: slog.CreateLogger(),
prefixes: []string{"/"},
middlewares: []Middleware[NoData]{NewMiddleware("base", func(ctx *MsgContext, db NoData) bool { return true })},
runners: []Runner[NoData]{NewRunner("base", func(bot *Bot[NoData]) error { return nil })},
}
plugin := NewPlugin[NoData]("late")
if err := bot.beginRun(); err != nil {
t.Fatalf("beginRun returned error: %v", err)
}
defer bot.finishRun()
bot.AddPlugins(plugin)
bot.AddMiddleware(NewMiddleware("late", func(ctx *MsgContext, db NoData) bool { return true }))
bot.AddRunner(NewRunner("late", func(bot *Bot[NoData]) error { return nil }))
if len(bot.plugins) != 0 {
t.Fatalf("expected AddPlugins to be ignored after configuration freeze, got %d plugins", len(bot.plugins))
}
if len(bot.middlewares) != 1 {
t.Fatalf("expected AddMiddleware to be ignored after configuration freeze, got %d middlewares", len(bot.middlewares))
}
if len(bot.runners) != 1 {
t.Fatalf("expected AddRunner to be ignored after configuration freeze, got %d runners", len(bot.runners))
}
}
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"sort" "sort"
"strings" "strings"
"git.nix13.pw/scuroneko/laniakea/tgapi" "git.scuroneko.dev/scuroneko/laniakea/tgapi"
) )
// CmdRegexp matches command names allowed for Telegram command registration. // CmdRegexp matches command names allowed for Telegram command registration.
+8 -8
View File
@@ -10,8 +10,8 @@ import (
"sync/atomic" "sync/atomic"
"testing" "testing"
"git.nix13.pw/scuroneko/laniakea/tgapi" "git.scuroneko.dev/scuroneko/laniakea/tgapi"
"git.nix13.pw/scuroneko/slog" "git.scuroneko.dev/scuroneko/slog"
) )
type roundTripFunc func(*http.Request) (*http.Response, error) type roundTripFunc func(*http.Request) (*http.Response, error)
@@ -43,16 +43,16 @@ func TestAutoGenerateCommandsChecksLimitBeforeDelete(t *testing.T) {
} }
}() }()
plugin := NewPlugin[NoDB]("overflow") plugin := NewPlugin[NoData]("overflow")
exec := func(ctx *MsgContext, db NoDB) error { return nil } exec := func(ctx *MsgContext, db NoData) error { return nil }
for i := 0; i < 101; i++ { for i := 0; i < 101; i++ {
plugin.AddCommand(NewCommand(exec, "cmd"+strconv.Itoa(i))) plugin.AddCommand(NewCommand(exec, "cmd"+strconv.Itoa(i)))
} }
bot := &Bot[NoDB]{ bot := &Bot[NoData]{
api: api, api: api,
logger: slog.CreateLogger(), logger: slog.CreateLogger(),
plugins: []Plugin[NoDB]{*plugin}, plugins: []Plugin[NoData]{*plugin},
} }
err := bot.AutoGenerateCommands() err := bot.AutoGenerateCommands()
@@ -65,8 +65,8 @@ func TestAutoGenerateCommandsChecksLimitBeforeDelete(t *testing.T) {
} }
func TestGatherCommandsForPluginReturnsSortedCommands(t *testing.T) { func TestGatherCommandsForPluginReturnsSortedCommands(t *testing.T) {
plugin := NewPlugin[NoDB]("sorted") plugin := NewPlugin[NoData]("sorted")
exec := func(ctx *MsgContext, db NoDB) error { return nil } exec := func(ctx *MsgContext, db NoData) error { return nil }
plugin.AddCommand(NewCommand(exec, "zeta")) plugin.AddCommand(NewCommand(exec, "zeta"))
plugin.AddCommand(NewCommand(exec, "alpha")) plugin.AddCommand(NewCommand(exec, "alpha"))
+3 -3
View File
@@ -13,17 +13,17 @@ Core concepts:
Example usage: Example usage:
bot, err := laniakea.NewBot[*mydb.DBContext](laniakea.LoadOptsFromEnv()) bot, err := laniakea.NewBot[*mydb.AppData](laniakea.LoadOptsFromEnv())
if err != nil { if err != nil {
return err return err
} }
bot.DatabaseContext(myDB). bot.SetAppData(myDB).
AddUpdateType(tgapi.UpdateTypeMessage). AddUpdateType(tgapi.UpdateTypeMessage).
AddPrefixes("/", "!"). AddPrefixes("/", "!").
AddPlugins(&startPlugin, &helpPlugin). AddPlugins(&startPlugin, &helpPlugin).
AddMiddleware(authMiddleware, logMiddleware). AddMiddleware(authMiddleware, logMiddleware).
AddRunner(cleanupRunner). AddRunner(cleanupRunner).
AddL10n(l10n.New()) SetL10n(l10n.New())
return bot.Run() return bot.Run()
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"sync" "sync"
"sync/atomic" "sync/atomic"
"git.nix13.pw/scuroneko/laniakea/tgapi" "git.scuroneko.dev/scuroneko/laniakea/tgapi"
) )
// Interface for generating unique draft IDs. // Interface for generating unique draft IDs.
+3 -3
View File
@@ -5,8 +5,8 @@ import (
"strings" "strings"
"testing" "testing"
"git.nix13.pw/scuroneko/laniakea/tgapi" "git.scuroneko.dev/scuroneko/laniakea/tgapi"
"git.nix13.pw/scuroneko/slog" "git.scuroneko.dev/scuroneko/slog"
) )
func TestDraftFlushRequiresChatID(t *testing.T) { func TestDraftFlushRequiresChatID(t *testing.T) {
@@ -22,7 +22,7 @@ func TestMsgContextNewDraftWorksWithoutLimiter(t *testing.T) {
ctx := &MsgContext{ ctx := &MsgContext{
Api: &tgapi.API{}, Api: &tgapi.API{},
Msg: &tgapi.Message{ Msg: &tgapi.Message{
Chat: &tgapi.Chat{ID: 42, Type: string(tgapi.ChatTypePrivate)}, Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate},
}, },
Logger: slog.CreateLogger(), Logger: slog.CreateLogger(),
draftProvider: NewRandomDraftProvider(&tgapi.API{}), draftProvider: NewRandomDraftProvider(&tgapi.API{}),
+59
View File
@@ -0,0 +1,59 @@
package laniakea
import "errors"
type classifiedError struct {
err error
userVisible bool
internalOnly bool
}
func (e *classifiedError) Error() string {
if e == nil || e.err == nil {
return ""
}
return e.err.Error()
}
func (e *classifiedError) Unwrap() error {
if e == nil {
return nil
}
return e.err
}
// AsUserError marks err as safe to show to the user through the centralized
// handler error flow.
func AsUserError(err error) error {
if err == nil {
return nil
}
return &classifiedError{err: err, userVisible: true}
}
// AsInternalError marks err as internal-only so it will be logged but not sent
// to the user through the centralized handler error flow.
func AsInternalError(err error) error {
if err == nil {
return nil
}
return &classifiedError{err: err, internalOnly: true}
}
// IsUserError reports whether err was explicitly marked as user-visible.
func IsUserError(err error) bool {
var classified *classifiedError
if !errors.As(err, &classified) {
return false
}
return classified.userVisible
}
// IsInternalError reports whether err was explicitly marked as internal-only.
func IsInternalError(err error) bool {
var classified *classifiedError
if !errors.As(err, &classified) {
return false
}
return classified.internalOnly
}
+15 -7
View File
@@ -24,7 +24,8 @@ var (
ErrPayloadTypeMismatch = errors.New("payload type mismatch") ErrPayloadTypeMismatch = errors.New("payload type mismatch")
// ErrDraftChatIDZero reports that a draft has no target chat ID. // ErrDraftChatIDZero reports that a draft has no target chat ID.
ErrDraftChatIDZero = errors.New("zero draft 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 reports that an operation requires ctx.Msg but none is set.
ErrMessageContextNil = errors.New("message context is nil") ErrMessageContextNil = errors.New("message context is nil")
// ErrEditTargetMissing reports that an edit operation has no message target. // ErrEditTargetMissing reports that an edit operation has no message target.
@@ -44,12 +45,19 @@ var (
// ErrBindArgsUnsupportedFieldType reports that BindArgs encountered an unsupported field kind. // ErrBindArgsUnsupportedFieldType reports that BindArgs encountered an unsupported field kind.
ErrBindArgsUnsupportedFieldType = errors.New("bind args: unsupported field type") ErrBindArgsUnsupportedFieldType = errors.New("bind args: unsupported field type")
// ErrBindArgsConversion reports that BindArgs could not convert a string argument into a field type. // ErrBindArgsConversion reports that BindArgs could not convert a string argument into a field type.
ErrBindArgsConversion = errors.New("bind args: conversion failed") ErrBindArgsConversion = errors.New("bind args: conversion failed")
ErrCantFindSession = errors.New("can't find session for this context") // ErrCantFindSession reports that no scene session matches the current context.
ErrSceneNotFound = errors.New("scene not found") ErrCantFindSession = errors.New("can't find session for this context")
ErrSceneStepNotFound = errors.New("scene step not found") // ErrSceneNotFound reports that the requested scene is not registered.
ErrSceneCommandNotFound = errors.New("scene command not found") ErrSceneNotFound = errors.New("scene not found")
ErrNotInScene = errors.New("not in scene") // 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 { func validateMessageText(text string) error {
+3 -3
View File
@@ -1,12 +1,12 @@
module git.nix13.pw/scuroneko/laniakea module git.scuroneko.dev/scuroneko/laniakea
go 1.26 go 1.26
retract v1.0.0-rc.5 retract v1.0.0-rc.5
require ( require (
git.nix13.pw/scuroneko/extypes v1.2.2 git.scuroneko.dev/scuroneko/extypes v1.2.3
git.nix13.pw/scuroneko/slog v1.1.2 git.scuroneko.dev/scuroneko/slog v1.1.3
github.com/alitto/pond/v2 v2.7.0 github.com/alitto/pond/v2 v2.7.0
golang.org/x/time v0.15.0 golang.org/x/time v0.15.0
) )
+4 -4
View File
@@ -1,7 +1,7 @@
git.nix13.pw/scuroneko/extypes v1.2.2 h1:N54c1ejrPs1yfIkvYuwqI7B1+8S9mDv2GqQA6sct4dk= git.scuroneko.dev/scuroneko/extypes v1.2.3 h1:n7QsfTZEn9fJNZLXGH/LkNq4cADaRk+LTu6LNMv9y6s=
git.nix13.pw/scuroneko/extypes v1.2.2/go.mod h1:b4XYk1OW1dVSiE2MT/OMuX/K/UItf1swytX6eroVYnk= git.scuroneko.dev/scuroneko/extypes v1.2.3/go.mod h1:MhYpXC6sloLOpoM2guf64eSOrz+ET/QJZ8toobc3Ors=
git.nix13.pw/scuroneko/slog v1.1.2 h1:pl7tV5FN25Yso7sLYoOgBXi9+jLo5BDJHWmHlNPjpY0= git.scuroneko.dev/scuroneko/slog v1.1.3 h1:vI4GZykn8gDb6OJ2xq+KLcEk38M7O4e/z1kzpeRHEHw=
git.nix13.pw/scuroneko/slog v1.1.2/go.mod h1:UcfRIHDqpVQHahBGM93awLDK8//AsAvOqBwwbWqMkjM= 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 h1:c76L+yN916m/DRXjGCeUBHHu92uWnh/g1bwVk4zyyXg=
github.com/alitto/pond/v2 v2.7.0/go.mod h1:xkjYEgQ05RSpWdfSd1nM3OVv7TBhLdy7rMp3+2Nq+yE= 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= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
+53 -248
View File
@@ -6,9 +6,9 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"strings" "time"
"git.nix13.pw/scuroneko/laniakea/tgapi" "git.scuroneko.dev/scuroneko/laniakea/tgapi"
) )
// ErrInvalidPayloadType is returned when callback payload encoding type is unknown. // ErrInvalidPayloadType is returned when callback payload encoding type is unknown.
@@ -20,6 +20,7 @@ func (bot *Bot[T]) handle(parentCtx context.Context, u *tgapi.Update) {
bot.logger.Errorln(fmt.Sprintf("panic in handle: %v", r)) bot.logger.Errorln(fmt.Sprintf("panic in handle: %v", r))
} }
}() }()
startTime := time.Now()
ctx, cancel := context.WithCancel(parentCtx) ctx, cancel := context.WithCancel(parentCtx)
defer cancel() defer cancel()
@@ -31,13 +32,20 @@ func (bot *Bot[T]) handle(parentCtx context.Context, u *tgapi.Update) {
l10n: bot.l10n, l10n: bot.l10n,
draftProvider: bot.draftProvider, draftProvider: bot.draftProvider,
sceneRuntime: bot, sceneRuntime: bot,
observer: bot.observer,
payloadType: bot.payloadType, payloadType: bot.payloadType,
ctx: ctx, ctx: ctx,
} }
bot.prepareUpdateCtx(u, msgCtx) bot.prepareUpdateCtx(u, msgCtx)
bot.safeEmitEvent(ctx, UpdateReceivedEvent{
UpdateID: u.UpdateID,
UpdateType: u.Type,
FromID: msgCtx.FromID,
ChatID: msgCtx.ChatID,
})
for _, middleware := range bot.middlewares { for _, middleware := range bot.middlewares {
if !middleware.Execute(msgCtx, bot.dbContext) { if !middleware.Execute(msgCtx, bot.appData) {
return return
} }
} }
@@ -45,117 +53,56 @@ func (bot *Bot[T]) handle(parentCtx context.Context, u *tgapi.Update) {
sceneHandled, err := bot.tryHandleScene(msgCtx) sceneHandled, err := bot.tryHandleScene(msgCtx)
if err != nil { if err != nil {
bot.logger.Errorln(err) bot.logger.Errorln(err)
bot.safeEmitEvent(ctx, UpdateHandledEvent{
UpdateID: u.UpdateID,
UpdateType: u.Type,
FromID: msgCtx.FromID,
ChatID: msgCtx.ChatID,
Duration: time.Since(startTime),
Handled: false,
})
bot.safeEmitEvent(ctx, ErrorEvent{
UpdateID: u.UpdateID,
UpdateType: u.Type,
Plugin: "bot",
HandlerKind: HandlerSceneKind,
HandlerName: "tryHandleScene",
FromID: msgCtx.FromID,
ChatID: msgCtx.ChatID,
Err: err,
UserFacing: false,
})
return return
} }
if sceneHandled { if sceneHandled {
bot.safeEmitEvent(ctx, UpdateHandledEvent{
UpdateID: u.UpdateID,
UpdateType: u.Type,
FromID: msgCtx.FromID,
ChatID: msgCtx.ChatID,
Duration: time.Since(startTime),
Handled: true,
})
return return
} }
handled := false
switch u.Type { switch u.Type {
case tgapi.UpdateTypeMessage, tgapi.UpdateTypeChannelPost: case tgapi.UpdateTypeMessage, tgapi.UpdateTypeChannelPost:
bot.handleMessage(u, msgCtx) handled = bot.handleMessage(u, msgCtx)
case tgapi.UpdateTypeCallbackQuery: case tgapi.UpdateTypeCallbackQuery:
bot.handleCallback(u, msgCtx) handled = bot.handleCallback(u, msgCtx)
default: default:
bot.handleUpdate(u, msgCtx) handled = bot.handleUpdate(u, msgCtx)
}
}
func (bot *Bot[T]) handleMessage(update *tgapi.Update, ctx *MsgContext) {
var msg *tgapi.Message
if update.Message != nil {
msg = update.Message
} else if update.ChannelPost != nil {
msg = update.ChannelPost
} else {
return
}
var text string
if len(msg.Text) > 0 {
text = msg.Text
} else if len(msg.Caption) > 0 {
text = msg.Caption
} else {
return
}
prefix, cmd, args := bot.parseCommand(text)
if cmd == "" {
return
}
ctx.Prefix = prefix
if strings.Contains(cmd, "@") {
botUsername := bot.username
if botUsername != "" && strings.HasSuffix(cmd, "@"+botUsername) {
cmd = cmd[:len(cmd)-len("@"+botUsername)] // убираем @botname
}
}
// Ищем команду по точному совпадению
for _, plugin := range bot.plugins {
if _, exists := plugin.commands[cmd]; exists {
ctx.Text = args
ctx.Args = strings.Fields(args) // Убирает лишние пробелы
if plugin.logger != nil {
ctx.Logger = plugin.logger
}
if !plugin.executeMiddlewares(ctx, bot.dbContext) {
return
}
plugin.executeCmd(cmd, ctx, bot.dbContext)
return
}
}
}
func (bot *Bot[T]) handleCallback(update *tgapi.Update, ctx *MsgContext) {
data, err := bot.decodePayload(update.CallbackQuery.Data)
if err != nil {
bot.logger.Errorln(err)
return
}
ctx.Args = data.Args
for _, plugin := range bot.plugins {
_, ok := plugin.payloads[data.Command]
if !ok {
continue
}
ctx.Logger = plugin.logger
if ctx.Logger == nil {
ctx.Logger = bot.logger
}
if !plugin.executeMiddlewares(ctx, bot.dbContext) {
return
}
plugin.executePayload(data.Command, ctx, bot.dbContext)
return
}
}
func (bot *Bot[T]) handleUpdate(u *tgapi.Update, ctx *MsgContext) {
for _, plugin := range bot.plugins {
handler, ok := plugin.handlers[u.Type]
if !ok {
continue
}
pluginCtx := cloneMsgContext(ctx)
if plugin.logger != nil {
pluginCtx.Logger = plugin.logger
}
if !plugin.executeMiddlewares(pluginCtx, bot.dbContext) {
continue
}
if err := handler(pluginCtx, bot.dbContext); err != nil {
pluginCtx.error(err)
}
} }
bot.safeEmitEvent(ctx, UpdateHandledEvent{
UpdateID: u.UpdateID,
UpdateType: u.Type,
FromID: msgCtx.FromID,
ChatID: msgCtx.ChatID,
Duration: time.Since(startTime),
Handled: handled,
})
} }
func cloneMsgContext(src *MsgContext) *MsgContext { func cloneMsgContext(src *MsgContext) *MsgContext {
@@ -166,139 +113,6 @@ func cloneMsgContext(src *MsgContext) *MsgContext {
return &cloned return &cloned
} }
func (bot *Bot[T]) prepareUpdateCtx(u *tgapi.Update, ctx *MsgContext) {
var from *tgapi.User
switch u.Type {
case tgapi.UpdateTypeMessage:
if u.Message != nil {
ctx.Msg = u.Message
}
case tgapi.UpdateTypeEditedMessage:
if u.EditedMessage != nil {
ctx.Msg = u.EditedMessage
}
case tgapi.UpdateTypeChannelPost:
if u.ChannelPost != nil {
ctx.Msg = u.ChannelPost
}
case tgapi.UpdateTypeEditedChannelPost:
if u.EditedChannelPost != nil {
ctx.Msg = u.EditedChannelPost
}
case tgapi.UpdateTypeBusinessMessage:
if u.BusinessMessage != nil {
ctx.Msg = u.BusinessMessage
}
case tgapi.UpdateTypeEditedBusinessMessage:
if u.EditedBusinessMessage != nil {
ctx.Msg = u.EditedBusinessMessage
}
case tgapi.UpdateTypeInlineQuery:
if u.InlineQuery != nil {
from = &u.InlineQuery.From
}
case tgapi.UpdateTypeChosenInlineResult:
if u.ChosenInlineResult != nil {
from = &u.ChosenInlineResult.From
}
case tgapi.UpdateTypeCallbackQuery:
if u.CallbackQuery != nil {
if u.CallbackQuery.Message != nil {
ctx.Msg = u.CallbackQuery.Message
ctx.CallbackMsgId = u.CallbackQuery.Message.MessageID
}
if u.CallbackQuery.InlineMessageID != nil {
ctx.InlineMsgId = *u.CallbackQuery.InlineMessageID
}
ctx.CallbackQueryId = u.CallbackQuery.ID
from = &u.CallbackQuery.From
}
case tgapi.UpdateTypeShippingQuery:
if u.ShippingQuery != nil {
from = &u.ShippingQuery.From
}
case tgapi.UpdateTypePreCheckoutQuery:
if u.PreCheckoutQuery != nil {
from = &u.PreCheckoutQuery.From
}
case tgapi.UpdateTypePurchasedPaidMedia:
if u.PurchasedPaidMedia != nil {
from = &u.PurchasedPaidMedia.From
}
case tgapi.UpdateTypeMyChatMember:
if u.MyChatMember != nil {
from = &u.MyChatMember.From
}
case tgapi.UpdateTypeChatMember:
if u.ChatMember != nil {
from = &u.ChatMember.From
}
case tgapi.UpdateTypeChatJoinRequest:
if u.ChatJoinRequest != nil {
from = &u.ChatJoinRequest.From
}
case tgapi.UpdateTypeBusinessConnection:
if u.BusinessConnection != nil {
from = &u.BusinessConnection.User
}
case tgapi.UpdateTypePollAnswer:
if u.PollAnswer != nil {
from = &u.PollAnswer.User
}
case tgapi.UpdateTypeMessageReaction:
if u.MessageReaction != nil {
from = u.MessageReaction.User
}
case tgapi.UpdateTypeChatBoost:
if u.ChatBoost != nil {
from = &u.ChatBoost.Boost.Source.User
}
case tgapi.UpdateTypeRemovedChatBoost:
if u.RemovedChatBoost != nil {
from = &u.RemovedChatBoost.Source.User
}
}
if ctx.Msg != nil && from == nil {
from = ctx.Msg.From
}
if from != nil {
ctx.From = from
ctx.FromID = from.ID
}
}
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) {
return prefix, true
}
}
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) { func encodeJsonPayload(d CallbackData) (string, error) {
b, err := json.Marshal(d) b, err := json.Marshal(d)
if err != nil { if err != nil {
@@ -306,11 +120,13 @@ func encodeJsonPayload(d CallbackData) (string, error) {
} }
return string(b), nil return string(b), nil
} }
func decodeJsonPayload(s string) (CallbackData, error) { func decodeJsonPayload(s string) (CallbackData, error) {
var data CallbackData var data CallbackData
err := json.Unmarshal([]byte(s), &data) err := json.Unmarshal([]byte(s), &data)
return data, err return data, err
} }
func encodeBase64Payload(d CallbackData) (string, error) { func encodeBase64Payload(d CallbackData) (string, error) {
data, err := encodeJsonPayload(d) data, err := encodeJsonPayload(d)
if err != nil { if err != nil {
@@ -321,15 +137,6 @@ func encodeBase64Payload(d CallbackData) (string, error) {
return string(dst), nil return string(dst), nil
} }
// func encodePayload(payloadType BotPayloadType, d CallbackData) (string, error) {
// switch payloadType {
// case BotPayloadBase64:
// return encodeBase64Payload(d)
// case BotPayloadJson:
// return encodeJsonPayload(d)
// }
// return "", ErrInvalidPayloadType
// }
func decodeBase64Payload(s string) (CallbackData, error) { func decodeBase64Payload(s string) (CallbackData, error) {
b, err := base64.RawURLEncoding.DecodeString(s) b, err := base64.RawURLEncoding.DecodeString(s)
if err != nil { if err != nil {
@@ -337,6 +144,7 @@ func decodeBase64Payload(s string) (CallbackData, error) {
} }
return decodeJsonPayload(string(b)) return decodeJsonPayload(string(b))
} }
func decodePayload(payloadType BotPayloadType, s string, strict bool) (CallbackData, BotPayloadType, error) { func decodePayload(payloadType BotPayloadType, s string, strict bool) (CallbackData, BotPayloadType, error) {
switch payloadType { switch payloadType {
case BotPayloadBase64: case BotPayloadBase64:
@@ -369,9 +177,6 @@ func decodePayload(payloadType BotPayloadType, s string, strict bool) (CallbackD
return CallbackData{}, "", ErrInvalidPayloadType return CallbackData{}, "", ErrInvalidPayloadType
} }
// func (bot *Bot[T]) encodePayload(d CallbackData) (string, error) {
// return encodePayload(bot.payloadType, d)
// }
func (bot *Bot[T]) decodePayload(s string) (CallbackData, error) { func (bot *Bot[T]) decodePayload(s string) (CallbackData, error) {
data, decodedType, err := decodePayload(bot.payloadType, s, bot.strictPayloadType) data, decodedType, err := decodePayload(bot.payloadType, s, bot.strictPayloadType)
if err != nil { if err != nil {
+776 -30
View File
@@ -2,14 +2,50 @@ package laniakea
import ( import (
"context" "context"
"errors"
"testing" "testing"
"git.nix13.pw/scuroneko/laniakea/tgapi" "git.scuroneko.dev/scuroneko/laniakea/tgapi"
"git.nix13.pw/scuroneko/slog" "git.scuroneko.dev/scuroneko/slog"
) )
func ptr[T any](v T) *T {
return &v
}
type recordingObserver struct {
started []HandlerStartedEvent
finished []HandlerFinishedEvent
errors []ErrorEvent
policies []PolicyCheckedEvent
runners []RunnerFinishedEvent
retries []PollingRetryEvent
}
func (*recordingObserver) OnReceiveUpdate(context.Context, UpdateReceivedEvent) {}
func (*recordingObserver) OnHandledUpdate(context.Context, UpdateHandledEvent) {}
func (o *recordingObserver) OnHandlerStarted(_ context.Context, ev HandlerStartedEvent) {
o.started = append(o.started, ev)
}
func (o *recordingObserver) OnHandlerFinished(_ context.Context, ev HandlerFinishedEvent) {
o.finished = append(o.finished, ev)
}
func (*recordingObserver) OnSceneTransition(context.Context, SceneTransitionEvent) {}
func (o *recordingObserver) OnPolicyChecked(_ context.Context, ev PolicyCheckedEvent) {
o.policies = append(o.policies, ev)
}
func (o *recordingObserver) OnRunnerFinished(_ context.Context, ev RunnerFinishedEvent) {
o.runners = append(o.runners, ev)
}
func (o *recordingObserver) OnPollingRetry(_ context.Context, ev PollingRetryEvent) {
o.retries = append(o.retries, ev)
}
func (o *recordingObserver) OnError(_ context.Context, ev ErrorEvent) {
o.errors = append(o.errors, ev)
}
func TestCheckPrefixesSkipsEmptyPrefixes(t *testing.T) { func TestCheckPrefixesSkipsEmptyPrefixes(t *testing.T) {
bot := &Bot[NoDB]{prefixes: []string{"", "/"}} bot := &Bot[NoData]{prefixes: []string{"", "/"}}
if prefix, ok := bot.checkPrefixes("hello"); ok { if prefix, ok := bot.checkPrefixes("hello"); ok {
t.Fatalf("unexpected prefix match for plain text: %q", prefix) t.Fatalf("unexpected prefix match for plain text: %q", prefix)
@@ -23,10 +59,10 @@ func TestBotMiddlewareReceivesLogger(t *testing.T) {
logger := slog.CreateLogger() logger := slog.CreateLogger()
called := false called := false
bot := &Bot[NoDB]{ bot := &Bot[NoData]{
logger: logger, logger: logger,
middlewares: []Middleware[NoDB]{ middlewares: []Middleware[NoData]{
NewMiddleware("logger-check", func(ctx *MsgContext, db NoDB) bool { NewMiddleware("logger-check", func(ctx *MsgContext, db NoData) bool {
called = true called = true
if ctx.Logger != logger { if ctx.Logger != logger {
t.Fatalf("expected bot logger in middleware context, got %#v", ctx.Logger) t.Fatalf("expected bot logger in middleware context, got %#v", ctx.Logger)
@@ -51,8 +87,8 @@ func TestBotMiddlewareReceivesLogger(t *testing.T) {
} }
func TestAddUpdateHandlerRejectsReservedUpdateTypes(t *testing.T) { func TestAddUpdateHandlerRejectsReservedUpdateTypes(t *testing.T) {
plugin := NewPlugin[NoDB]("test") plugin := NewPlugin[NoData]("test")
handler := func(ctx *MsgContext, db NoDB) error { return nil } handler := func(ctx *MsgContext, db NoData) error { return nil }
for _, updateType := range []tgapi.UpdateType{ for _, updateType := range []tgapi.UpdateType{
tgapi.UpdateTypeMessage, tgapi.UpdateTypeMessage,
@@ -74,6 +110,307 @@ func TestAddUpdateHandlerRejectsReservedUpdateTypes(t *testing.T) {
} }
} }
func TestPrepareUpdateCtxContract(t *testing.T) {
tests := []struct {
name string
update *tgapi.Update
wantMsg bool
wantFrom bool
wantFromID int64
wantChat bool
wantChatID int64
wantCallbackID string
wantCallbackMsgID int
wantInlineMsgID string
}{
{
name: "message",
update: &tgapi.Update{
Type: tgapi.UpdateTypeMessage,
Message: &tgapi.Message{
MessageID: 11,
From: &tgapi.User{ID: 101},
Chat: &tgapi.Chat{ID: 1001},
},
},
wantMsg: true,
wantFrom: true,
wantFromID: 101,
wantChat: true,
wantChatID: 1001,
},
{
name: "edited message",
update: &tgapi.Update{
Type: tgapi.UpdateTypeEditedMessage,
EditedMessage: &tgapi.Message{
MessageID: 12,
From: &tgapi.User{ID: 102},
Chat: &tgapi.Chat{ID: 1002},
},
},
wantMsg: true,
wantFrom: true,
wantFromID: 102,
wantChat: true,
wantChatID: 1002,
},
{
name: "channel post sender chat",
update: &tgapi.Update{
Type: tgapi.UpdateTypeChannelPost,
ChannelPost: &tgapi.Message{
MessageID: 13,
Chat: &tgapi.Chat{ID: -1003},
},
},
wantMsg: true,
wantChat: true,
wantChatID: -1003,
},
{
name: "business message",
update: &tgapi.Update{
Type: tgapi.UpdateTypeBusinessMessage,
BusinessMessage: &tgapi.Message{
MessageID: 14,
From: &tgapi.User{ID: 103},
Chat: &tgapi.Chat{ID: 1004},
},
},
wantMsg: true,
wantFrom: true,
wantFromID: 103,
wantChat: true,
wantChatID: 1004,
},
{
name: "inline query",
update: &tgapi.Update{
Type: tgapi.UpdateTypeInlineQuery,
InlineQuery: &tgapi.InlineQuery{ID: "iq", From: tgapi.User{ID: 104}},
},
wantFrom: true,
wantFromID: 104,
},
{
name: "chosen inline result",
update: &tgapi.Update{
Type: tgapi.UpdateTypeChosenInlineResult,
ChosenInlineResult: &tgapi.ChosenInlineResult{ResultID: "res", From: tgapi.User{ID: 105}},
},
wantFrom: true,
wantFromID: 105,
},
{
name: "callback query with message",
update: &tgapi.Update{
Type: tgapi.UpdateTypeCallbackQuery,
CallbackQuery: &tgapi.CallbackQuery{
ID: "cb-1",
From: tgapi.User{ID: 106},
Message: &tgapi.Message{
MessageID: 77,
Chat: &tgapi.Chat{ID: 1005},
},
},
},
wantMsg: true,
wantFrom: true,
wantFromID: 106,
wantChat: true,
wantChatID: 1005,
wantCallbackID: "cb-1",
wantCallbackMsgID: 77,
},
{
name: "callback query with inline message",
update: &tgapi.Update{
Type: tgapi.UpdateTypeCallbackQuery,
CallbackQuery: &tgapi.CallbackQuery{
ID: "cb-2",
From: tgapi.User{ID: 107},
InlineMessageID: ptr("inline-42"),
},
},
wantFrom: true,
wantFromID: 107,
wantCallbackID: "cb-2",
wantInlineMsgID: "inline-42",
},
{
name: "shipping query",
update: &tgapi.Update{
Type: tgapi.UpdateTypeShippingQuery,
ShippingQuery: &tgapi.ShippingQuery{ID: "ship", From: tgapi.User{ID: 108}},
},
wantFrom: true,
wantFromID: 108,
},
{
name: "pre checkout query",
update: &tgapi.Update{
Type: tgapi.UpdateTypePreCheckoutQuery,
PreCheckoutQuery: &tgapi.PreCheckoutQuery{ID: "pre", From: tgapi.User{ID: 109}},
},
wantFrom: true,
wantFromID: 109,
},
{
name: "purchased paid media",
update: &tgapi.Update{
Type: tgapi.UpdateTypePurchasedPaidMedia,
PurchasedPaidMedia: &tgapi.PaidMediaPurchased{From: tgapi.User{ID: 110}},
},
wantFrom: true,
wantFromID: 110,
},
{
name: "my chat member",
update: &tgapi.Update{
Type: tgapi.UpdateTypeMyChatMember,
MyChatMember: &tgapi.ChatMemberUpdated{From: tgapi.User{ID: 111}, Chat: tgapi.Chat{ID: -2001}},
},
wantFrom: true,
wantFromID: 111,
wantChat: true,
wantChatID: -2001,
},
{
name: "chat member",
update: &tgapi.Update{
Type: tgapi.UpdateTypeChatMember,
ChatMember: &tgapi.ChatMemberUpdated{From: tgapi.User{ID: 112}, Chat: tgapi.Chat{ID: -2002}},
},
wantFrom: true,
wantFromID: 112,
wantChat: true,
wantChatID: -2002,
},
{
name: "chat join request",
update: &tgapi.Update{
Type: tgapi.UpdateTypeChatJoinRequest,
ChatJoinRequest: &tgapi.ChatJoinRequest{From: tgapi.User{ID: 113}, Chat: tgapi.Chat{ID: -2003}},
},
wantFrom: true,
wantFromID: 113,
wantChat: true,
wantChatID: -2003,
},
{
name: "business connection",
update: &tgapi.Update{
Type: tgapi.UpdateTypeBusinessConnection,
BusinessConnection: &tgapi.BusinessConnection{User: tgapi.User{ID: 114}},
},
wantFrom: true,
wantFromID: 114,
},
{
name: "poll answer",
update: &tgapi.Update{
Type: tgapi.UpdateTypePollAnswer,
PollAnswer: &tgapi.PollAnswer{User: tgapi.User{ID: 115}},
},
wantFrom: true,
wantFromID: 115,
},
{
name: "message reaction",
update: &tgapi.Update{
Type: tgapi.UpdateTypeMessageReaction,
MessageReaction: &tgapi.MessageReactionUpdated{User: &tgapi.User{ID: 116}, Chat: &tgapi.Chat{ID: -2004}},
},
wantFrom: true,
wantFromID: 116,
wantChat: true,
wantChatID: -2004,
},
{
name: "chat boost",
update: &tgapi.Update{
Type: tgapi.UpdateTypeChatBoost,
ChatBoost: &tgapi.ChatBoostUpdated{
Chat: tgapi.Chat{ID: -2005},
Boost: tgapi.ChatBoost{Source: tgapi.ChatBoostSource{User: tgapi.User{ID: 117}}},
},
},
wantFrom: true,
wantFromID: 117,
wantChat: true,
wantChatID: -2005,
},
{
name: "removed chat boost",
update: &tgapi.Update{
Type: tgapi.UpdateTypeRemovedChatBoost,
RemovedChatBoost: &tgapi.ChatBoostRemoved{
Chat: tgapi.Chat{ID: -2006},
Source: tgapi.ChatBoostSource{User: tgapi.User{ID: 118}},
},
},
wantFrom: true,
wantFromID: 118,
wantChat: true,
wantChatID: -2006,
},
{
name: "poll",
update: &tgapi.Update{
Type: tgapi.UpdateTypePoll,
Poll: &tgapi.Poll{ID: "poll"},
},
},
{
name: "message reaction count",
update: &tgapi.Update{
Type: tgapi.UpdateTypeMessageReactionCount,
MessageReactionCount: &tgapi.MessageReactionCountUpdated{},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
bot := &Bot[NoData]{}
ctx := &MsgContext{}
bot.prepareUpdateCtx(tt.update, ctx)
if got := ctx.Msg != nil; got != tt.wantMsg {
t.Fatalf("unexpected Msg presence: got %v want %v", got, tt.wantMsg)
}
if got := ctx.From != nil; got != tt.wantFrom {
t.Fatalf("unexpected From presence: got %v want %v", got, tt.wantFrom)
}
if ctx.FromID != tt.wantFromID {
t.Fatalf("unexpected FromID: got %d want %d", ctx.FromID, tt.wantFromID)
}
if got := ctx.Chat != nil; got != tt.wantChat {
t.Fatalf("unexpected Chat presence: got %v want %v", got, tt.wantChat)
}
if ctx.ChatID != tt.wantChatID {
t.Fatalf("unexpected ChatID: got %d want %d", ctx.ChatID, tt.wantChatID)
}
if ctx.CallbackQueryId != tt.wantCallbackID {
t.Fatalf("unexpected CallbackQueryId: got %q want %q", ctx.CallbackQueryId, tt.wantCallbackID)
}
if ctx.CallbackMsgId != tt.wantCallbackMsgID {
t.Fatalf("unexpected CallbackMsgId: got %d want %d", ctx.CallbackMsgId, tt.wantCallbackMsgID)
}
if ctx.InlineMsgId != tt.wantInlineMsgID {
t.Fatalf("unexpected InlineMsgId: got %q want %q", ctx.InlineMsgId, tt.wantInlineMsgID)
}
if ctx.Text != "" {
t.Fatalf("prepareUpdateCtx must not populate Text, got %q", ctx.Text)
}
if len(ctx.Args) != 0 {
t.Fatalf("prepareUpdateCtx must not populate Args, got %v", ctx.Args)
}
})
}
}
func TestHandleUpdateHandlersPopulateFromContext(t *testing.T) { func TestHandleUpdateHandlersPopulateFromContext(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@@ -111,7 +448,7 @@ func TestHandleUpdateHandlersPopulateFromContext(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
called := false called := false
plugin := NewPlugin[NoDB]("test").AddUpdateHandler(tt.update.Type, func(ctx *MsgContext, db NoDB) error { plugin := NewPlugin[NoData]("test").AddUpdateHandler(tt.update.Type, func(ctx *MsgContext, db NoData) error {
called = true called = true
if ctx.Update.UpdateID != tt.update.UpdateID { if ctx.Update.UpdateID != tt.update.UpdateID {
t.Fatalf("unexpected update in context: got %d want %d", ctx.Update.UpdateID, tt.update.UpdateID) t.Fatalf("unexpected update in context: got %d want %d", ctx.Update.UpdateID, tt.update.UpdateID)
@@ -131,9 +468,9 @@ func TestHandleUpdateHandlersPopulateFromContext(t *testing.T) {
return nil return nil
}) })
bot := &Bot[NoDB]{ bot := &Bot[NoData]{
logger: slog.CreateLogger(), logger: slog.CreateLogger(),
plugins: []Plugin[NoDB]{clonePlugin(plugin)}, plugins: []Plugin[NoData]{clonePlugin(plugin)},
} }
bot.handle(context.Background(), tt.update) bot.handle(context.Background(), tt.update)
@@ -149,7 +486,7 @@ func TestHandleUpdateHandlersReceiveIsolatedContexts(t *testing.T) {
firstCalled := false firstCalled := false
secondCalled := false secondCalled := false
first := NewPlugin[NoDB]("first").AddUpdateHandler(tgapi.UpdateTypeInlineQuery, func(ctx *MsgContext, db NoDB) error { first := NewPlugin[NoData]("first").AddUpdateHandler(tgapi.UpdateTypeInlineQuery, func(ctx *MsgContext, db NoData) error {
firstCalled = true firstCalled = true
if ctx.FromID != 41 { if ctx.FromID != 41 {
t.Fatalf("unexpected FromID in first handler: got %d want 41", ctx.FromID) t.Fatalf("unexpected FromID in first handler: got %d want 41", ctx.FromID)
@@ -160,7 +497,7 @@ func TestHandleUpdateHandlersReceiveIsolatedContexts(t *testing.T) {
ctx.Args = []string{"mutated"} ctx.Args = []string{"mutated"}
return nil return nil
}) })
second := NewPlugin[NoDB]("second").AddUpdateHandler(tgapi.UpdateTypeInlineQuery, func(ctx *MsgContext, db NoDB) error { second := NewPlugin[NoData]("second").AddUpdateHandler(tgapi.UpdateTypeInlineQuery, func(ctx *MsgContext, db NoData) error {
secondCalled = true secondCalled = true
if ctx.From == nil { if ctx.From == nil {
t.Fatal("expected ctx.From to remain populated for second handler") t.Fatal("expected ctx.From to remain populated for second handler")
@@ -177,9 +514,9 @@ func TestHandleUpdateHandlersReceiveIsolatedContexts(t *testing.T) {
return nil return nil
}) })
bot := &Bot[NoDB]{ bot := &Bot[NoData]{
logger: slog.CreateLogger(), logger: slog.CreateLogger(),
plugins: []Plugin[NoDB]{ plugins: []Plugin[NoData]{
clonePlugin(first), clonePlugin(first),
clonePlugin(second), clonePlugin(second),
}, },
@@ -200,10 +537,61 @@ func TestHandleUpdateHandlersReceiveIsolatedContexts(t *testing.T) {
} }
} }
func TestHandleUpdateObserverEmitsUpdateErrors(t *testing.T) {
observer := &recordingObserver{}
plugin := NewPlugin[NoData]("test").AddUpdateHandler(tgapi.UpdateTypeInlineQuery, func(ctx *MsgContext, db NoData) error {
return AsUserError(errors.New("update failed"))
})
bot := &Bot[NoData]{
logger: slog.CreateLogger(),
plugins: []Plugin[NoData]{clonePlugin(plugin)},
observer: observer,
}
bot.handle(context.Background(), &tgapi.Update{
UpdateID: 4,
Type: tgapi.UpdateTypeInlineQuery,
InlineQuery: &tgapi.InlineQuery{
ID: "iq",
From: tgapi.User{ID: 41},
},
})
if len(observer.errors) != 1 {
t.Fatalf("expected one observer error event, got %d", len(observer.errors))
}
ev := observer.errors[0]
if ev.Plugin != "test" {
t.Fatalf("unexpected plugin: %q", ev.Plugin)
}
if ev.HandlerKind != HandlerUpdateKind {
t.Fatalf("unexpected handler kind: %q", ev.HandlerKind)
}
if ev.HandlerName != string(tgapi.UpdateTypeInlineQuery) {
t.Fatalf("unexpected handler name: %q", ev.HandlerName)
}
if !ev.UserFacing {
t.Fatal("expected update error to be marked user-facing")
}
if len(observer.started) != 1 {
t.Fatalf("expected one handler started event, got %d", len(observer.started))
}
if got := observer.started[0]; got.HandlerKind != HandlerUpdateKind || got.HandlerName != string(tgapi.UpdateTypeInlineQuery) || got.Plugin != "test" {
t.Fatalf("unexpected started event: %#v", got)
}
if len(observer.finished) != 1 {
t.Fatalf("expected one handler finished event, got %d", len(observer.finished))
}
if got := observer.finished[0]; got.HandlerKind != HandlerUpdateKind || got.HandlerName != string(tgapi.UpdateTypeInlineQuery) || got.Plugin != "test" || got.Err == nil || !got.UserFacing {
t.Fatalf("unexpected finished event: %#v", got)
}
}
func TestHandleChannelPostCommandWithSenderChat(t *testing.T) { func TestHandleChannelPostCommandWithSenderChat(t *testing.T) {
called := false called := false
plugin := NewPlugin[NoDB]("test") plugin := NewPlugin[NoData]("test")
plugin.NewCommand(func(ctx *MsgContext, db NoDB) error { plugin.NewCommand(func(ctx *MsgContext, db NoData) error {
called = true called = true
if ctx.Msg == nil { if ctx.Msg == nil {
t.Fatal("expected message context") t.Fatal("expected message context")
@@ -220,10 +608,10 @@ func TestHandleChannelPostCommandWithSenderChat(t *testing.T) {
return nil return nil
}, "ping") }, "ping")
bot := &Bot[NoDB]{ bot := &Bot[NoData]{
logger: slog.CreateLogger(), logger: slog.CreateLogger(),
prefixes: []string{"/"}, prefixes: []string{"/"},
plugins: []Plugin[NoDB]{clonePlugin(plugin)}, plugins: []Plugin[NoData]{clonePlugin(plugin)},
} }
bot.handle(context.Background(), &tgapi.Update{ bot.handle(context.Background(), &tgapi.Update{
@@ -232,8 +620,8 @@ func TestHandleChannelPostCommandWithSenderChat(t *testing.T) {
ChannelPost: &tgapi.Message{ ChannelPost: &tgapi.Message{
MessageID: 55, MessageID: 55,
Text: "/ping", Text: "/ping",
SenderChat: &tgapi.Chat{ID: -1001, Type: string(tgapi.ChatTypeChannel)}, SenderChat: &tgapi.Chat{ID: -1001, Type: tgapi.ChatTypeChannel},
Chat: &tgapi.Chat{ID: -1001, Type: string(tgapi.ChatTypeChannel)}, Chat: &tgapi.Chat{ID: -1001, Type: tgapi.ChatTypeChannel},
}, },
}) })
@@ -249,18 +637,18 @@ func TestCommandHandlerBindArgsEndToEnd(t *testing.T) {
} }
var got banInput var got banInput
plugin := NewPlugin[NoDB]("test") plugin := NewPlugin[NoData]("test")
plugin.NewCommand(func(ctx *MsgContext, db NoDB) error { plugin.NewCommand(func(ctx *MsgContext, db NoData) error {
return ctx.BindArgs(&got) return ctx.BindArgs(&got)
}, "ban", }, "ban",
NewCommandArg("user_id").SetValueType(CommandValueIntType).SetRequired(), NewCommandArg("user_id").SetValueType(CommandValueIntType).SetRequired(),
NewCommandArg("reason").SetRequired(), NewCommandArg("reason").SetRequired(),
) )
bot := &Bot[NoDB]{ bot := &Bot[NoData]{
logger: slog.CreateLogger(), logger: slog.CreateLogger(),
prefixes: []string{"/"}, prefixes: []string{"/"},
plugins: []Plugin[NoDB]{clonePlugin(plugin)}, plugins: []Plugin[NoData]{clonePlugin(plugin)},
} }
bot.handle(context.Background(), &tgapi.Update{ bot.handle(context.Background(), &tgapi.Update{
@@ -269,7 +657,7 @@ func TestCommandHandlerBindArgsEndToEnd(t *testing.T) {
Message: &tgapi.Message{ Message: &tgapi.Message{
MessageID: 1, MessageID: 1,
Text: "/ban 42 too loud", Text: "/ban 42 too loud",
Chat: &tgapi.Chat{ID: 99, Type: string(tgapi.ChatTypePrivate)}, Chat: &tgapi.Chat{ID: 99, Type: tgapi.ChatTypePrivate},
}, },
}) })
@@ -286,18 +674,18 @@ func TestPayloadHandlerBindArgsEndToEnd(t *testing.T) {
} }
var got payloadInput var got payloadInput
plugin := NewPlugin[NoDB]("test") plugin := NewPlugin[NoData]("test")
plugin.NewPayload(func(ctx *MsgContext, db NoDB) error { plugin.NewPayload(func(ctx *MsgContext, db NoData) error {
return ctx.BindArgs(&got) return ctx.BindArgs(&got)
}, "approve", }, "approve",
NewCommandArg("id").SetValueType(CommandValueIntType).SetRequired(), NewCommandArg("id").SetValueType(CommandValueIntType).SetRequired(),
NewCommandArg("note").SetRequired(), NewCommandArg("note").SetRequired(),
) )
bot := &Bot[NoDB]{ bot := &Bot[NoData]{
logger: slog.CreateLogger(), logger: slog.CreateLogger(),
payloadType: BotPayloadJson, payloadType: BotPayloadJson,
plugins: []Plugin[NoDB]{clonePlugin(plugin)}, plugins: []Plugin[NoData]{clonePlugin(plugin)},
} }
data, err := encodeJsonPayload(CallbackData{ data, err := encodeJsonPayload(CallbackData{
@@ -323,3 +711,361 @@ func TestPayloadHandlerBindArgsEndToEnd(t *testing.T) {
t.Fatalf("unexpected bound payload input: got %#v want %#v", got, want) t.Fatalf("unexpected bound payload input: got %#v want %#v", got, want)
} }
} }
func TestHandleEditedMessageStaysOutOfCommandFlow(t *testing.T) {
commandCalled := false
updateCalled := false
plugin := NewPlugin[NoData]("test")
plugin.NewCommand(func(ctx *MsgContext, db NoData) error {
commandCalled = true
return nil
}, "ping")
plugin.AddUpdateHandler(tgapi.UpdateTypeEditedMessage, func(ctx *MsgContext, db NoData) error {
updateCalled = true
if ctx.Msg == nil {
t.Fatal("expected ctx.Msg in edited message handler")
}
if ctx.Text != "" {
t.Fatalf("expected empty Text in edited_message update handler, got %q", ctx.Text)
}
if len(ctx.Args) != 0 {
t.Fatalf("expected empty Args in edited_message update handler, got %v", ctx.Args)
}
return nil
})
bot := &Bot[NoData]{
logger: slog.CreateLogger(),
prefixes: []string{"/"},
plugins: []Plugin[NoData]{clonePlugin(plugin)},
}
bot.handle(context.Background(), &tgapi.Update{
UpdateID: 20,
Type: tgapi.UpdateTypeEditedMessage,
EditedMessage: &tgapi.Message{
MessageID: 1,
Text: "/ping",
From: &tgapi.User{ID: 1},
Chat: &tgapi.Chat{ID: 42},
},
})
if commandCalled {
t.Fatal("edited_message must not enter command flow")
}
if !updateCalled {
t.Fatal("expected edited_message update handler to be called")
}
}
func TestHandleEditedChannelPostStaysOutOfCommandFlow(t *testing.T) {
commandCalled := false
updateCalled := false
plugin := NewPlugin[NoData]("test")
plugin.NewCommand(func(ctx *MsgContext, db NoData) error {
commandCalled = true
return nil
}, "ping")
plugin.AddUpdateHandler(tgapi.UpdateTypeEditedChannelPost, func(ctx *MsgContext, db NoData) error {
updateCalled = true
if ctx.Msg == nil {
t.Fatal("expected ctx.Msg in edited channel post handler")
}
return nil
})
bot := &Bot[NoData]{
logger: slog.CreateLogger(),
prefixes: []string{"/"},
plugins: []Plugin[NoData]{clonePlugin(plugin)},
}
bot.handle(context.Background(), &tgapi.Update{
UpdateID: 21,
Type: tgapi.UpdateTypeEditedChannelPost,
EditedChannelPost: &tgapi.Message{
MessageID: 1,
Text: "/ping",
Chat: &tgapi.Chat{ID: -10042},
},
})
if commandCalled {
t.Fatal("edited_channel_post must not enter command flow")
}
if !updateCalled {
t.Fatal("expected edited_channel_post update handler to be called")
}
}
func TestHandleCallbackPopulatesMessageTargets(t *testing.T) {
called := false
plugin := NewPlugin[NoData]("test")
plugin.NewPayload(func(ctx *MsgContext, db NoData) error {
called = true
if ctx.CallbackQueryId != "cb-msg" {
t.Fatalf("unexpected CallbackQueryId: %q", ctx.CallbackQueryId)
}
if ctx.CallbackMsgId != 55 {
t.Fatalf("unexpected CallbackMsgId: %d", ctx.CallbackMsgId)
}
if ctx.InlineMsgId != "" {
t.Fatalf("did not expect InlineMsgId, got %q", ctx.InlineMsgId)
}
if ctx.Msg == nil {
t.Fatal("expected callback message context")
}
if ctx.From == nil || ctx.FromID != 7 {
t.Fatalf("unexpected callback sender: %#v / %d", ctx.From, ctx.FromID)
}
if ctx.Text != "" {
t.Fatalf("callback flow must not populate Text, got %q", ctx.Text)
}
if got, want := ctx.Args, []string{"7", "ok"}; len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
t.Fatalf("unexpected callback args: got %v want %v", got, want)
}
return nil
}, "approve")
bot := &Bot[NoData]{
logger: slog.CreateLogger(),
payloadType: BotPayloadJson,
plugins: []Plugin[NoData]{clonePlugin(plugin)},
}
data, err := encodeJsonPayload(CallbackData{Command: "approve", Args: []string{"7", "ok"}})
if err != nil {
t.Fatalf("encodeJsonPayload returned error: %v", err)
}
bot.handle(context.Background(), &tgapi.Update{
UpdateID: 30,
Type: tgapi.UpdateTypeCallbackQuery,
CallbackQuery: &tgapi.CallbackQuery{
ID: "cb-msg",
Data: data,
From: tgapi.User{ID: 7},
Message: &tgapi.Message{
MessageID: 55,
Chat: &tgapi.Chat{ID: 77},
},
},
})
if !called {
t.Fatal("expected payload handler to be called")
}
}
func TestHandleCallbackPopulatesInlineTargets(t *testing.T) {
called := false
plugin := NewPlugin[NoData]("test")
plugin.NewPayload(func(ctx *MsgContext, db NoData) error {
called = true
if ctx.CallbackQueryId != "cb-inline" {
t.Fatalf("unexpected CallbackQueryId: %q", ctx.CallbackQueryId)
}
if ctx.CallbackMsgId != 0 {
t.Fatalf("did not expect CallbackMsgId, got %d", ctx.CallbackMsgId)
}
if ctx.InlineMsgId != "inline-55" {
t.Fatalf("unexpected InlineMsgId: %q", ctx.InlineMsgId)
}
if ctx.Msg != nil {
t.Fatalf("did not expect callback chat message context, got %#v", ctx.Msg)
}
if ctx.From == nil || ctx.FromID != 8 {
t.Fatalf("unexpected callback sender: %#v / %d", ctx.From, ctx.FromID)
}
if ctx.Text != "" {
t.Fatalf("callback flow must not populate Text, got %q", ctx.Text)
}
if got, want := ctx.Args, []string{"9"}; len(got) != len(want) || got[0] != want[0] {
t.Fatalf("unexpected callback args: got %v want %v", got, want)
}
return nil
}, "inline.approve")
bot := &Bot[NoData]{
logger: slog.CreateLogger(),
payloadType: BotPayloadJson,
plugins: []Plugin[NoData]{clonePlugin(plugin)},
}
data, err := encodeJsonPayload(CallbackData{Command: "inline.approve", Args: []string{"9"}})
if err != nil {
t.Fatalf("encodeJsonPayload returned error: %v", err)
}
bot.handle(context.Background(), &tgapi.Update{
UpdateID: 31,
Type: tgapi.UpdateTypeCallbackQuery,
CallbackQuery: &tgapi.CallbackQuery{
ID: "cb-inline",
Data: data,
From: tgapi.User{ID: 8},
InlineMessageID: ptr("inline-55"),
},
})
if !called {
t.Fatal("expected inline payload handler to be called")
}
}
func TestHandleCallbackObserverEmitsPayloadEvents(t *testing.T) {
observer := &recordingObserver{}
plugin := NewPlugin[NoData]("test")
plugin.NewPayload(func(ctx *MsgContext, db NoData) error {
return nil
}, "approve")
bot := &Bot[NoData]{
logger: slog.CreateLogger(),
payloadType: BotPayloadJson,
plugins: []Plugin[NoData]{clonePlugin(plugin)},
observer: observer,
}
data, err := encodeJsonPayload(CallbackData{Command: "approve", Args: []string{"7"}})
if err != nil {
t.Fatalf("encodeJsonPayload returned error: %v", err)
}
bot.handle(context.Background(), &tgapi.Update{
UpdateID: 32,
Type: tgapi.UpdateTypeCallbackQuery,
CallbackQuery: &tgapi.CallbackQuery{
ID: "cb-observer",
Data: data,
From: tgapi.User{ID: 7},
Message: &tgapi.Message{
MessageID: 56,
Chat: &tgapi.Chat{ID: 78},
},
},
})
if len(observer.started) != 1 {
t.Fatalf("expected one started event, got %d", len(observer.started))
}
if got := observer.started[0]; got.HandlerKind != HandlerPayloadKind || got.HandlerName != "approve" || got.Plugin != "test" {
t.Fatalf("unexpected started event: %#v", got)
}
if len(observer.finished) != 1 {
t.Fatalf("expected one finished event, got %d", len(observer.finished))
}
if got := observer.finished[0]; got.HandlerKind != HandlerPayloadKind || got.HandlerName != "approve" || got.Plugin != "test" || got.Err != nil || got.UserFacing {
t.Fatalf("unexpected finished event: %#v", got)
}
if len(observer.errors) != 0 {
t.Fatalf("did not expect error events, got %#v", observer.errors)
}
}
func TestHandleCallbackObserverEmitsPayloadErrors(t *testing.T) {
observer := &recordingObserver{}
plugin := NewPlugin[NoData]("test")
wantErr := AsInternalError(errors.New("boom"))
plugin.NewPayload(func(ctx *MsgContext, db NoData) error {
return wantErr
}, "approve")
bot := &Bot[NoData]{
logger: slog.CreateLogger(),
payloadType: BotPayloadJson,
plugins: []Plugin[NoData]{clonePlugin(plugin)},
observer: observer,
}
data, err := encodeJsonPayload(CallbackData{Command: "approve", Args: []string{"7"}})
if err != nil {
t.Fatalf("encodeJsonPayload returned error: %v", err)
}
bot.handle(context.Background(), &tgapi.Update{
UpdateID: 33,
Type: tgapi.UpdateTypeCallbackQuery,
CallbackQuery: &tgapi.CallbackQuery{
ID: "cb-observer-err",
Data: data,
From: tgapi.User{ID: 7},
Message: &tgapi.Message{
MessageID: 57,
Chat: &tgapi.Chat{ID: 79},
},
},
})
if len(observer.started) != 1 {
t.Fatalf("expected one started event, got %d", len(observer.started))
}
if len(observer.finished) != 1 {
t.Fatalf("expected one finished event, got %d", len(observer.finished))
}
if got := observer.finished[0]; !errors.Is(got.Err, wantErr) || got.UserFacing {
t.Fatalf("unexpected finished event: %#v", got)
}
if len(observer.errors) != 1 {
t.Fatalf("expected one error event, got %d", len(observer.errors))
}
if got := observer.errors[0]; !errors.Is(got.Err, wantErr) || got.HandlerKind != HandlerPayloadKind || got.HandlerName != "approve" || got.Plugin != "test" || got.UserFacing {
t.Fatalf("unexpected error event: %#v", got)
}
}
func TestHandleCallbackObserverEmitsDecodeErrors(t *testing.T) {
observer := &recordingObserver{}
bot := &Bot[NoData]{
logger: slog.CreateLogger(),
payloadType: BotPayloadJson,
observer: observer,
}
handled := bot.handleCallback(&tgapi.Update{
UpdateID: 34,
Type: tgapi.UpdateTypeCallbackQuery,
CallbackQuery: &tgapi.CallbackQuery{
ID: "cb-bad",
Data: "{not-json",
From: tgapi.User{ID: 7},
},
}, &MsgContext{
Update: tgapi.Update{
UpdateID: 34,
Type: tgapi.UpdateTypeCallbackQuery,
},
Logger: bot.logger,
ctx: context.Background(),
CallbackQueryId: "cb-bad",
From: &tgapi.User{ID: 7},
FromID: 7,
sceneRuntime: bot,
})
if handled {
t.Fatal("expected invalid callback payload to stay unhandled")
}
if len(observer.started) != 0 || len(observer.finished) != 0 {
t.Fatalf("expected no handler lifecycle events for decode failure, got started=%d finished=%d", len(observer.started), len(observer.finished))
}
if len(observer.errors) != 1 {
t.Fatalf("expected one observer error event, got %d", len(observer.errors))
}
ev := observer.errors[0]
if ev.Plugin != "bot" {
t.Fatalf("unexpected plugin: %q", ev.Plugin)
}
if ev.HandlerKind != HandlerPayloadKind {
t.Fatalf("unexpected handler kind: %q", ev.HandlerKind)
}
if ev.HandlerName != "decodePayload" {
t.Fatalf("unexpected handler name: %q", ev.HandlerName)
}
if ev.UserFacing {
t.Fatal("expected decode failure to stay internal")
}
}
+2 -2
View File
@@ -3,8 +3,8 @@ package laniakea
import ( import (
"fmt" "fmt"
"git.nix13.pw/scuroneko/extypes" "git.scuroneko.dev/scuroneko/extypes"
"git.nix13.pw/scuroneko/laniakea/tgapi" "git.scuroneko.dev/scuroneko/laniakea/tgapi"
) )
const ( const (
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"git.nix13.pw/scuroneko/laniakea/tgapi" "git.scuroneko.dev/scuroneko/laniakea/tgapi"
) )
// Updates fetches new updates from Telegram API using long polling. // Updates fetches new updates from Telegram API using long polling.
+109 -23
View File
@@ -4,42 +4,83 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"log"
"reflect" "reflect"
"strconv" "strconv"
"strings" "strings"
"time" "time"
"git.nix13.pw/scuroneko/laniakea/tgapi" "git.scuroneko.dev/scuroneko/laniakea/tgapi"
"git.nix13.pw/scuroneko/slog" "git.scuroneko.dev/scuroneko/slog"
) )
// MsgContext holds the context for handling a Telegram message or callback query. // MsgContext holds the normalized per-update context passed to command, payload,
// It provides methods to respond, edit, delete, and translate messages, as well as // scene, middleware, and generic update handlers.
// manage inline keyboards and message drafts. //
// MsgContext is populated from the current Telegram update before handler routing.
// Not every field is guaranteed for every update kind. In particular:
// - Update is always present.
// - Msg is populated only for update kinds that carry a Telegram message object.
// - From and FromID are populated only when the update exposes a user identity.
// - Chat and ChatID are populated only when the update exposes a chat identity.
// - Text, Args, and Prefix are populated only by command or scene command routing.
// - CallbackQueryId, CallbackMsgId, and InlineMsgId are populated only for
// callback query handling when the corresponding callback targets exist.
//
// Helper methods on MsgContext may require a message-backed context. For example,
// reply helpers need Msg, while inline callback edit helpers can work through
// InlineMsgId when there is no chat message.
type MsgContext struct { type MsgContext struct {
Api *tgapi.API Api *tgapi.API
Update tgapi.Update Update tgapi.Update
Msg *tgapi.Message // Msg is the normalized Telegram message for message-backed update kinds.
// It is nil for updates that do not include a message object.
Msg *tgapi.Message
// From is the normalized Telegram user for update kinds that expose one.
// It stays nil for sender-chat-only updates and update kinds without a user.
From *tgapi.User From *tgapi.User
// Chat is the normalized Telegram chat for update kinds that expose one.
// It is nil for updates that do not include a chat identity.
Chat *tgapi.Chat
// Logger is the logger assigned by the matched plugin for the current handler call. // Logger is the logger assigned by the matched plugin for the current handler call.
// It may fall back to the bot logger when the plugin has no dedicated logger. // It may fall back to the bot logger when the plugin has no dedicated logger.
Logger *slog.Logger Logger *slog.Logger
InlineMsgId string // InlineMsgId is the inline message identifier for callback queries that target
CallbackMsgId int // an inline message instead of a chat message.
InlineMsgId string
// CallbackMsgId is the message ID targeted by the current callback query when
// the callback comes from a chat message.
CallbackMsgId int
// CallbackQueryId is the Telegram callback query ID for payload handlers and
// callback-backed scene handlers.
CallbackQueryId string CallbackQueryId string
FromID int64 // FromID is the normalized sender ID when the current update exposes a user.
Prefix string // It is zero when the update has no user identity.
Text string FromID int64
Args []string // ChatID is the normalized chat ID when the current update exposes a chat.
// It is zero when the update has no chat identity.
ChatID int64
// Prefix is the matched command prefix for command routing and scene-local
// command routing. It is empty outside those flows.
Prefix string
// Text is the parsed command tail for command routing, the parsed scene-command
// tail for scene-local command routing, or the trimmed message text seen by a
// scene step/message handler. It is empty when the current routing path does
// not derive text input.
Text string
// Args contains parsed command or payload arguments for the current routing
// path. It is nil or empty when no argument vector is derived.
Args []string
errorTemplate string errorTemplate string
l10n *L10n l10n *L10n
draftProvider *DraftProvider draftProvider *DraftProvider
payloadType BotPayloadType payloadType BotPayloadType
sceneRuntime sceneRuntime sceneRuntime sceneRuntime
observer Observer
ctx context.Context ctx context.Context
} }
@@ -478,6 +519,13 @@ func (ctx *MsgContext) SendAction(action tgapi.ChatActionType) {
// Internal helper that formats, sends, and logs an error. // Internal helper that formats, sends, and logs an error.
func (ctx *MsgContext) error(err error) { func (ctx *MsgContext) error(err error) {
if err == nil {
return
}
ctx.Logger.Errorln(err)
if IsInternalError(err) {
return
}
text := fmt.Sprintf(ctx.errorTemplate, err.Error()) text := fmt.Sprintf(ctx.errorTemplate, err.Error())
if ctx.CallbackQueryId != "" { if ctx.CallbackQueryId != "" {
@@ -485,7 +533,6 @@ func (ctx *MsgContext) error(err error) {
} else { } else {
ctx.answer(text, nil, tgapi.ParseNone) ctx.answer(text, nil, tgapi.ParseNone)
} }
ctx.Logger.Errorln(err)
} }
// Error is an alias for error(). // Error is an alias for error().
@@ -644,26 +691,59 @@ func (ctx *MsgContext) Context() context.Context {
return ctx.ctx return ctx.ctx
} }
func (ctx *MsgContext) emitPolicyChecked(event PolicyCheckedEvent) {
if ctx == nil || ctx.observer == nil {
return
}
defer func() {
if r := recover(); r != nil {
if ctx.Logger != nil {
ctx.Logger.Errorln(fmt.Sprintf("panic in observer policy event: %v", r))
return
}
log.Printf("panic in observer policy event: %v", r)
}
}()
ctx.observer.OnPolicyChecked(ctx.Context(), event)
}
// EnterScene enters the named scene at its configured entry step.
func (ctx *MsgContext) EnterScene(name string) error { func (ctx *MsgContext) EnterScene(name string) error {
scene, ok := ctx.sceneRuntime.FindScene(name) if ctx.sceneRuntime == nil {
return ErrSceneRuntimeNil
}
scene, ok := ctx.sceneRuntime.findScene(name)
if !ok { if !ok {
return ErrSceneNotFound return ErrSceneNotFound
} }
key, ok := ctx.sceneRuntime.BuildSceneKey(scene.Scope, ctx) key, ok := ctx.sceneRuntime.buildSceneKey(scene.Scope, ctx)
if !ok { if !ok {
return ErrCantFindSession return ErrCantFindSession
} }
if scene.Entry == "" {
return ErrSceneEntryNotSet
}
if _, ok := scene.Steps[scene.Entry]; !ok {
return ErrSceneStepNotFound
}
session := SceneSession{ session := SceneSession{
Scene: scene.Name, Scene: scene.Name,
Step: scene.Entry, Step: scene.Entry,
} }
return ctx.sceneRuntime.SetSession(key, session) return ctx.sceneRuntime.setSession(key, session)
} }
// EnterSceneStep enters the named scene at a specific step.
func (ctx *MsgContext) EnterSceneStep(name, step string) error { func (ctx *MsgContext) EnterSceneStep(name, step string) error {
scene, ok := ctx.sceneRuntime.FindScene(name) if ctx.sceneRuntime == nil {
return ErrSceneRuntimeNil
}
scene, ok := ctx.sceneRuntime.findScene(name)
if !ok { if !ok {
return ErrSceneNotFound return ErrSceneNotFound
} }
@@ -671,7 +751,7 @@ func (ctx *MsgContext) EnterSceneStep(name, step string) error {
return ErrSceneStepNotFound return ErrSceneStepNotFound
} }
key, ok := ctx.sceneRuntime.BuildSceneKey(scene.Scope, ctx) key, ok := ctx.sceneRuntime.buildSceneKey(scene.Scope, ctx)
if !ok { if !ok {
return ErrCantFindSession return ErrCantFindSession
} }
@@ -681,10 +761,16 @@ func (ctx *MsgContext) EnterSceneStep(name, step string) error {
Step: step, Step: step,
} }
return ctx.sceneRuntime.SetSession(key, session) return ctx.sceneRuntime.setSession(key, session)
} }
// ExitScene leaves the currently active scene for this context.
func (ctx *MsgContext) ExitScene() error { func (ctx *MsgContext) ExitScene() error {
_, session, err := ctx.sceneRuntime.FindSceneSession(ctx) if ctx.sceneRuntime == nil {
return ErrSceneRuntimeNil
}
_, session, err := ctx.sceneRuntime.findSceneSession(ctx)
if err != nil { if err != nil {
return err return err
} }
@@ -692,15 +778,15 @@ func (ctx *MsgContext) ExitScene() error {
return ErrNotInScene return ErrNotInScene
} }
scene, ok := ctx.sceneRuntime.FindScene(session.Scene) scene, ok := ctx.sceneRuntime.findScene(session.Scene)
if !ok { if !ok {
return ErrSceneNotFound return ErrSceneNotFound
} }
key, ok := ctx.sceneRuntime.BuildSceneKey(scene.Scope, ctx) key, ok := ctx.sceneRuntime.buildSceneKey(scene.Scope, ctx)
if !ok { if !ok {
return ErrCantFindSession return ErrCantFindSession
} }
return ctx.sceneRuntime.DeleteSession(key) return ctx.sceneRuntime.deleteSession(key)
} }
+164 -6
View File
@@ -9,8 +9,8 @@ import (
"strings" "strings"
"testing" "testing"
"git.nix13.pw/scuroneko/laniakea/tgapi" "git.scuroneko.dev/scuroneko/laniakea/tgapi"
"git.nix13.pw/scuroneko/slog" "git.scuroneko.dev/scuroneko/slog"
) )
func TestAnswerPhotoIncludesDirectMessagesTopicID(t *testing.T) { func TestAnswerPhotoIncludesDirectMessagesTopicID(t *testing.T) {
@@ -47,7 +47,7 @@ func TestAnswerPhotoIncludesDirectMessagesTopicID(t *testing.T) {
ctx := &MsgContext{ ctx := &MsgContext{
Api: api, Api: api,
Msg: &tgapi.Message{ Msg: &tgapi.Message{
Chat: &tgapi.Chat{ID: 42, Type: string(tgapi.ChatTypePrivate)}, Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate},
DirectMessageTopic: &tgapi.DirectMessageTopic{TopicID: 77}, DirectMessageTopic: &tgapi.DirectMessageTopic{TopicID: 77},
}, },
Logger: slog.CreateLogger(), Logger: slog.CreateLogger(),
@@ -166,9 +166,167 @@ func TestBindArgsRejectsUnsupportedFieldTypes(t *testing.T) {
} }
} }
func TestErrorDefaultRemainsUserVisibleForMessageFlow(t *testing.T) {
var requests int
var gotBody map[string]any
client := &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
requests++
body, err := io.ReadAll(req.Body)
if err != nil {
t.Fatalf("failed to read request body: %v", err)
}
if err := json.Unmarshal(body, &gotBody); err != nil {
t.Fatalf("failed to decode request body: %v", err)
}
return &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(`{"ok":true,"result":{"message_id":9,"date":1}}`)),
}, nil
}),
}
api := tgapi.NewAPI(
tgapi.NewAPIOpts("token").
SetAPIUrl("https://example.test").
SetHTTPClient(client),
)
defer func() {
if err := api.Close(); err != nil {
t.Fatalf("Close returned error: %v", err)
}
}()
ctx := &MsgContext{
Api: api,
Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}},
Logger: slog.CreateLogger(),
errorTemplate: "Error: %s",
}
ctx.error(errors.New("boom"))
if requests != 1 {
t.Fatalf("expected one user-facing error reply, got %d requests", requests)
}
if got := gotBody["text"]; got != "Error: boom" {
t.Fatalf("unexpected error reply text: %v", got)
}
}
func TestErrorInternalSkipsUserReplyForMessageFlow(t *testing.T) {
client := &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
t.Fatal("unexpected HTTP request for internal-only error")
return nil, nil
}),
}
api := tgapi.NewAPI(
tgapi.NewAPIOpts("token").
SetAPIUrl("https://example.test").
SetHTTPClient(client),
)
defer func() {
if err := api.Close(); err != nil {
t.Fatalf("Close returned error: %v", err)
}
}()
ctx := &MsgContext{
Api: api,
Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}},
Logger: slog.CreateLogger(),
errorTemplate: "Error: %s",
}
ctx.error(AsInternalError(errors.New("boom")))
}
func TestErrorInternalSkipsCallbackAnswer(t *testing.T) {
client := &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
t.Fatal("unexpected callback answer request for internal-only error")
return nil, nil
}),
}
api := tgapi.NewAPI(
tgapi.NewAPIOpts("token").
SetAPIUrl("https://example.test").
SetHTTPClient(client),
)
defer func() {
if err := api.Close(); err != nil {
t.Fatalf("Close returned error: %v", err)
}
}()
ctx := &MsgContext{
Api: api,
Logger: slog.CreateLogger(),
errorTemplate: "%s",
CallbackQueryId: "cb-1",
}
ctx.error(AsInternalError(errors.New("boom")))
}
func TestErrorUserVisibleAnswersCallback(t *testing.T) {
var requests int
var gotBody map[string]any
client := &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
requests++
body, err := io.ReadAll(req.Body)
if err != nil {
t.Fatalf("failed to read request body: %v", err)
}
if err := json.Unmarshal(body, &gotBody); err != nil {
t.Fatalf("failed to decode request body: %v", err)
}
return &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(`{"ok":true,"result":true}`)),
}, nil
}),
}
api := tgapi.NewAPI(
tgapi.NewAPIOpts("token").
SetAPIUrl("https://example.test").
SetHTTPClient(client),
)
defer func() {
if err := api.Close(); err != nil {
t.Fatalf("Close returned error: %v", err)
}
}()
ctx := &MsgContext{
Api: api,
Logger: slog.CreateLogger(),
errorTemplate: "Oops: %s",
CallbackQueryId: "cb-1",
}
ctx.error(AsUserError(errors.New("boom")))
if requests != 1 {
t.Fatalf("expected one callback error answer, got %d requests", requests)
}
if got := gotBody["text"]; got != "Oops: boom" {
t.Fatalf("unexpected callback error text: %v", got)
}
}
func TestAnswerRejectsEmptyMessage(t *testing.T) { func TestAnswerRejectsEmptyMessage(t *testing.T) {
ctx := &MsgContext{ ctx := &MsgContext{
Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: string(tgapi.ChatTypePrivate)}}, Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}},
Logger: slog.CreateLogger(), Logger: slog.CreateLogger(),
} }
@@ -198,7 +356,7 @@ func TestAnswerRejectsLongMessageWithoutSendingRequest(t *testing.T) {
ctx := &MsgContext{ ctx := &MsgContext{
Api: api, Api: api,
Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: string(tgapi.ChatTypePrivate)}}, Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}},
Logger: slog.CreateLogger(), Logger: slog.CreateLogger(),
} }
@@ -282,7 +440,7 @@ func TestAnswerLongSplitsRequestsAndAttachesKeyboardToLastChunk(t *testing.T) {
ctx := &MsgContext{ ctx := &MsgContext{
Api: api, Api: api,
Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: string(tgapi.ChatTypePrivate)}}, Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}},
Logger: slog.CreateLogger(), Logger: slog.CreateLogger(),
} }
kb := NewInlineKeyboardJson(1).AddCallbackButton("A", "cmd") kb := NewInlineKeyboardJson(1).AddCallbackButton("A", "cmd")
+219
View File
@@ -0,0 +1,219 @@
package laniakea
import (
"strings"
"time"
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
)
func (bot *Bot[T]) handleMessage(update *tgapi.Update, ctx *MsgContext) bool {
var msg *tgapi.Message
if update.Message != nil {
msg = update.Message
} else if update.ChannelPost != nil {
msg = update.ChannelPost
} else {
return false
}
var text string
if len(msg.Text) > 0 {
text = msg.Text
} else if len(msg.Caption) > 0 {
text = msg.Caption
} else {
return false
}
prefix, cmd, args := bot.parseCommand(text)
if cmd == "" {
return false
}
ctx.Prefix = prefix
if strings.Contains(cmd, "@") {
botUsername := bot.username
if botUsername != "" && strings.HasSuffix(cmd, "@"+botUsername) {
cmd = cmd[:len(cmd)-len("@"+botUsername)] // убираем @botname
}
}
// Ищем команду по точному совпадению
for _, plugin := range bot.plugins {
if _, exists := plugin.commands[cmd]; exists {
ctx.Text = args
ctx.Args = strings.Fields(args) // Убирает лишние пробелы
if plugin.logger != nil {
ctx.Logger = plugin.logger
}
if !plugin.executeMiddlewares(ctx, bot.appData) {
return false
}
startTime := time.Now()
bot.safeEmitEvent(ctx.Context(), HandlerStartedEvent{
UpdateID: update.UpdateID,
UpdateType: update.Type,
Plugin: plugin.name,
HandlerKind: HandlerCommandKind,
HandlerName: cmd,
FromID: ctx.FromID,
ChatID: ctx.ChatID,
})
err := plugin.executeCmd(cmd, ctx, bot.appData)
handlerEndEvent := HandlerFinishedEvent{
UpdateID: update.UpdateID,
UpdateType: update.Type,
Plugin: plugin.name,
HandlerKind: HandlerCommandKind,
HandlerName: cmd,
FromID: ctx.FromID,
ChatID: ctx.ChatID,
Duration: time.Since(startTime),
}
var errorEvent *ErrorEvent = nil
if err != nil {
ctx.error(err)
handlerEndEvent.Err = err
handlerEndEvent.UserFacing = IsUserError(err)
errorEvent = &ErrorEvent{
UpdateID: update.UpdateID,
UpdateType: update.Type,
Plugin: plugin.name,
HandlerKind: HandlerCommandKind,
HandlerName: cmd,
FromID: ctx.FromID,
ChatID: ctx.ChatID,
Err: err,
UserFacing: handlerEndEvent.UserFacing,
}
}
bot.safeEmitEvent(ctx.Context(), handlerEndEvent)
if errorEvent != nil {
bot.safeEmitEvent(ctx.Context(), *errorEvent)
}
return true
}
}
return false
}
func (bot *Bot[T]) handleCallback(update *tgapi.Update, ctx *MsgContext) bool {
data, err := bot.decodePayload(update.CallbackQuery.Data)
if err != nil {
bot.logger.Errorln(err)
bot.safeEmitEvent(ctx.Context(), ErrorEvent{
UpdateID: update.UpdateID,
UpdateType: update.Type,
Plugin: "bot",
HandlerKind: HandlerPayloadKind,
HandlerName: "decodePayload",
FromID: ctx.FromID,
ChatID: ctx.ChatID,
Err: err,
UserFacing: false,
})
return false
}
ctx.Args = data.Args
for _, plugin := range bot.plugins {
_, ok := plugin.payloads[data.Command]
if !ok {
continue
}
ctx.Logger = plugin.logger
if ctx.Logger == nil {
ctx.Logger = bot.logger
}
if !plugin.executeMiddlewares(ctx, bot.appData) {
return false
}
startTime := time.Now()
bot.safeEmitEvent(ctx.Context(), HandlerStartedEvent{
UpdateID: update.UpdateID,
UpdateType: update.Type,
Plugin: plugin.name,
HandlerKind: HandlerPayloadKind,
HandlerName: data.Command,
FromID: ctx.FromID,
ChatID: ctx.ChatID,
})
err := plugin.executePayload(data.Command, ctx, bot.appData)
endEvent := HandlerFinishedEvent{
UpdateID: update.UpdateID,
UpdateType: update.Type,
Plugin: plugin.name,
HandlerKind: HandlerPayloadKind,
HandlerName: data.Command,
FromID: ctx.FromID,
ChatID: ctx.ChatID,
Duration: time.Since(startTime),
}
var errorEvent *ErrorEvent = nil
if err != nil {
ctx.error(err)
errorEvent = &ErrorEvent{
UpdateID: update.UpdateID,
UpdateType: update.Type,
Plugin: plugin.name,
HandlerKind: HandlerPayloadKind,
HandlerName: data.Command,
FromID: ctx.FromID,
ChatID: ctx.ChatID,
Err: err,
UserFacing: IsUserError(err),
}
endEvent.Err = err
endEvent.UserFacing = errorEvent.UserFacing
}
bot.safeEmitEvent(ctx.Context(), endEvent)
if errorEvent != nil {
bot.safeEmitEvent(ctx.Context(), *errorEvent)
}
return true
}
return false
}
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) {
return prefix, true
}
}
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 "", "", ""
}
+183
View File
@@ -0,0 +1,183 @@
package laniakea
import (
"context"
"fmt"
"time"
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
)
// HandlerEventKind identifies the kind of handler observed by runtime events.
type HandlerEventKind string
const (
// HandlerCommandKind identifies a command handler.
HandlerCommandKind HandlerEventKind = "command"
// HandlerPayloadKind identifies a callback payload handler.
HandlerPayloadKind HandlerEventKind = "payload"
// HandlerUpdateKind identifies a generic update handler.
HandlerUpdateKind HandlerEventKind = "update"
// HandlerRunnerKind identifies a background runner execution.
HandlerRunnerKind HandlerEventKind = "runner"
// HandlerPollingKind identifies polling and getUpdates runtime work.
HandlerPollingKind HandlerEventKind = "polling"
// HandlerSceneKind identifies a scene runtime handler wrapper.
HandlerSceneKind HandlerEventKind = "scene"
// HandlerSceneStepKind identifies a scene step handler.
HandlerSceneStepKind HandlerEventKind = "scene_step"
// HandlerSceneCommandKind identifies a scene-local command handler.
HandlerSceneCommandKind HandlerEventKind = "scene_command"
// HandlerSceneMessageKind identifies a scene message fallback handler.
HandlerSceneMessageKind HandlerEventKind = "scene_message"
)
type Event interface {
isEvent()
}
// UpdateReceivedEvent describes an update entering the bot runtime.
type UpdateReceivedEvent struct {
UpdateID int
UpdateType tgapi.UpdateType
FromID int64
ChatID int64
}
// UpdateHandledEvent describes a completed update execution path.
type UpdateHandledEvent struct {
UpdateID int
UpdateType tgapi.UpdateType
FromID int64
ChatID int64
Duration time.Duration
Handled bool
}
// HandlerStartedEvent describes a handler about to execute.
type HandlerStartedEvent struct {
UpdateID int
UpdateType tgapi.UpdateType
Plugin string
HandlerKind HandlerEventKind
HandlerName string
FromID int64
ChatID int64
}
// HandlerFinishedEvent describes a handler that has completed.
type HandlerFinishedEvent struct {
UpdateID int
UpdateType tgapi.UpdateType
Plugin string
HandlerKind HandlerEventKind
HandlerName string
FromID int64
ChatID int64
Duration time.Duration
Err error
UserFacing bool
}
// SceneTransitionEvent describes a scene state transition.
type SceneTransitionEvent struct {
Plugin string
Scene string
From string
To string
Action SceneAction
FromID int64
ChatID int64
}
// PolicyCheckedEvent describes the result of a policy evaluation.
type PolicyCheckedEvent struct {
Name string
Plugin string
FromID int64
ChatID int64
Passed bool
Err error
Internal bool
}
// RunnerFinishedEvent describes a completed background runner execution.
type RunnerFinishedEvent struct {
Name string
Duration time.Duration
Err error
}
// PollingRetryEvent describes a polling retry after a failed getUpdates call.
type PollingRetryEvent struct {
Attempt int
Delay time.Duration
Err error
}
// ErrorEvent describes an error routed through framework error handling.
type ErrorEvent struct {
UpdateID int
UpdateType tgapi.UpdateType
Plugin string
HandlerKind HandlerEventKind
HandlerName string
FromID int64
ChatID int64
Err error
UserFacing bool
}
func (UpdateReceivedEvent) isEvent() {}
func (UpdateHandledEvent) isEvent() {}
func (HandlerStartedEvent) isEvent() {}
func (HandlerFinishedEvent) isEvent() {}
func (SceneTransitionEvent) isEvent() {}
func (PolicyCheckedEvent) isEvent() {}
func (RunnerFinishedEvent) isEvent() {}
func (PollingRetryEvent) isEvent() {}
func (ErrorEvent) isEvent() {}
// Observer receives best-effort runtime instrumentation events.
type Observer interface {
OnReceiveUpdate(ctx context.Context, event UpdateReceivedEvent)
OnHandledUpdate(ctx context.Context, event UpdateHandledEvent)
OnHandlerStarted(ctx context.Context, event HandlerStartedEvent)
OnHandlerFinished(ctx context.Context, event HandlerFinishedEvent)
OnSceneTransition(ctx context.Context, event SceneTransitionEvent)
OnPolicyChecked(ctx context.Context, event PolicyCheckedEvent)
OnRunnerFinished(ctx context.Context, event RunnerFinishedEvent)
OnPollingRetry(ctx context.Context, event PollingRetryEvent)
OnError(ctx context.Context, event ErrorEvent)
}
func (bot *Bot[T]) safeEmitEvent(ctx context.Context, event Event) {
if bot.observer == nil {
return
}
defer func() {
if r := recover(); r != nil {
bot.logger.Errorln(fmt.Sprintf("panic in observer: %v", r))
}
}()
switch e := event.(type) {
case UpdateReceivedEvent:
bot.observer.OnReceiveUpdate(ctx, e)
case UpdateHandledEvent:
bot.observer.OnHandledUpdate(ctx, e)
case HandlerStartedEvent:
bot.observer.OnHandlerStarted(ctx, e)
case HandlerFinishedEvent:
bot.observer.OnHandlerFinished(ctx, e)
case SceneTransitionEvent:
bot.observer.OnSceneTransition(ctx, e)
case PolicyCheckedEvent:
bot.observer.OnPolicyChecked(ctx, e)
case RunnerFinishedEvent:
bot.observer.OnRunnerFinished(ctx, e)
case PollingRetryEvent:
bot.observer.OnPollingRetry(ctx, e)
case ErrorEvent:
bot.observer.OnError(ctx, e)
}
}
+40 -30
View File
@@ -4,10 +4,10 @@ import (
"errors" "errors"
"regexp" "regexp"
"git.nix13.pw/scuroneko/extypes" "git.scuroneko.dev/scuroneko/extypes"
"git.nix13.pw/scuroneko/laniakea/tgapi" "git.scuroneko.dev/scuroneko/laniakea/tgapi"
"git.nix13.pw/scuroneko/laniakea/utils" "git.scuroneko.dev/scuroneko/laniakea/utils"
"git.nix13.pw/scuroneko/slog" "git.scuroneko.dev/scuroneko/slog"
) )
// CommandValueType defines the expected type of command argument. // CommandValueType defines the expected type of command argument.
@@ -40,6 +40,11 @@ var ErrCmdArgCountMismatch = errors.New("command arg count mismatch")
// ErrCmdArgRegexpMismatch is returned when an argument fails regex validation. // ErrCmdArgRegexpMismatch is returned when an argument fails regex validation.
var ErrCmdArgRegexpMismatch = errors.New("command arg regexp mismatch") var ErrCmdArgRegexpMismatch = errors.New("command arg regexp mismatch")
var (
errCommandNotFound = errors.New("command not found")
errPayloadNotFound = errors.New("payload not found")
)
// CommandArg defines a single argument for a command, including type, regex, // CommandArg defines a single argument for a command, including type, regex,
// and whether it is required. // and whether it is required.
type CommandArg struct { type CommandArg struct {
@@ -80,13 +85,13 @@ func (c CommandArg) SetRequired() CommandArg {
} }
// CommandExecutor is the function type that executes a command. // CommandExecutor is the function type that executes a command.
// It receives the message context and a database context (generic). // It receives the message context and injected application data.
// Returning a non-nil error routes it through the bot's error handler. // Returning a non-nil error routes it through the bot's error handler.
type CommandExecutor[T DbContext] func(ctx *MsgContext, dbContext T) error type CommandExecutor[T AppData] func(ctx *MsgContext, dbContext T) error
// Command represents a bot command with arguments, description, and executor. // Command represents a bot command with arguments, description, and executor.
// Can be registered in a Plugin and optionally skipped from auto-generation. // Can be registered in a Plugin and optionally skipped from auto-generation.
type Command[T DbContext] struct { type Command[T AppData] struct {
command string // The command trigger (e.g., "/start") command string // The command trigger (e.g., "/start")
description string // Human-readable description for help description string // Human-readable description for help
exec CommandExecutor[T] // Function to execute when command is triggered exec CommandExecutor[T] // Function to execute when command is triggered
@@ -157,7 +162,7 @@ func (c *Command[T]) validateArgs(args []string) error {
// A Plugin is intended to be fully configured before it is passed to Bot.AddPlugins. // A Plugin is intended to be fully configured before it is passed to Bot.AddPlugins.
// After registration, treat the plugin as committed and do not mutate it further. // After registration, treat the plugin as committed and do not mutate it further.
// Post-registration changes through the original *Plugin are not a supported API. // Post-registration changes through the original *Plugin are not a supported API.
type Plugin[T DbContext] struct { type Plugin[T AppData] struct {
name string // Name of the plugin (e.g., "admin", "user") name string // Name of the plugin (e.g., "admin", "user")
commands map[string]*Command[T] // Registered commands (triggered by message) commands map[string]*Command[T] // Registered commands (triggered by message)
payloads map[string]*Command[T] // Registered payloads (triggered by callback data) payloads map[string]*Command[T] // Registered payloads (triggered by callback data)
@@ -172,7 +177,7 @@ type Plugin[T DbContext] struct {
} }
// NewPlugin creates a new Plugin with the given name. // NewPlugin creates a new Plugin with the given name.
func NewPlugin[T DbContext](name string) *Plugin[T] { func NewPlugin[T AppData](name string) *Plugin[T] {
return &Plugin[T]{ return &Plugin[T]{
name: name, name: name,
commands: make(map[string]*Command[T]), commands: make(map[string]*Command[T]),
@@ -215,11 +220,24 @@ func (p *Plugin[T]) NewPayload(exec CommandExecutor[T], command string, args ...
return cmd return cmd
} }
// AddScene registers a multi-step scene in the plugin.
func (p *Plugin[T]) AddScene(scene *Scene[T]) *Plugin[T] { func (p *Plugin[T]) AddScene(scene *Scene[T]) *Plugin[T] {
if scene == nil {
return p
}
scene.PluginName = p.name scene.PluginName = p.name
scene.setPluginName(p.name) scene.setPluginName(p.name)
p.scenes[scene.Name] = scene
return p return p
} }
// UsePolicy registers a Policy as plugin middleware for all plugin handlers.
func (p *Plugin[T]) UsePolicy(name string, policy Policy[T]) *Plugin[T] {
mw := RequirePolicy(name, policy)
return p.AddMiddleware(mw)
}
// NewScene creates, registers, and returns a new scene owned by the plugin.
func (p *Plugin[T]) NewScene(name string) *Scene[T] { func (p *Plugin[T]) NewScene(name string) *Scene[T] {
scene := NewScene[T](name) scene := NewScene[T](name)
scene.setPluginName(p.name) scene.setPluginName(p.name)
@@ -304,55 +322,47 @@ func (p *Plugin[T]) Close() error {
} }
// Internal helper that validates and executes a command handler. // Internal helper that validates and executes a command handler.
func (p *Plugin[T]) executeCmd(cmd string, ctx *MsgContext, db T) { func (p *Plugin[T]) executeCmd(cmd string, ctx *MsgContext, db T) error {
command, exists := p.commands[cmd] command, exists := p.commands[cmd]
if !exists { if !exists {
ctx.error(errors.New("command not found")) return AsInternalError(errCommandNotFound)
return
} }
if err := command.validateArgs(ctx.Args); err != nil { if err := command.validateArgs(ctx.Args); err != nil {
ctx.error(err) return AsUserError(err)
return
} }
// Run command-specific middlewares // Run command-specific middlewares
for _, m := range command.middlewares { for _, m := range command.middlewares {
if !m.Execute(ctx, db) { if !m.Execute(ctx, db) {
return return AsInternalError(errors.New("middleware blocked call"))
} }
} }
// Execute command // Execute command
if err := command.exec(ctx, db); err != nil { return command.exec(ctx, db)
ctx.error(err)
}
} }
// Internal helper that validates and executes a payload handler. // Internal helper that validates and executes a payload handler.
func (p *Plugin[T]) executePayload(payload string, ctx *MsgContext, db T) { func (p *Plugin[T]) executePayload(payload string, ctx *MsgContext, db T) error {
command, exists := p.payloads[payload] command, exists := p.payloads[payload]
if !exists { if !exists {
ctx.error(errors.New("payload not found")) return AsInternalError(errPayloadNotFound)
return
} }
if err := command.validateArgs(ctx.Args); err != nil { if err := command.validateArgs(ctx.Args); err != nil {
ctx.error(err) return AsUserError(err)
return
} }
// Run command-specific middlewares // Run command-specific middlewares
for _, m := range command.middlewares { for _, m := range command.middlewares {
if !m.Execute(ctx, db) { if !m.Execute(ctx, db) {
return return AsInternalError(errors.New("middleware blocked call"))
} }
} }
// Execute payload // Execute payload
if err := command.exec(ctx, db); err != nil { return command.exec(ctx, db)
ctx.error(err)
}
} }
// Internal helper that runs plugin middlewares in order. // Internal helper that runs plugin middlewares in order.
@@ -368,11 +378,11 @@ func (p *Plugin[T]) executeMiddlewares(ctx *MsgContext, db T) bool {
// MiddlewareExecutor is the function type for middleware logic. // MiddlewareExecutor is the function type for middleware logic.
// Returns true to continue execution, false to block it. // Returns true to continue execution, false to block it.
// If async, return value is ignored. // If async, return value is ignored.
type MiddlewareExecutor[T DbContext] func(ctx *MsgContext, db T) bool type MiddlewareExecutor[T AppData] func(ctx *MsgContext, db T) bool
// Middleware represents a reusable execution interceptor. // Middleware represents a reusable execution interceptor.
// Can be synchronous (blocking) or asynchronous (non-blocking). // Can be synchronous (blocking) or asynchronous (non-blocking).
type Middleware[T DbContext] struct { type Middleware[T AppData] struct {
name string // Human-readable name for logging/debugging name string // Human-readable name for logging/debugging
executor MiddlewareExecutor[T] // Function to execute executor MiddlewareExecutor[T] // Function to execute
order int // Optional sort order (not used yet) order int // Optional sort order (not used yet)
@@ -380,7 +390,7 @@ type Middleware[T DbContext] struct {
} }
// NewMiddleware creates a new synchronous middleware. // NewMiddleware creates a new synchronous middleware.
func NewMiddleware[T DbContext](name string, executor MiddlewareExecutor[T]) Middleware[T] { func NewMiddleware[T AppData](name string, executor MiddlewareExecutor[T]) Middleware[T] {
return Middleware[T]{name, executor, 0, false} return Middleware[T]{name, executor, 0, false}
} }
+4 -4
View File
@@ -6,7 +6,7 @@ import (
) )
func TestValidateArgsRequiresFullMatch(t *testing.T) { func TestValidateArgsRequiresFullMatch(t *testing.T) {
intCmd := NewCommand[NoDB](func(ctx *MsgContext, db NoDB) error { return nil }, "int", NewCommandArg("n").SetValueType(CommandValueIntType).SetRequired()) intCmd := NewCommand[NoData](func(ctx *MsgContext, db NoData) error { return nil }, "int", NewCommandArg("n").SetValueType(CommandValueIntType).SetRequired())
if err := intCmd.validateArgs([]string{"123"}); err != nil { if err := intCmd.validateArgs([]string{"123"}); err != nil {
t.Fatalf("expected valid integer argument, got %v", err) t.Fatalf("expected valid integer argument, got %v", err)
} }
@@ -14,7 +14,7 @@ func TestValidateArgsRequiresFullMatch(t *testing.T) {
t.Fatalf("expected ErrCmdArgRegexpMismatch for partial int match, got %v", err) t.Fatalf("expected ErrCmdArgRegexpMismatch for partial int match, got %v", err)
} }
boolCmd := NewCommand[NoDB](func(ctx *MsgContext, db NoDB) error { return nil }, "bool", NewCommandArg("flag").SetValueType(CommandValueBoolType).SetRequired()) boolCmd := NewCommand[NoData](func(ctx *MsgContext, db NoData) error { return nil }, "bool", NewCommandArg("flag").SetValueType(CommandValueBoolType).SetRequired())
if err := boolCmd.validateArgs([]string{"false"}); err != nil { if err := boolCmd.validateArgs([]string{"false"}); err != nil {
t.Fatalf("expected valid bool argument, got %v", err) t.Fatalf("expected valid bool argument, got %v", err)
} }
@@ -24,8 +24,8 @@ func TestValidateArgsRequiresFullMatch(t *testing.T) {
} }
func TestValidateArgsEnforcesRequiredArgIndex(t *testing.T) { func TestValidateArgsEnforcesRequiredArgIndex(t *testing.T) {
cmd := NewCommand[NoDB]( cmd := NewCommand[NoData](
func(ctx *MsgContext, db NoDB) error { return nil }, func(ctx *MsgContext, db NoData) error { return nil },
"mixed", "mixed",
NewCommandArg("optional"), NewCommandArg("optional"),
NewCommandArg("required").SetRequired(), NewCommandArg("required").SetRequired(),
+224
View File
@@ -0,0 +1,224 @@
package laniakea
import (
"errors"
"fmt"
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
)
// Policy defines a reusable authorization rule for the current update context.
type Policy[T AppData] func(ctx *MsgContext, data T) error
// RequirePolicy adapts a Policy into a blocking middleware.
func RequirePolicy[T AppData](name string, p Policy[T]) Middleware[T] {
return NewMiddleware(name, func(ctx *MsgContext, data T) bool {
if err := p(ctx, data); err != nil {
ctx.emitPolicyChecked(PolicyCheckedEvent{
Name: name,
FromID: ctx.FromID,
ChatID: ctx.ChatID,
Passed: false,
Err: err,
Internal: IsInternalError(err),
})
ctx.error(err)
return false
}
ctx.emitPolicyChecked(PolicyCheckedEvent{
Name: name,
FromID: ctx.FromID,
ChatID: ctx.ChatID,
Passed: true,
})
return true
})
}
// AllPolicies composes policies that all must succeed.
func AllPolicies[T AppData](policies ...Policy[T]) Policy[T] {
return func(ctx *MsgContext, data T) error {
for _, p := range policies {
if err := p(ctx, data); err != nil {
return err
}
}
return nil
}
}
// AnyPolicy composes policies where at least one must succeed.
func AnyPolicy[T AppData](policies ...Policy[T]) Policy[T] {
return func(ctx *MsgContext, data T) error {
var firstDeny error
var internalErr error
for _, p := range policies {
err := p(ctx, data)
if err == nil {
return nil
}
if IsInternalError(err) {
if internalErr == nil {
internalErr = err
}
continue
}
if firstDeny == nil {
firstDeny = err
}
}
if internalErr != nil {
return internalErr
}
if firstDeny != nil {
return firstDeny
}
return AsUserError(errors.New("no policy matched"))
}
}
// NotPolicy inverts a policy deny result while preserving internal failures.
func NotPolicy[T AppData](policy Policy[T]) Policy[T] {
return func(ctx *MsgContext, data T) error {
var err error
if err = policy(ctx, data); err == nil {
return AsUserError(errors.New("the action is not allowed due to policy violation"))
}
if IsInternalError(err) {
return err
}
return nil
}
}
// RequirePrivateChat allows execution only in private chats.
func RequirePrivateChat[T AppData]() Policy[T] {
return func(ctx *MsgContext, data T) error {
if ctx.Msg == nil || ctx.Msg.Chat == nil {
return AsInternalError(errors.New("private-chat policy requires message chat context"))
}
if ctx.Msg.Chat.Type != tgapi.ChatTypePrivate {
return AsUserError(errors.New("this action is only available in private chat"))
}
return nil
}
}
// RequireGroupChat allows execution only in group or supergroup chats.
func RequireGroupChat[T AppData]() Policy[T] {
return func(ctx *MsgContext, data T) error {
if ctx.Msg == nil || ctx.Msg.Chat == nil {
return AsInternalError(errors.New("group-chat policy requires message chat context"))
}
if ctx.Msg.Chat.Type != tgapi.ChatTypeGroup && ctx.Msg.Chat.Type != tgapi.ChatTypeSupergroup {
return AsUserError(errors.New("this action is only available in group chats"))
}
return nil
}
}
// RequireSupergroupChat allows execution only in supergroup chats.
func RequireSupergroupChat[T AppData]() Policy[T] {
return func(ctx *MsgContext, data T) error {
if ctx.Msg == nil || ctx.Msg.Chat == nil {
return AsInternalError(errors.New("supergroup-chat policy requires message chat context"))
}
if ctx.Msg.Chat.Type != tgapi.ChatTypeSupergroup {
return AsUserError(errors.New("this action is only available in supergroup chats"))
}
return nil
}
}
// RequireChatAdmin allows execution only for chat administrators or owners.
func RequireChatAdmin[T AppData]() Policy[T] {
return func(ctx *MsgContext, data T) error {
if ctx.FromID == 0 || ctx.ChatID == 0 {
return AsInternalError(errors.New("chat-admin policy requires message chat context"))
}
member, err := ctx.Api.GetChatMember(tgapi.GetChatMemberP{
ChatID: ctx.ChatID,
UserID: ctx.FromID,
})
if err != nil {
return AsInternalError(fmt.Errorf("failed to fetch chat member status: %w", err))
}
if member.Status != tgapi.ChatMemberStatusAdministrator && member.Status != tgapi.ChatMemberStatusOwner {
return AsUserError(errors.New("this action is only available to chat admins"))
}
return nil
}
}
// RequireChatCreator allows execution only for the chat owner.
func RequireChatCreator[T AppData]() Policy[T] {
return func(ctx *MsgContext, data T) error {
if ctx.FromID == 0 || ctx.ChatID == 0 {
return AsInternalError(errors.New("chat-creator policy requires message chat context"))
}
member, err := ctx.Api.GetChatMember(tgapi.GetChatMemberP{
ChatID: ctx.ChatID,
UserID: ctx.FromID,
})
if err != nil {
return AsInternalError(fmt.Errorf("failed to fetch chat creator: %w", err))
}
if member.Status != tgapi.ChatMemberStatusOwner {
return AsUserError(errors.New("this action is only available to the chat creator"))
}
return nil
}
}
// RequireBotAdmin allows execution only when the bot is an admin in the chat.
func RequireBotAdmin[T AppData]() Policy[T] {
return func(ctx *MsgContext, data T) error {
if ctx.ChatID == 0 {
return AsInternalError(errors.New("bot-admin policy requires message chat context"))
}
bot, err := ctx.Api.GetMe()
if err != nil {
return AsInternalError(fmt.Errorf("failed to fetch bot info: %w", err))
}
member, err := ctx.Api.GetChatMember(tgapi.GetChatMemberP{
ChatID: ctx.ChatID,
UserID: bot.ID,
})
if err != nil {
return AsInternalError(fmt.Errorf("failed to fetch bot member status: %w", err))
}
if member.Status != tgapi.ChatMemberStatusAdministrator && member.Status != tgapi.ChatMemberStatusOwner {
return AsUserError(errors.New("this action requires the bot to be an admin in the chat"))
}
return nil
}
}
// RequireCallbackFromUser allows execution only for callback queries sent by non-bot users.
func RequireCallbackFromUser[T AppData]() Policy[T] {
return func(ctx *MsgContext, data T) error {
if ctx.Update.CallbackQuery == nil {
return AsInternalError(errors.New("callback-user policy requires callback query context"))
}
if ctx.Update.CallbackQuery.From.IsBot {
return AsUserError(errors.New("this action is only available to human users"))
}
return nil
}
}
+281
View File
@@ -0,0 +1,281 @@
package laniakea
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"strings"
"testing"
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
"git.scuroneko.dev/scuroneko/slog"
)
func TestRequirePolicyStopsExecutionOnDeniedPolicy(t *testing.T) {
var requests int
var gotBody map[string]any
client := &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
requests++
body, err := io.ReadAll(req.Body)
if err != nil {
t.Fatalf("failed to read request body: %v", err)
}
if err := json.Unmarshal(body, &gotBody); err != nil {
t.Fatalf("failed to decode request body: %v", err)
}
return &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(`{"ok":true,"result":{"message_id":9,"date":1}}`)),
}, nil
}),
}
api := tgapi.NewAPI(
tgapi.NewAPIOpts("token").
SetAPIUrl("https://example.test").
SetHTTPClient(client),
)
defer func() {
if err := api.Close(); err != nil {
t.Fatalf("Close returned error: %v", err)
}
}()
ctx := &MsgContext{
Api: api,
Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}},
Logger: slog.CreateLogger(),
errorTemplate: "Error: %s",
}
mw := RequirePolicy[NoData]("deny", func(ctx *MsgContext, data NoData) error {
return AsUserError(errors.New("blocked"))
})
if mw.Execute(ctx, NoData{}) {
t.Fatal("expected denied policy middleware to stop execution")
}
if requests != 1 {
t.Fatalf("expected one user-facing error reply, got %d requests", requests)
}
if got := gotBody["text"]; got != "Error: blocked" {
t.Fatalf("unexpected policy error reply text: %v", got)
}
}
func TestRequirePrivateChatAllowsPrivateChat(t *testing.T) {
ctx := &MsgContext{
Msg: &tgapi.Message{
Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate},
},
Logger: slog.CreateLogger(),
}
if err := RequirePrivateChat[NoData]()(ctx, NoData{}); err != nil {
t.Fatalf("RequirePrivateChat returned error: %v", err)
}
}
func TestRequirePrivateChatDeniesNonPrivateChat(t *testing.T) {
ctx := &MsgContext{
Msg: &tgapi.Message{
Chat: &tgapi.Chat{ID: -100, Type: tgapi.ChatTypeSupergroup},
},
Logger: slog.CreateLogger(),
}
err := RequirePrivateChat[NoData]()(ctx, NoData{})
if err == nil {
t.Fatal("expected RequirePrivateChat to deny non-private chats")
}
if !IsUserError(err) {
t.Fatalf("expected user-visible deny error, got %v", err)
}
}
func TestRequireChatAdminUsesNormalizedIDs(t *testing.T) {
var sawGetChatMember bool
var gotBody map[string]any
client := &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
if !strings.Contains(req.URL.Path, "getChatMember") {
t.Fatalf("unexpected API method: %s", req.URL.Path)
}
sawGetChatMember = true
body, err := io.ReadAll(req.Body)
if err != nil {
t.Fatalf("failed to read request body: %v", err)
}
if err := json.Unmarshal(body, &gotBody); err != nil {
t.Fatalf("failed to decode request body: %v", err)
}
return &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(
`{"ok":true,"result":{"status":"administrator","user":{"id":55,"is_bot":false,"first_name":"tester"}}}`,
)),
}, nil
}),
}
api := tgapi.NewAPI(
tgapi.NewAPIOpts("token").
SetAPIUrl("https://example.test").
SetHTTPClient(client),
)
defer func() {
if err := api.Close(); err != nil {
t.Fatalf("Close returned error: %v", err)
}
}()
ctx := &MsgContext{
Api: api,
ChatID: -2001,
FromID: 55,
Logger: slog.CreateLogger(),
}
if err := RequireChatAdmin[NoData]()(ctx, NoData{}); err != nil {
t.Fatalf("RequireChatAdmin returned error: %v", err)
}
if !sawGetChatMember {
t.Fatal("expected GetChatMember to be called")
}
if got := gotBody["chat_id"]; got != float64(-2001) {
t.Fatalf("unexpected chat_id in request: %v", got)
}
if got := gotBody["user_id"]; got != float64(55) {
t.Fatalf("unexpected user_id in request: %v", got)
}
}
func TestAllPoliciesReturnsFirstError(t *testing.T) {
want := AsUserError(errors.New("blocked"))
policy := AllPolicies[NoData](
func(ctx *MsgContext, data NoData) error { return nil },
func(ctx *MsgContext, data NoData) error { return want },
func(ctx *MsgContext, data NoData) error {
t.Fatal("unexpected evaluation after first failure")
return nil
},
)
err := policy(&MsgContext{Logger: slog.CreateLogger()}, NoData{})
if !errors.Is(err, want) {
t.Fatalf("expected first policy error, got %v", err)
}
}
func TestAnyPolicyAllowsLaterSuccessAfterInternalError(t *testing.T) {
policy := AnyPolicy[NoData](
func(ctx *MsgContext, data NoData) error { return AsInternalError(errors.New("temporary")) },
func(ctx *MsgContext, data NoData) error { return nil },
)
if err := policy(&MsgContext{Logger: slog.CreateLogger()}, NoData{}); err != nil {
t.Fatalf("expected later success to allow access, got %v", err)
}
}
func TestAnyPolicyReturnsInternalErrorWhenNonePass(t *testing.T) {
internal := AsInternalError(errors.New("temporary"))
policy := AnyPolicy[NoData](
func(ctx *MsgContext, data NoData) error { return AsUserError(errors.New("denied")) },
func(ctx *MsgContext, data NoData) error { return internal },
)
err := policy(&MsgContext{Logger: slog.CreateLogger()}, NoData{})
if !errors.Is(err, internal) {
t.Fatalf("expected internal error, got %v", err)
}
}
func TestAnyPolicyReturnsFirstDenyWhenNoPolicyPasses(t *testing.T) {
first := AsUserError(errors.New("first deny"))
policy := AnyPolicy[NoData](
func(ctx *MsgContext, data NoData) error { return first },
func(ctx *MsgContext, data NoData) error { return AsUserError(errors.New("second deny")) },
)
err := policy(&MsgContext{Logger: slog.CreateLogger()}, NoData{})
if !errors.Is(err, first) {
t.Fatalf("expected first deny error, got %v", err)
}
}
func TestNotPolicyInvertsUserDenyButPreservesInternalErrors(t *testing.T) {
inverted := NotPolicy[NoData](func(ctx *MsgContext, data NoData) error {
return AsUserError(errors.New("denied"))
})
if err := inverted(&MsgContext{Logger: slog.CreateLogger()}, NoData{}); err != nil {
t.Fatalf("expected inverted deny to succeed, got %v", err)
}
internal := AsInternalError(errors.New("temporary"))
preserve := NotPolicy[NoData](func(ctx *MsgContext, data NoData) error {
return internal
})
err := preserve(&MsgContext{Logger: slog.CreateLogger()}, NoData{})
if !errors.Is(err, internal) {
t.Fatalf("expected internal error to be preserved, got %v", err)
}
}
func TestRequirePolicyEmitsObserverEvents(t *testing.T) {
t.Run("allow", func(t *testing.T) {
observer := &recordingObserver{}
ctx := &MsgContext{
Logger: slog.CreateLogger(),
ctx: context.Background(),
observer: observer,
FromID: 10,
ChatID: 20,
}
mw := RequirePolicy[NoData]("allow", func(ctx *MsgContext, data NoData) error {
return nil
})
if !mw.Execute(ctx, NoData{}) {
t.Fatal("expected allowed policy middleware to continue execution")
}
if len(observer.policies) != 1 {
t.Fatalf("expected one policy event, got %d", len(observer.policies))
}
if got := observer.policies[0]; got.Name != "allow" || !got.Passed || got.Err != nil || got.Internal {
t.Fatalf("unexpected policy event: %#v", got)
}
})
t.Run("deny", func(t *testing.T) {
observer := &recordingObserver{}
ctx := &MsgContext{
Logger: slog.CreateLogger(),
ctx: context.Background(),
observer: observer,
errorTemplate: "%s",
}
mw := RequirePolicy[NoData]("deny", func(ctx *MsgContext, data NoData) error {
return AsInternalError(errors.New("blocked"))
})
if mw.Execute(ctx, NoData{}) {
t.Fatal("expected denied policy middleware to stop execution")
}
if len(observer.policies) != 1 {
t.Fatalf("expected one policy event, got %d", len(observer.policies))
}
if got := observer.policies[0]; got.Name != "deny" || got.Passed || got.Err == nil || !got.Internal {
t.Fatalf("unexpected policy event: %#v", got)
}
})
}
+42 -4
View File
@@ -7,7 +7,7 @@ import (
// RunnerFn is the function type for a runner. It receives a pointer to // RunnerFn is the function type for a runner. It receives a pointer to
// the Bot and returns an error if execution fails. // the Bot and returns an error if execution fails.
type RunnerFn[T DbContext] func(*Bot[T]) error type RunnerFn[T AppData] func(*Bot[T]) error
// Runner represents a configurable background or one-time task to be // Runner represents a configurable background or one-time task to be
// executed by a Bot. // executed by a Bot.
@@ -20,7 +20,7 @@ type RunnerFn[T DbContext] func(*Bot[T]) error
// - onetime=true, async=true: Run once in a goroutine (non-blocking). // - onetime=true, async=true: Run once in a goroutine (non-blocking).
// - onetime=false, async=true: Run repeatedly in a goroutine with timeout. // - onetime=false, async=true: Run repeatedly in a goroutine with timeout.
// - onetime=false, async=false: Invalid configuration — ignored with warning. // - onetime=false, async=false: Invalid configuration — ignored with warning.
type Runner[T DbContext] struct { type Runner[T AppData] struct {
name string // Human-readable name for logging name string // Human-readable name for logging
onetime bool // If true, runs once; if false, runs periodically onetime bool // If true, runs once; if false, runs periodically
async bool // If true, runs in a goroutine; else, runs synchronously async bool // If true, runs in a goroutine; else, runs synchronously
@@ -33,7 +33,7 @@ type Runner[T DbContext] struct {
// //
// Builder methods (Onetime, Async, Timeout) can be chained to customize behavior. // Builder methods (Onetime, Async, Timeout) can be chained to customize behavior.
// DO NOT call builder methods concurrently or after Execute(). // DO NOT call builder methods concurrently or after Execute().
func NewRunner[T DbContext](name string, fn RunnerFn[T]) Runner[T] { func NewRunner[T AppData](name string, fn RunnerFn[T]) Runner[T] {
return Runner[T]{ return Runner[T]{
name: name, name: name,
fn: fn, fn: fn,
@@ -107,8 +107,21 @@ func (bot *Bot[T]) ExecRunners(ctx context.Context) {
bot.runnerOnceWG.Add(1) bot.runnerOnceWG.Add(1)
go func(r Runner[T]) { go func(r Runner[T]) {
defer bot.runnerOnceWG.Done() defer bot.runnerOnceWG.Done()
startedAt := time.Now()
err := r.fn(bot) err := r.fn(bot)
bot.safeEmitEvent(ctx, RunnerFinishedEvent{
Name: r.name,
Duration: time.Since(startedAt),
Err: err,
})
if err != nil { if err != nil {
bot.safeEmitEvent(ctx, ErrorEvent{
Plugin: "bot",
HandlerKind: HandlerRunnerKind,
HandlerName: r.name,
Err: err,
UserFacing: false,
})
bot.logger.Warnf("Runner %s failed: %s\n", r.name, err) bot.logger.Warnf("Runner %s failed: %s\n", r.name, err)
} }
}(runner) }(runner)
@@ -116,10 +129,22 @@ func (bot *Bot[T]) ExecRunners(ctx context.Context) {
// One-time sync: block until done // One-time sync: block until done
t := time.Now() t := time.Now()
err := runner.fn(bot) err := runner.fn(bot)
elapsed := time.Since(t)
bot.safeEmitEvent(ctx, RunnerFinishedEvent{
Name: runner.name,
Duration: elapsed,
Err: err,
})
if err != nil { if err != nil {
bot.safeEmitEvent(ctx, ErrorEvent{
Plugin: "bot",
HandlerKind: HandlerRunnerKind,
HandlerName: runner.name,
Err: err,
UserFacing: false,
})
bot.logger.Warnf("Runner %s failed: %s\n", runner.name, err) bot.logger.Warnf("Runner %s failed: %s\n", runner.name, err)
} }
elapsed := time.Since(t)
if elapsed > time.Second*2 { if elapsed > time.Second*2 {
bot.logger.Warnf("Runner %s too slow. Elapsed time %v >= 2s\n", runner.name, elapsed) bot.logger.Warnf("Runner %s too slow. Elapsed time %v >= 2s\n", runner.name, elapsed)
} }
@@ -135,8 +160,21 @@ func (bot *Bot[T]) ExecRunners(ctx context.Context) {
case <-ctx.Done(): case <-ctx.Done():
return return
case <-ticker.C: case <-ticker.C:
startedAt := time.Now()
err := r.fn(bot) err := r.fn(bot)
bot.safeEmitEvent(ctx, RunnerFinishedEvent{
Name: r.name,
Duration: time.Since(startedAt),
Err: err,
})
if err != nil { if err != nil {
bot.safeEmitEvent(ctx, ErrorEvent{
Plugin: "bot",
HandlerKind: HandlerRunnerKind,
HandlerName: r.name,
Err: err,
UserFacing: false,
})
bot.logger.Warnf("Runner %s failed: %s\n", r.name, err) bot.logger.Warnf("Runner %s failed: %s\n", r.name, err)
} }
} }
+42 -7
View File
@@ -2,19 +2,24 @@ package laniakea
import ( import (
"context" "context"
"errors"
"sync/atomic" "sync/atomic"
"testing" "testing"
"time" "time"
"git.nix13.pw/scuroneko/slog" "git.scuroneko.dev/scuroneko/slog"
) )
type runnerObserver struct {
recordingObserver
}
func TestExecRunnersRunsOnetimeSyncRunner(t *testing.T) { func TestExecRunnersRunsOnetimeSyncRunner(t *testing.T) {
var calls atomic.Int32 var calls atomic.Int32
bot := &Bot[NoDB]{ bot := &Bot[NoData]{
logger: slog.CreateLogger(), logger: slog.CreateLogger(),
runners: []Runner[NoDB]{ runners: []Runner[NoData]{
NewRunner("sync-once", func(*Bot[NoDB]) error { NewRunner("sync-once", func(*Bot[NoData]) error {
calls.Add(1) calls.Add(1)
return nil return nil
}).Onetime(true).Async(false), }).Onetime(true).Async(false),
@@ -33,10 +38,10 @@ func TestExecRunnersStopsBackgroundRunnerOnCancel(t *testing.T) {
triggered := make(chan struct{}, 1) triggered := make(chan struct{}, 1)
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
bot := &Bot[NoDB]{ bot := &Bot[NoData]{
logger: slog.CreateLogger(), logger: slog.CreateLogger(),
runners: []Runner[NoDB]{ runners: []Runner[NoData]{
NewRunner("background", func(*Bot[NoDB]) error { NewRunner("background", func(*Bot[NoData]) error {
if calls.Add(1) == 1 { if calls.Add(1) == 1 {
triggered <- struct{}{} triggered <- struct{}{}
} }
@@ -60,3 +65,33 @@ func TestExecRunnersStopsBackgroundRunnerOnCancel(t *testing.T) {
t.Fatal("expected background runner to be called at least once") t.Fatal("expected background runner to be called at least once")
} }
} }
func TestExecRunnersEmitObserverEvents(t *testing.T) {
observer := &runnerObserver{}
wantErr := errors.New("runner failed")
bot := &Bot[NoData]{
logger: slog.CreateLogger(),
observer: observer,
runners: []Runner[NoData]{
NewRunner("sync-once", func(*Bot[NoData]) error {
return wantErr
}).Onetime(true).Async(false),
},
}
bot.ExecRunners(context.Background())
if len(observer.runners) != 1 {
t.Fatalf("expected one runner-finished event, got %d", len(observer.runners))
}
if got := observer.runners[0]; got.Name != "sync-once" || !errors.Is(got.Err, wantErr) {
t.Fatalf("unexpected runner-finished event: %#v", got)
}
if len(observer.errors) != 1 {
t.Fatalf("expected one error event, got %d", len(observer.errors))
}
if got := observer.errors[0]; got.HandlerKind != HandlerRunnerKind || got.HandlerName != "sync-once" || !errors.Is(got.Err, wantErr) {
t.Fatalf("unexpected runner error event: %#v", got)
}
}
+60 -17
View File
@@ -5,11 +5,18 @@ import (
"sync" "sync"
) )
// SceneHandler handles a scene step, scene command, or fallback message.
type SceneHandler[T any] func(ctx *SceneContext, db T) (SceneResult, error) type SceneHandler[T any] func(ctx *SceneContext, db T) (SceneResult, error)
// Scene defines a multi-step conversational flow.
type Scene[T any] struct { type Scene[T any] struct {
Name string // Name identifies the scene in plugin registration and session state.
Scope SceneScope Name string
Entry string // starting step // 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 PluginName string
steps map[string]SceneHandler[T] steps map[string]SceneHandler[T]
@@ -17,6 +24,7 @@ type Scene[T any] struct {
message SceneHandler[T] message SceneHandler[T]
} }
// NewScene creates a new scene with user-chat scope by default.
func NewScene[T any](name string) *Scene[T] { func NewScene[T any](name string) *Scene[T] {
return &Scene[T]{ return &Scene[T]{
Name: name, Name: name,
@@ -27,27 +35,37 @@ func NewScene[T any](name string) *Scene[T] {
message: nil, message: nil,
} }
} }
// SetScope changes how scene sessions are keyed and shared.
func (s *Scene[T]) SetScope(scope SceneScope) *Scene[T] { func (s *Scene[T]) SetScope(scope SceneScope) *Scene[T] {
s.Scope = scope s.Scope = scope
return s return s
} }
// SetEntry sets the initial step entered by MsgContext.EnterScene.
func (s *Scene[T]) SetEntry(step string) *Scene[T] { func (s *Scene[T]) SetEntry(step string) *Scene[T] {
s.Entry = step s.Entry = step
return s return s
} }
func (s *Scene[T]) setPluginName(name string) *Scene[T] { func (s *Scene[T]) setPluginName(name string) *Scene[T] {
s.PluginName = name s.PluginName = name
return s return s
} }
// OnStep registers a handler for a named scene step.
func (s *Scene[T]) OnStep(step string, handler SceneHandler[T]) *Scene[T] { func (s *Scene[T]) OnStep(step string, handler SceneHandler[T]) *Scene[T] {
s.steps[step] = handler s.steps[step] = handler
return s 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] { func (s *Scene[T]) OnCommand(cmd string, handler SceneHandler[T]) *Scene[T] {
s.commands[cmd] = handler s.commands[cmd] = handler
return s 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] { func (s *Scene[T]) OnMessage(handler SceneHandler[T]) *Scene[T] {
s.message = handler s.message = handler
return s return s
@@ -77,39 +95,45 @@ func (s *Scene[T]) executeMessage(ctx *SceneContext, db T) (SceneResult, bool, e
return result, true, err return result, true, err
} }
// SceneSession stores the active scene state for one session key.
type SceneSession struct { type SceneSession struct {
// Scene is the registered scene name for the active session.
Scene string Scene string
Step string // Step is the current step name inside the active scene.
Data []byte Step string
// Data stores opaque session payload bytes, typically JSON.
Data []byte
} }
// SetData sets the session data. It is thread-safe and can be used to store any arbitrary data as a byte slice. // SetData stores arbitrary opaque session data.
func (s *SceneSession) SetData(data []byte) { func (s *SceneSession) SetData(data []byte) {
s.Data = data s.Data = data
} }
// GetData retrieves the session data. It is thread-safe and returns the data as a byte slice. // GetData returns the raw session data payload.
func (s *SceneSession) GetData() []byte { func (s *SceneSession) GetData() []byte {
return s.Data return s.Data
} }
// HasData reports whether the session has a non-empty data payload.
func (s *SceneSession) HasData() bool { func (s *SceneSession) HasData() bool {
return len(s.Data) > 0 return len(s.Data) > 0
} }
// ClearData clears the session data. It is thread-safe and sets the data to nil. // ClearData removes any stored session data.
func (s *SceneSession) ClearData() { func (s *SceneSession) ClearData() {
s.Data = nil s.Data = nil
} }
// BindData binds the session data to the provided struct. // BindData unmarshals the stored JSON payload into v.
func (s *SceneSession) BindData(v any) error { func (s *SceneSession) BindData(v any) error {
if len(s.Data) == 0 { if len(s.Data) == 0 {
return nil // No data to bind, return nil error return nil
} }
return json.Unmarshal(s.Data, v) return json.Unmarshal(s.Data, v)
} }
// SaveData saves the provided struct as JSON in the session data. // SaveData marshals v as JSON and stores it in the session.
func (s *SceneSession) SaveData(v any) error { func (s *SceneSession) SaveData(v any) error {
data, err := json.Marshal(v) data, err := json.Marshal(v)
if err != nil { if err != nil {
@@ -119,22 +143,27 @@ func (s *SceneSession) SaveData(v any) error {
return nil return nil
} }
// SessionStore persists scene sessions by key.
type SessionStore interface { type SessionStore interface {
Get(key string) (SceneSession, error) Get(key string) (SceneSession, error)
Set(key string, session SceneSession) error Set(key string, session SceneSession) error
Delete(key string) error Delete(key string) error
} }
// MemorySessionStore stores scene sessions in memory.
type MemorySessionStore struct { type MemorySessionStore struct {
store map[string]SceneSession store map[string]SceneSession
mu sync.RWMutex mu sync.RWMutex
} }
// NewMemorySessionStore creates an empty in-memory session store.
func NewMemorySessionStore() *MemorySessionStore { func NewMemorySessionStore() *MemorySessionStore {
return &MemorySessionStore{ return &MemorySessionStore{
store: make(map[string]SceneSession), 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) { func (s *MemorySessionStore) Get(key string) (SceneSession, error) {
s.mu.RLock() s.mu.RLock()
defer s.mu.RUnlock() defer s.mu.RUnlock()
@@ -143,12 +172,16 @@ func (s *MemorySessionStore) Get(key string) (SceneSession, error) {
} }
return SceneSession{}, nil return SceneSession{}, nil
} }
// Set stores session under key.
func (s *MemorySessionStore) Set(key string, session SceneSession) error { func (s *MemorySessionStore) Set(key string, session SceneSession) error {
s.mu.Lock() s.mu.Lock()
s.store[key] = session s.store[key] = session
s.mu.Unlock() s.mu.Unlock()
return nil return nil
} }
// Delete removes the session stored under key.
func (s *MemorySessionStore) Delete(key string) error { func (s *MemorySessionStore) Delete(key string) error {
s.mu.Lock() s.mu.Lock()
delete(s.store, key) delete(s.store, key)
@@ -156,35 +189,45 @@ func (s *MemorySessionStore) Delete(key string) error {
return nil return nil
} }
// SceneResult describes how scene execution should proceed after a handler returns.
type SceneResult struct { type SceneResult struct {
Action SceneAction Action SceneAction
Next string Next string
} }
// SceneAction controls how the bot updates scene state after a handler returns.
type SceneAction int type SceneAction int
const ( const (
// SceneActionStay keeps the current scene and step active.
SceneActionStay SceneAction = iota SceneActionStay SceneAction = iota
// SceneActionNext moves the session to another named step.
SceneActionNext SceneActionNext
// SceneActionExit removes the current scene session.
SceneActionExit SceneActionExit
// SceneActionPass lets normal bot routing continue after the scene handler.
SceneActionPass SceneActionPass
) )
// SceneScope defines how scene sessions are keyed.
type SceneScope int type SceneScope int
const ( const (
// SceneScopeUser shares a scene across all chats for one user.
SceneScopeUser SceneScope = iota SceneScopeUser SceneScope = iota
// SceneScopeChat shares a scene across all users in one chat.
SceneScopeChat SceneScopeChat
// SceneScopeUserChat isolates a scene per user-chat pair.
SceneScopeUserChat SceneScopeUserChat
) )
type sceneRuntime interface { type sceneRuntime interface {
FindScene(name string) (*sceneMeta, bool) findScene(name string) (*sceneMeta, bool)
GetSession(key string) (SceneSession, error) getSession(key string) (SceneSession, error)
SetSession(key string, session SceneSession) error setSession(key string, session SceneSession) error
DeleteSession(key string) error deleteSession(key string) error
BuildSceneKey(scope SceneScope, ctx *MsgContext) (string, bool) buildSceneKey(scope SceneScope, ctx *MsgContext) (string, bool)
FindSceneSession(ctx *MsgContext) (string, SceneSession, error) findSceneSession(ctx *MsgContext) (string, SceneSession, error)
} }
type sceneMeta struct { type sceneMeta struct {
+18
View File
@@ -1,23 +1,41 @@
package laniakea package laniakea
// SceneContext wraps MsgContext with scene session state for scene handlers.
type SceneContext struct { type SceneContext struct {
*MsgContext *MsgContext
sess SceneSession sess SceneSession
key string key string
} }
// Next advances the current scene to step.
func (ctx *SceneContext) Next(step string) SceneResult { func (ctx *SceneContext) Next(step string) SceneResult {
return SceneResult{ return SceneResult{
Action: SceneActionNext, Action: SceneActionNext,
Next: step, Next: step,
} }
} }
// Stay keeps the current scene step active.
func (ctx *SceneContext) Stay() SceneResult { func (ctx *SceneContext) Stay() SceneResult {
return SceneResult{Action: SceneActionStay} return SceneResult{Action: SceneActionStay}
} }
// Exit leaves the current scene.
func (ctx *SceneContext) Exit() SceneResult { func (ctx *SceneContext) Exit() SceneResult {
return SceneResult{Action: SceneActionExit} return SceneResult{Action: SceneActionExit}
} }
// Pass stops scene handling and lets normal routing continue.
func (ctx *SceneContext) Pass() SceneResult { func (ctx *SceneContext) Pass() SceneResult {
return SceneResult{Action: SceneActionPass} 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)
}
+153 -28
View File
@@ -4,10 +4,11 @@ import (
"errors" "errors"
"fmt" "fmt"
"strings" "strings"
"time"
) )
func (bot *Bot[T]) tryHandleScene(ctx *MsgContext) (bool, error) { func (bot *Bot[T]) tryHandleScene(ctx *MsgContext) (bool, error) {
key, session, err := bot.FindSceneSession(ctx) key, session, err := bot.findSceneSession(ctx)
if err != nil { if err != nil {
if errors.Is(err, ErrCantFindSession) || errors.Is(err, ErrMessageNil) { if errors.Is(err, ErrCantFindSession) || errors.Is(err, ErrMessageNil) {
return false, nil return false, nil
@@ -26,24 +27,33 @@ func (bot *Bot[T]) tryHandleScene(ctx *MsgContext) (bool, error) {
if scene.PluginName != "" && scene.PluginName != plugin.name { if scene.PluginName != "" && scene.PluginName != plugin.name {
continue continue
} }
if !plugin.executeMiddlewares(ctx, bot.appData) {
return false, nil
}
sceneCtx := &SceneContext{ sceneCtx := &SceneContext{
MsgContext: ctx, MsgContext: ctx,
sess: session, sess: session,
key: key, key: key,
} }
return bot.executeScene(scene, sceneCtx)
return bot.executeScene(sceneCtx, scene)
} }
return false, ErrSceneNotFound return false, ErrSceneNotFound
} }
func (bot *Bot[T]) executeScene(scene *Scene[T], ctx *SceneContext) (bool, error) { func (bot *Bot[T]) executeScene(ctx *SceneContext, scene *Scene[T]) (bool, error) {
if ctx.MsgContext == nil || ctx.sess.Scene == "" { if ctx.MsgContext == nil || ctx.sess.Scene == "" {
return false, nil return false, nil
} }
text := ctx.Msg.Text
if text == "" { var text string
text = ctx.Msg.Caption if ctx.Msg != nil {
text = ctx.Msg.Text
if text == "" {
text = ctx.Msg.Caption
}
} }
text = strings.TrimSpace(text) text = strings.TrimSpace(text)
prefix, cmd, args := bot.parseCommand(text) prefix, cmd, args := bot.parseCommand(text)
if cmd != "" { if cmd != "" {
@@ -51,37 +61,143 @@ func (bot *Bot[T]) executeScene(scene *Scene[T], ctx *SceneContext) (bool, error
ctx.Text = args ctx.Text = args
ctx.Args = strings.Fields(args) ctx.Args = strings.Fields(args)
res, matched, err := scene.executeCommand(cmd, ctx, bot.dbContext) if _, ok := scene.commands[cmd]; ok {
if err != nil { startTime := time.Now()
return false, err bot.emitSceneStarted(ctx, scene, HandlerSceneCommandKind, cmd)
} res, _, err := scene.executeCommand(cmd, ctx, bot.appData)
if matched { if err != nil {
return bot.applySceneResult(scene, ctx, res) bot.emitSceneFinished(ctx, scene, HandlerSceneCommandKind, cmd, startTime, err)
bot.emitSceneError(ctx, scene, HandlerSceneCommandKind, cmd, err)
return false, err
}
from := ctx.sess.Step
ok, err := bot.applySceneResult(scene, ctx, res)
bot.emitSceneFinished(ctx, scene, HandlerSceneCommandKind, cmd, startTime, err)
if err != nil {
bot.emitSceneError(ctx, scene, HandlerSceneCommandKind, cmd, err)
}
if ok {
bot.emitSceneTransition(ctx, scene, from, res)
}
return ok, err
} }
} }
ctx.Text = text ctx.Text = text
ctx.Args = nil ctx.Args = nil
ctx.Prefix = "" ctx.Prefix = ""
if ctx.sess.Step != "" { if ctx.sess.Step != "" {
res, matched, err := scene.executeStep(ctx.sess.Step, ctx, bot.dbContext) step := ctx.sess.Step
if err != nil { if _, ok := scene.steps[step]; ok {
return false, err startTime := time.Now()
} bot.emitSceneStarted(ctx, scene, HandlerSceneStepKind, step)
if matched { res, _, err := scene.executeStep(step, ctx, bot.appData)
return bot.applySceneResult(scene, ctx, res) if err != nil {
bot.emitSceneFinished(ctx, scene, HandlerSceneStepKind, step, startTime, err)
bot.emitSceneError(ctx, scene, HandlerSceneStepKind, step, err)
return false, err
}
from := step
ok, err := bot.applySceneResult(scene, ctx, res)
bot.emitSceneFinished(ctx, scene, HandlerSceneStepKind, step, startTime, err)
if err != nil {
bot.emitSceneError(ctx, scene, HandlerSceneStepKind, from, err)
}
if ok {
bot.emitSceneTransition(ctx, scene, from, res)
}
return ok, err
} }
} }
res, matched, err := scene.executeMessage(ctx, bot.dbContext) if scene.message != nil {
if err != nil { startTime := time.Now()
return false, err bot.emitSceneStarted(ctx, scene, HandlerSceneMessageKind, "message_fallback")
} res, _, err := scene.executeMessage(ctx, bot.appData)
if matched { if err != nil {
return bot.applySceneResult(scene, ctx, res) bot.emitSceneFinished(ctx, scene, HandlerSceneMessageKind, "message_fallback", startTime, err)
bot.emitSceneError(ctx, scene, HandlerSceneMessageKind, "message_fallback", err)
return false, err
}
from := ctx.sess.Step
ok, err := bot.applySceneResult(scene, ctx, res)
bot.emitSceneFinished(ctx, scene, HandlerSceneMessageKind, "message_fallback", startTime, err)
if err != nil {
bot.emitSceneError(ctx, scene, HandlerSceneMessageKind, "message_fallback", err)
}
if ok {
bot.emitSceneTransition(ctx, scene, from, res)
}
return ok, err
} }
return false, nil return false, nil
} }
func (bot *Bot[T]) emitSceneStarted(ctx *SceneContext, scene *Scene[T], kind HandlerEventKind, name string) {
bot.safeEmitEvent(ctx.Context(), HandlerStartedEvent{
UpdateID: ctx.Update.UpdateID,
UpdateType: ctx.Update.Type,
Plugin: scene.PluginName,
HandlerKind: kind,
HandlerName: name,
FromID: ctx.FromID,
ChatID: ctx.ChatID,
})
}
func (bot *Bot[T]) emitSceneFinished(ctx *SceneContext, scene *Scene[T], kind HandlerEventKind, name string, startedAt time.Time, err error) {
bot.safeEmitEvent(ctx.Context(), HandlerFinishedEvent{
UpdateID: ctx.Update.UpdateID,
UpdateType: ctx.Update.Type,
Plugin: scene.PluginName,
HandlerKind: kind,
HandlerName: name,
FromID: ctx.FromID,
ChatID: ctx.ChatID,
Duration: time.Since(startedAt),
Err: err,
UserFacing: IsUserError(err),
})
}
func (bot *Bot[T]) emitSceneError(ctx *SceneContext, scene *Scene[T], kind HandlerEventKind, name string, err error) {
bot.safeEmitEvent(ctx.Context(), ErrorEvent{
UpdateID: ctx.Update.UpdateID,
UpdateType: ctx.Update.Type,
Plugin: scene.PluginName,
HandlerKind: kind,
HandlerName: name,
FromID: ctx.FromID,
ChatID: ctx.ChatID,
Err: err,
UserFacing: IsUserError(err),
})
}
func (bot *Bot[T]) emitSceneTransition(ctx *SceneContext, scene *Scene[T], from string, result SceneResult) {
if result.Action == SceneActionPass {
return
}
to := from
switch result.Action {
case SceneActionNext:
to = result.Next
case SceneActionExit:
to = ""
}
bot.safeEmitEvent(ctx.Context(), SceneTransitionEvent{
Plugin: scene.PluginName,
Scene: scene.Name,
From: from,
To: to,
Action: result.Action,
FromID: ctx.FromID,
ChatID: ctx.ChatID,
})
}
func (bot *Bot[T]) applySceneResult(scene *Scene[T], ctx *SceneContext, result SceneResult) (bool, error) { func (bot *Bot[T]) applySceneResult(scene *Scene[T], ctx *SceneContext, result SceneResult) (bool, error) {
switch result.Action { switch result.Action {
case SceneActionStay: case SceneActionStay:
@@ -89,7 +205,6 @@ func (bot *Bot[T]) applySceneResult(scene *Scene[T], ctx *SceneContext, result S
return false, err return false, err
} }
return true, nil return true, nil
case SceneActionNext: case SceneActionNext:
if result.Next == "" { if result.Next == "" {
return false, ErrSceneStepNotFound return false, ErrSceneStepNotFound
@@ -102,27 +217,37 @@ func (bot *Bot[T]) applySceneResult(scene *Scene[T], ctx *SceneContext, result S
return false, err return false, err
} }
return true, nil return true, nil
case SceneActionExit: case SceneActionExit:
if err := bot.sessionStore.Delete(ctx.key); err != nil { if err := bot.sessionStore.Delete(ctx.key); err != nil {
return false, err return false, err
} }
return true, nil return true, nil
case SceneActionPass: case SceneActionPass:
return false, nil return false, nil
default: default:
return false, nil return false, nil
} }
} }
func buildSceneKey(scope SceneScope, ctx *MsgContext) (string, bool) { func buildSceneKey(scope SceneScope, ctx *MsgContext) (string, bool) {
if ctx == nil {
return "", false
}
switch scope { switch scope {
case SceneScopeUserChat: 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 return fmt.Sprintf("user_id:%d:chat_id:%d", ctx.FromID, ctx.Msg.Chat.ID), true
case SceneScopeChat: case SceneScopeChat:
if ctx.Msg == nil || ctx.Msg.Chat == nil {
return "", false
}
return fmt.Sprintf("chat_id:%d", ctx.Msg.Chat.ID), true return fmt.Sprintf("chat_id:%d", ctx.Msg.Chat.ID), true
case SceneScopeUser: case SceneScopeUser:
if ctx.FromID == 0 {
return "", false
}
return fmt.Sprintf("user_id:%d", ctx.FromID), true return fmt.Sprintf("user_id:%d", ctx.FromID), true
default: default:
return "", false return "", false
+632
View File
@@ -0,0 +1,632 @@
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[NoData]("wizard")
scene := NewScene[NoData]("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[NoData]("wizard")
plugin.NewScene("signup").
SetEntry("start").
OnStep("start", func(ctx *SceneContext, db NoData) (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[NoData]{
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: 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: 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: 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: 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[NoData]("wizard")
plugin.NewScene("signup")
bot := &Bot[NoData]{
logger: slog.CreateLogger(),
sessionStore: NewMemorySessionStore(),
sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser},
}
bot.AddPlugins(plugin)
ctx := &MsgContext{
Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: 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[NoData]("wizard")
plugin.NewScene("signup").SetEntry("start")
bot := &Bot[NoData]{
logger: slog.CreateLogger(),
sessionStore: NewMemorySessionStore(),
sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser},
}
bot.AddPlugins(plugin)
ctx := &MsgContext{
Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: 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[NoData]("wizard")
plugin.NewScene("signup").
SetEntry("start").
OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) {
stepCalled = true
return ctx.Stay(), nil
}).
OnCommand("cancel", func(ctx *SceneContext, db NoData) (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[NoData]{
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: 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: 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 TestSceneCommandObserverEmitsLifecycleEvents(t *testing.T) {
observer := &recordingObserver{}
plugin := NewPlugin[NoData]("wizard")
plugin.NewScene("signup").
SetEntry("start").
OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) {
return ctx.Stay(), nil
}).
OnCommand("cancel", func(ctx *SceneContext, db NoData) (SceneResult, error) {
return ctx.Exit(), nil
})
bot := &Bot[NoData]{
logger: slog.CreateLogger(),
prefixes: []string{"/"},
sessionStore: NewMemorySessionStore(),
sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser},
observer: observer,
}
bot.AddPlugins(plugin)
enterCtx := &MsgContext{
Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: 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: 22,
Type: tgapi.UpdateTypeMessage,
Message: &tgapi.Message{
MessageID: 9,
Text: "/cancel",
Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate},
From: &tgapi.User{ID: 42},
},
})
if len(observer.started) != 1 {
t.Fatalf("expected one scene started event, got %d", len(observer.started))
}
if got := observer.started[0]; got.HandlerKind != HandlerSceneCommandKind || got.HandlerName != "cancel" || got.Plugin != "wizard" {
t.Fatalf("unexpected scene started event: %#v", got)
}
if len(observer.finished) != 1 {
t.Fatalf("expected one scene finished event, got %d", len(observer.finished))
}
if got := observer.finished[0]; got.HandlerKind != HandlerSceneCommandKind || got.HandlerName != "cancel" || got.Plugin != "wizard" || got.Err != nil {
t.Fatalf("unexpected scene finished event: %#v", got)
}
}
func TestSceneStepObserverEmitsLifecycleEvents(t *testing.T) {
observer := &recordingObserver{}
plugin := NewPlugin[NoData]("wizard")
plugin.NewScene("signup").
SetEntry("start").
OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) {
return ctx.Stay(), nil
})
bot := &Bot[NoData]{
logger: slog.CreateLogger(),
prefixes: []string{"/"},
sessionStore: NewMemorySessionStore(),
sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser},
observer: observer,
}
bot.AddPlugins(plugin)
enterCtx := &MsgContext{
Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: 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: 23,
Type: tgapi.UpdateTypeMessage,
Message: &tgapi.Message{
MessageID: 10,
Text: "hello there",
Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate},
From: &tgapi.User{ID: 42},
},
})
if len(observer.started) != 1 {
t.Fatalf("expected one scene started event, got %d", len(observer.started))
}
if got := observer.started[0]; got.HandlerKind != HandlerSceneStepKind || got.HandlerName != "start" || got.Plugin != "wizard" {
t.Fatalf("unexpected scene step started event: %#v", got)
}
if len(observer.finished) != 1 {
t.Fatalf("expected one scene finished event, got %d", len(observer.finished))
}
if got := observer.finished[0]; got.HandlerKind != HandlerSceneStepKind || got.HandlerName != "start" || got.Plugin != "wizard" || got.Err != nil {
t.Fatalf("unexpected scene step finished event: %#v", got)
}
}
func TestSceneMessageObserverEmitsLifecycleEvents(t *testing.T) {
observer := &recordingObserver{}
plugin := NewPlugin[NoData]("wizard")
scene := plugin.NewScene("signup").
SetEntry("start").
OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) {
return ctx.Stay(), nil
}).
OnMessage(func(ctx *SceneContext, db NoData) (SceneResult, error) {
return ctx.Exit(), nil
})
bot := &Bot[NoData]{
logger: slog.CreateLogger(),
prefixes: []string{"/"},
sessionStore: NewMemorySessionStore(),
sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser},
observer: observer,
}
bot.AddPlugins(plugin)
key, ok := buildSceneKey(SceneScopeUserChat, &MsgContext{
Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}},
FromID: 42,
})
if !ok {
t.Fatal("expected scene key to be built")
}
if err := bot.sessionStore.Set(key, SceneSession{Scene: scene.Name}); err != nil {
t.Fatalf("failed to seed scene session: %v", err)
}
bot.handle(context.Background(), &tgapi.Update{
UpdateID: 24,
Type: tgapi.UpdateTypeMessage,
Message: &tgapi.Message{
MessageID: 11,
Text: "hello there",
Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate},
From: &tgapi.User{ID: 42},
},
})
if len(observer.started) != 1 {
t.Fatalf("expected one scene started event, got %d", len(observer.started))
}
if got := observer.started[0]; got.HandlerKind != HandlerSceneMessageKind || got.HandlerName != "message_fallback" || got.Plugin != "wizard" {
t.Fatalf("unexpected scene message started event: %#v", got)
}
if len(observer.finished) != 1 {
t.Fatalf("expected one scene finished event, got %d", len(observer.finished))
}
if got := observer.finished[0]; got.HandlerKind != HandlerSceneMessageKind || got.HandlerName != "message_fallback" || got.Plugin != "wizard" || got.Err != nil {
t.Fatalf("unexpected scene message finished event: %#v", got)
}
}
func TestScenePassDoesNotPersistSessionData(t *testing.T) {
commandCalled := false
plugin := NewPlugin[NoData]("wizard")
plugin.NewCommand(func(ctx *MsgContext, db NoData) error {
commandCalled = true
return nil
}, "ping")
plugin.NewScene("signup").
SetEntry("start").
OnStep("start", func(ctx *SceneContext, db NoData) (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[NoData]{
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: 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: 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: 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[NoData]("wizard")
plugin.NewScene("signup").
SetEntry("start").
OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) {
return ctx.Stay(), nil
}).
OnMessage(func(ctx *SceneContext, db NoData) (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[NoData]{
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: 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: 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: 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[NoData]{
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[NoData]{
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[NoData]("signup").OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) {
return ctx.Stay(), nil
})
bot := &Bot[NoData]{
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
View File
@@ -9,8 +9,8 @@ import (
"net/http" "net/http"
"time" "time"
"git.nix13.pw/scuroneko/laniakea/utils" "git.scuroneko.dev/scuroneko/laniakea/utils"
"git.nix13.pw/scuroneko/slog" "git.scuroneko.dev/scuroneko/slog"
) )
// APIOpts holds configuration options for initializing the Telegram API client. // APIOpts holds configuration options for initializing the Telegram API client.
+8 -8
View File
@@ -3,14 +3,14 @@ package tgapi
// Chat represents a chat (private, group, supergroup, channel). // Chat represents a chat (private, group, supergroup, channel).
// See https://core.telegram.org/bots/api#chat // See https://core.telegram.org/bots/api#chat
type Chat struct { type Chat struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Type string `json:"type"` Type ChatType `json:"type"`
Title *string `json:"title,omitempty"` Title *string `json:"title,omitempty"`
Username *string `json:"username,omitempty"` Username *string `json:"username,omitempty"`
FirstName *string `json:"first_name,omitempty"` FirstName *string `json:"first_name,omitempty"`
LastName *string `json:"last_name,omitempty"` LastName *string `json:"last_name,omitempty"`
IsForum *bool `json:"is_forum,omitempty"` IsForum *bool `json:"is_forum,omitempty"`
IsDirectMessages *bool `json:"is_direct_messages,omitempty"` IsDirectMessages *bool `json:"is_direct_messages,omitempty"`
} }
// ChatType represents the type of a chat. // ChatType represents the type of a chat.
+1 -1
View File
@@ -1,6 +1,6 @@
package tgapi 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. // MessageID represents a message identifier wrapper returned by some API methods.
type MessageID struct { type MessageID struct {
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"io" "io"
"net/http" "net/http"
"git.nix13.pw/scuroneko/laniakea/utils" "git.scuroneko.dev/scuroneko/laniakea/utils"
) )
// UpdateParams holds parameters for the getUpdates method. // UpdateParams holds parameters for the getUpdates method.
+2 -2
View File
@@ -10,8 +10,8 @@ import (
"strings" "strings"
"time" "time"
"git.nix13.pw/scuroneko/laniakea/utils" "git.scuroneko.dev/scuroneko/laniakea/utils"
"git.nix13.pw/scuroneko/slog" "git.scuroneko.dev/scuroneko/slog"
) )
const ( const (
+223
View File
@@ -0,0 +1,223 @@
package laniakea
import (
"time"
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
)
func (bot *Bot[T]) handleUpdate(u *tgapi.Update, ctx *MsgContext) bool {
handled := false
for _, plugin := range bot.plugins {
handler, ok := plugin.handlers[u.Type]
if !ok {
continue
}
pluginCtx := cloneMsgContext(ctx)
if plugin.logger != nil {
pluginCtx.Logger = plugin.logger
}
if !plugin.executeMiddlewares(pluginCtx, bot.appData) {
continue
}
startTime := time.Now()
bot.safeEmitEvent(pluginCtx.Context(), HandlerStartedEvent{
UpdateID: u.UpdateID,
UpdateType: u.Type,
Plugin: plugin.name,
HandlerKind: HandlerUpdateKind,
HandlerName: string(u.Type),
FromID: pluginCtx.FromID,
ChatID: pluginCtx.ChatID,
})
err := handler(pluginCtx, bot.appData)
endEvent := HandlerFinishedEvent{
UpdateID: u.UpdateID,
UpdateType: u.Type,
Plugin: plugin.name,
HandlerKind: HandlerUpdateKind,
HandlerName: string(u.Type),
FromID: pluginCtx.FromID,
ChatID: pluginCtx.ChatID,
Duration: time.Since(startTime),
}
if err != nil {
endEvent.Err = err
endEvent.UserFacing = IsUserError(err)
}
bot.safeEmitEvent(pluginCtx.Context(), endEvent)
if err != nil {
bot.safeEmitEvent(pluginCtx.Context(), ErrorEvent{
UpdateID: u.UpdateID,
UpdateType: u.Type,
Plugin: plugin.name,
HandlerKind: HandlerUpdateKind,
HandlerName: string(u.Type),
FromID: pluginCtx.FromID,
ChatID: pluginCtx.ChatID,
Err: err,
UserFacing: IsUserError(err),
})
pluginCtx.error(err)
}
handled = true
}
return handled
}
func (bot *Bot[T]) prepareUpdateCtx(u *tgapi.Update, ctx *MsgContext) {
var from *tgapi.User
var chat *tgapi.Chat
switch u.Type {
case tgapi.UpdateTypeMessage:
if u.Message != nil {
ctx.Msg = u.Message
if u.Message.Chat != nil {
chat = u.Message.Chat
}
if u.Message.From != nil {
from = u.Message.From
}
}
case tgapi.UpdateTypeEditedMessage:
if u.EditedMessage != nil {
ctx.Msg = u.EditedMessage
if u.EditedMessage.Chat != nil {
chat = u.EditedMessage.Chat
}
if u.EditedMessage.From != nil {
from = u.EditedMessage.From
}
}
case tgapi.UpdateTypeChannelPost:
if u.ChannelPost != nil {
ctx.Msg = u.ChannelPost
if u.ChannelPost.Chat != nil {
chat = u.ChannelPost.Chat
}
if u.ChannelPost.From != nil {
from = u.ChannelPost.From
}
}
case tgapi.UpdateTypeEditedChannelPost:
if u.EditedChannelPost != nil {
ctx.Msg = u.EditedChannelPost
if u.EditedChannelPost.Chat != nil {
chat = u.EditedChannelPost.Chat
}
if u.EditedChannelPost.From != nil {
from = u.EditedChannelPost.From
}
}
case tgapi.UpdateTypeBusinessMessage:
if u.BusinessMessage != nil {
ctx.Msg = u.BusinessMessage
if u.BusinessMessage.Chat != nil {
chat = u.BusinessMessage.Chat
}
if u.BusinessMessage.From != nil {
from = u.BusinessMessage.From
}
}
case tgapi.UpdateTypeEditedBusinessMessage:
if u.EditedBusinessMessage != nil {
ctx.Msg = u.EditedBusinessMessage
if u.EditedBusinessMessage.Chat != nil {
chat = u.EditedBusinessMessage.Chat
}
if u.EditedBusinessMessage.From != nil {
from = u.EditedBusinessMessage.From
}
}
case tgapi.UpdateTypeInlineQuery:
if u.InlineQuery != nil {
from = &u.InlineQuery.From
}
case tgapi.UpdateTypeChosenInlineResult:
if u.ChosenInlineResult != nil {
from = &u.ChosenInlineResult.From
}
case tgapi.UpdateTypeCallbackQuery:
if u.CallbackQuery != nil {
if u.CallbackQuery.Message != nil {
ctx.Msg = u.CallbackQuery.Message
ctx.CallbackMsgId = u.CallbackQuery.Message.MessageID
if u.CallbackQuery.Message.Chat != nil {
chat = u.CallbackQuery.Message.Chat
}
}
if u.CallbackQuery.InlineMessageID != nil {
ctx.InlineMsgId = *u.CallbackQuery.InlineMessageID
}
ctx.CallbackQueryId = u.CallbackQuery.ID
from = &u.CallbackQuery.From
}
case tgapi.UpdateTypeShippingQuery:
if u.ShippingQuery != nil {
from = &u.ShippingQuery.From
}
case tgapi.UpdateTypePreCheckoutQuery:
if u.PreCheckoutQuery != nil {
from = &u.PreCheckoutQuery.From
}
case tgapi.UpdateTypePurchasedPaidMedia:
if u.PurchasedPaidMedia != nil {
from = &u.PurchasedPaidMedia.From
}
case tgapi.UpdateTypeMyChatMember:
if u.MyChatMember != nil {
from = &u.MyChatMember.From
chat = &u.MyChatMember.Chat
}
case tgapi.UpdateTypeChatMember:
if u.ChatMember != nil {
from = &u.ChatMember.From
chat = &u.ChatMember.Chat
}
case tgapi.UpdateTypeChatJoinRequest:
if u.ChatJoinRequest != nil {
from = &u.ChatJoinRequest.From
chat = &u.ChatJoinRequest.Chat
}
case tgapi.UpdateTypeBusinessConnection:
if u.BusinessConnection != nil {
from = &u.BusinessConnection.User
}
case tgapi.UpdateTypePollAnswer:
if u.PollAnswer != nil {
from = &u.PollAnswer.User
}
case tgapi.UpdateTypeMessageReaction:
if u.MessageReaction != nil {
from = u.MessageReaction.User
chat = u.MessageReaction.Chat
}
case tgapi.UpdateTypeChatBoost:
if u.ChatBoost != nil {
from = &u.ChatBoost.Boost.Source.User
chat = &u.ChatBoost.Chat
}
case tgapi.UpdateTypeRemovedChatBoost:
if u.RemovedChatBoost != nil {
from = &u.RemovedChatBoost.Source.User
chat = &u.RemovedChatBoost.Chat
}
}
if ctx.Msg != nil && from == nil {
from = ctx.Msg.From
}
if from != nil {
ctx.From = from
ctx.FromID = from.ID
} else {
ctx.FromID = 0
}
if chat != nil {
ctx.Chat = chat
ctx.ChatID = chat.ID
} else {
ctx.ChatID = 0
}
}
+1 -1
View File
@@ -3,7 +3,7 @@ package laniakea
import ( import (
"strings" "strings"
"git.nix13.pw/scuroneko/laniakea/utils" "git.scuroneko.dev/scuroneko/laniakea/utils"
) )
// Ptr returns a pointer to v. // Ptr returns a pointer to v.
+2 -2
View File
@@ -6,8 +6,8 @@ import (
"mime/multipart" "mime/multipart"
"testing" "testing"
"git.nix13.pw/scuroneko/laniakea/tgapi" "git.scuroneko.dev/scuroneko/laniakea/tgapi"
"git.nix13.pw/scuroneko/laniakea/utils" "git.scuroneko.dev/scuroneko/laniakea/utils"
) )
type multipartEncodeParams struct { type multipartEncodeParams struct {
+1 -1
View File
@@ -3,7 +3,7 @@ package utils
import ( import (
"os" "os"
"git.nix13.pw/scuroneko/slog" "git.scuroneko.dev/scuroneko/slog"
) )
// GetLoggerLevel returns DEBUG when DEBUG=true in env, otherwise FATAL. // GetLoggerLevel returns DEBUG when DEBUG=true in env, otherwise FATAL.
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"strings" "strings"
"testing" "testing"
"git.nix13.pw/scuroneko/slog" "git.scuroneko.dev/scuroneko/slog"
) )
func TestCreateFileLoggerWritesToConfiguredFile(t *testing.T) { func TestCreateFileLoggerWritesToConfiguredFile(t *testing.T) {
+2 -2
View File
@@ -2,7 +2,7 @@ package utils
const ( const (
// VersionString is the module version string. // VersionString is the module version string.
VersionString = "1.0.0-rc.12" VersionString = "1.0.0-rc.13"
// VersionMajor is the module major version. // VersionMajor is the module major version.
VersionMajor = 1 VersionMajor = 1
// VersionMinor is the module minor version. // VersionMinor is the module minor version.
@@ -10,5 +10,5 @@ const (
// VersionPatch is the module patch version. // VersionPatch is the module patch version.
VersionPatch = 0 VersionPatch = 0
// VersionBeta is the prerelease counter for the current version. // VersionBeta is the prerelease counter for the current version.
VersionBeta = 12 VersionBeta = 13
) )