REPOSITORY / ScuroNeko/Laniakea

Compare commits

DIFF REPOSITORY

Compare 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
ScuroNeko 3ad9e48d71 fix: guard nil logger in prefix check 2026-03-27 16:06:24 +03:00
ScuroNeko 4f8d583b03 wip: scene sessions 2026-03-27 16:05:20 +03:00
ScuroNeko 68e7529f16 Add framework backlog and signed-commit policy
Document scene modal-chat flow and other missing core concepts in TODO.md
Require agent-created commits to be GPG-signed and fail fast if signing cannot complete
2026-03-26 23:27:54 +03:00
ScuroNeko 0ee0917af5 Update repo agent documentation rules
Require paired English and Russian docs when expanding project documentation
Clarify changelog handling for main repo changes and keep rc.12 notes aligned
Ignore local editor and Codex config directories in git
2026-03-26 23:06:05 +03:00
ScuroNeko 8618397bc1 wip: expand docs and wiki structure
add wiki links to README and README_RU
fill Start here pages for setup, commands/plugins, and MsgContext
add wiki page-priority tracker and local wiki AGENTS rules
2026-03-26 18:35:35 +03:00
ScuroNeko 945b8240e6 wip: add long message helpers and payload type controls
- add centralized message validation errors
- switch command handlers to return error
- add explicit long plain-text reply helpers
- support strict payload type policy with debug logging
- document versioning and changelog workflow
2026-03-26 18:15:06 +03:00
53 changed files with 5894 additions and 935 deletions
+4
View File
@@ -1,2 +1,6 @@
.idea/
.wiki/
.vscode/
test/
.codex/
.codex
+57
View File
@@ -22,6 +22,18 @@ Review the codebase with focus on:
- When feasible, make small, high-confidence improvements directly.
- When uncertain, state confidence level and evidence.
## Documentation languages
- When creating or expanding project documentation, generate and maintain both English and Russian versions in the same turn whenever reasonably possible.
- For wiki pages, prefer paired pages such as `Page.md` and `Page-RU.md`.
- Keep English and Russian pages aligned in structure, major examples, and user-facing guidance.
- If only one language can be updated safely in the current turn, explicitly say which language is lagging and why.
## Wiki and backlog workflow
- Treat the wiki as the primary place for large design ideas, architectural drafts, and framework backlog notes.
- If the agent identifies a substantial new concept or design direction, such as scenes, callback agents, a webhook model, or another framework-level abstraction, the agent must ask the user whether it should also formalize that idea as a draft wiki page.
- When the user agrees, prefer paired wiki pages such as `Page.md` and `Page-RU.md`, and clearly mark draft design pages with `DRAFT` when the API is not implemented or not yet stable.
- Keep `TODO.md`, the wiki backlog pages, and `CHANGELOG.md` aligned when framework-level items move between planned and completed states.
## Go review expectations
Check for:
- bugs, fragile logic, invalid assumptions, nil handling issues, resource leaks;
@@ -76,6 +88,51 @@ Before finalizing changes, run the relevant project checks when available:
Prefer the repositorys documented commands. If multiple choices exist, use the most standard and least destructive ones first.
## Versioning and changelog
- After every code or documentation change in the main repository, update `CHANGELOG.md`.
- Changes made only inside the `.wiki/` repository do not require a `CHANGELOG.md` update.
- Add changes only to the section for the next version after the latest published git tag.
- The agent must check the latest published tag, `CHANGELOG.md`, and `utils/version.go` before editing the changelog.
- The agent must verify that the target changelog version matches the version declared in `utils/version.go`.
- If the latest published tag is, for example, `v1.0.0`, and `CHANGELOG.md` does not yet contain the next version section, the agent must stop and ask the user which version the change belongs to:
1. `v1.0.1`
2. `v1.1.0`
3. `v2.0.0`
- The agent must not guess the next version when that section is missing.
- If the user-selected version does not match `utils/version.go`, the agent must warn about the mismatch and require the version file to be updated before proceeding.
- Changelog entries must describe all user-visible behavior changes made in the turn, including API additions, fixes, behavior changes, and breaking changes.
- When a framework backlog item recorded in `TODO.md` is completed, the agent must also update the backlog status using the existing format:
1. move the completed item into the top of the `Done` section;
2. replace the numbered backlog label with a version tag, for example `1. Scene Model` becomes `[v2.0.0] Scene Model`;
3. keep the item title and descriptive notes aligned with the corresponding `CHANGELOG.md` entry.
- The agent must treat `TODO.md` and `CHANGELOG.md` as linked records: a completed backlog item should not be left in one file as done and in the other as still pending or undocumented.
## Breaking changes policy
- The agent must detect potential breaking changes before editing public APIs.
- Breaking changes are forbidden unless the selected target version is a new major version.
- If the requested change is breaking and the user did not bump the major version, the agent must stop and warn that the change is not allowed under the current version.
- In that case, the agent must offer only these options:
1. do not make the breaking change;
2. introduce a backward-compatible alternative such as a new method, function, type, or struct, but only if that keeps the codebase reasonably small and clear;
3. bump the major version and then apply the breaking change.
- Prefer additive compatibility over signature changes when the additive option is small and maintainable.
- Example: if a method like `ctx.answer(...)` needs an extra parameter, the agent must either require a major-version bump or add a new method that keeps the old method working.
## Commit message format
- When the user asks for a commit message, the agent must produce it in this format:
1. a short summary line;
2. up to three additional lines with only the most important changes;
3. each additional line must start on its own new line.
- The agent must output the commit message as a plain multiline block that the user can copy directly.
- Do not collapse the lines into a paragraph, bullet list, or wrapped prose explanation.
- Keep commit text concise and high-signal.
- Do not turn commit messages into changelogs.
## Commit signing
- All commits created by the agent must be GPG-signed.
- If commit signing or pushing requires leaving the sandbox, the agent must request escalation explicitly before running the command.
- If a signed commit cannot be created successfully, the agent must report the failure clearly and stop instead of creating an unsigned fallback commit.
## Output format
For repo-wide review tasks, structure the result as:
+75
View File
@@ -1,5 +1,80 @@
# 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
### Added
- `AnswerLong(...)`, `AnswerLongf(...)`, `KeyboardLong(...)`, and `SplitMessageText(...)` for explicit plain-text splitting of long replies without changing the semantics of existing single-message helpers.
- Centralized library-level validation errors in `errors.go`, including `ErrEmptyMessage`, `ErrMessageTooLong`, `ErrCaptionTooLong`, and context/target validation sentinels.
- `Bot.GetPayloadType()`, `InlineKeyboard.GetPayloadType()`, and optional strict payload decoding via `BotOpts.StrictPayloadType` / `Bot.SetStrictPayloadType(...)`.
- `MsgContext.BindArgs(...)` for binding positional command arguments into exported struct fields.
- Binding sentinels `ErrBindArgsTargetNotPointer`, `ErrBindArgsTargetNotStruct`, `ErrBindArgsUnsupportedFieldType`, and `ErrBindArgsConversion`.
- Work-in-progress scene/session support, including plugin scene registration, scoped scene sessions, scene entry/exit APIs on `MsgContext`, default in-memory session storage, scene-local routing before normal command handling, and state helpers on `SceneContext`.
### Changed
- `CommandExecutor` now returns `error`, and command, payload, and non-command update handlers now use centralized bot error handling for returned errors.
- README and README_RU examples now use the new handler signature and document the long-message helpers.
- README and README_RU now link to the project wiki, and the wiki now includes a page-priority tracker while content is being filled in.
- README and README_RU now document scenes, session scopes, scene state helpers, and `SceneActionPass` semantics.
- `TODO.md` and the framework backlog pages now group the remaining framework work into explicit priority 1, 2, and 3 buckets.
- Payload-type comments and docs now distinguish between the bot's default payload type and keyboard-local overrides.
- Scene runtime sentinel errors now have explicit godoc comments.
- Public scene structs now document their exported fields more explicitly.
- `MsgContext.Context()` now safely falls back to `context.Background()` when no request-scoped context is attached.
- `MsgContext` reply, edit, callback, delete, action, and draft-limiter paths now use the context accessor instead of reaching into raw internal state.
- Version constants were bumped to `v1.0.0-rc.12`.
### Fixed
- Message and caption validation now runs before Telegram API calls, rejecting empty messages, oversized message text, and oversized captions with stable sentinel errors.
- Draft flushing and draft updates now reject oversized messages before sending invalid requests.
- Callback payload decoding now optionally enforces strict type matching, while the default tolerant mode logs Base64-to-JSON decoding in debug mode and still accepts keyboard-local payload overrides.
- Positional argument binding now leaves missing trailing struct fields at zero values, joins the remaining arguments into the final string field, and returns clearer binding errors.
- Request-scoped contexts are now created per update handler execution and safely reused through `MsgContext.Context()` even for manually constructed test contexts.
- Command and payload handlers now have regression coverage for end-to-end typed argument binding through the normal routing path.
### Breaking Changes
- `CommandExecutor[T]` changed from `func(ctx *MsgContext, db T)` to `func(ctx *MsgContext, db T) error`.
- `Plugin.NewCommand(...)`, `Plugin.NewPayload(...)`, and `Plugin.AddUpdateHandler(...)` now require handlers with the new error-returning signature.
### Tests
- Added regression tests for `MsgContext.BindArgs(...)`, including scalar conversion, tail-string binding, zero-value trailing fields, invalid targets, unsupported field types, and end-to-end command/payload binding.
- Added scene regression tests for runtime guards, scene-local command handling, and `SceneActionPass` preserving session state.
- Added scene regression tests for message fallback handling, user-scoped session lookup without `Msg`, and custom `SessionStore` error propagation.
## v1.0.0-rc.11
### Fixed
+69 -23
View File
@@ -4,12 +4,14 @@
[![Go Version](https://img.shields.io/badge/Go-1.24+-00ADD8?logo=go&style=flat-square)](https://go.dev/)
[![License: GPL-3.0](https://img.shields.io/badge/License-GPL%203.0-blue.svg?style=flat-square)](LICENSE)
![Gitea Release](https://img.shields.io/gitea/v/release/ScuroNeko/Laniakea?gitea_url=https%3A%2F%2Fgit.nix13.pw&sort=semver&display_name=release&style=flat-square&color=purple&link=https%3A%2F%2Fgit.nix13.pw%2FScuroNeko%2FLaniakea%2Freleases)
![Gitea Release](https://img.shields.io/gitea/v/release/ScuroNeko/Laniakea?gitea_url=https%3A%2F%2Fgit.scuroneko.dev&sort=semver&display_name=release&style=flat-square&color=purple&link=https%3A%2F%2Fgit.scuroneko.dev%2FScuroNeko%2FLaniakea%2Freleases)
A lightweight, easy-to-use, and performant Telegram Bot API wrapper for Go. It simplifies bot development with a clean plugin system, middleware support, automatic command generation, and built-in rate limiting.
[На русском](README_RU.md)
[Wiki](https://git.scuroneko.dev/ScuroNeko/Laniakea/wiki)
---
## ✨ Features
@@ -19,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).
* **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).
* **Context-Aware:** Pass custom database or state contexts to your handlers.
* **Fluent Interface:** Chain methods for clean configuration (e.g., `bot.ErrorTemplate(...).AddPlugins(...)`).
* **Context-Aware:** Pass custom application data or state contexts to your handlers.
* **Configurable API:** Mix `Set...` and `Add...` helpers to configure bots clearly (for example, `bot.SetErrorTemplate(...).AddPlugins(...)`).
---
## 📦 Installation
```bash
go get git.nix13.pw/scuroneko/laniakea
go get git.scuroneko.dev/scuroneko/laniakea
```
or
@@ -45,17 +47,18 @@ package main
import (
"log"
"git.nix13.pw/scuroneko/laniakea" // Import the Laniakea library
"git.scuroneko.dev/scuroneko/laniakea" // Import the Laniakea library
)
// echo is a command handler function.
// It receives two parameters:
// - 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)
func echo(ctx *laniakea.MsgContext, db laniakea.NoDB) {
// - data: your shared application data (here we use NoData, a placeholder for no shared data)
func echo(ctx *laniakea.MsgContext, data laniakea.NoData) error {
// 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.Answer(ctx.Text) // User input WITHOUT command
return nil
}
func main() {
@@ -63,8 +66,8 @@ func main() {
opts := &laniakea.BotOpts{Token: "TOKEN"}
// 2. Initialize a new bot instance.
// We use laniakea.NoDB as the database context type (no database needed for this example).
bot, err := laniakea.NewBot[laniakea.NoDB](opts)
// We use laniakea.NoData as the application data type (no shared data needed for this example).
bot, err := laniakea.NewBot[laniakea.NoData](opts)
if err != nil {
log.Fatal(err)
}
@@ -73,7 +76,7 @@ func main() {
// 3. Create a new plugin named "ping".
// 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.
// p.NewCommand(echo, "echo") creates a command that triggers the 'echo' function on the "/echo" command.
@@ -81,14 +84,15 @@ func main() {
// 5. Add another command using an anonymous function (closure).
// This command simply replies "Pong" when the user sends "/ping".
p.AddCommand(p.NewCommand(func(ctx *laniakea.MsgContext, db laniakea.NoDB) {
p.AddCommand(p.NewCommand(func(ctx *laniakea.MsgContext, data laniakea.NoData) error {
ctx.Answer("Pong")
return nil
}, "ping"))
// 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.
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.
// This is optional but very useful for most bots.
@@ -105,11 +109,11 @@ func main() {
### How It Works
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.
4. `AddCommand`: Registers a command. The first argument is the handler function (func(*MsgContext, T)), 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.
6. `ErrorTemplate`: Sets a template for error messages. The %s placeholder is replaced by the actual error.
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 application data T, and return an error for centralized error handling.
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.
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.
@@ -128,9 +132,10 @@ bot.AddPlugins(plugin)
A command is a function that handles a specific bot command (e.g., /start).
```go
func myHandler(ctx *laniakea.MsgContext, db *MyDB) {
func myHandler(ctx *laniakea.MsgContext, db *MyDB) error {
// Access command arguments via ctx.Args ([]string)
// Reply to the user: ctx.Answer("some text")
return nil
}
```
@@ -139,8 +144,10 @@ func myHandler(ctx *laniakea.MsgContext, db *MyDB) {
Provides access to the incoming message and useful reply methods:
- `Answer(text string) *AnswerMessage`: Sends a message with parse_mode none.
- `AnswerLong(text string) []*AnswerMessage`: Splits long plain text into multiple messages.
- `AnswerMarkdown(text string) *AnswerMessage`: Sends a message formatted with MarkdownV2 (you handle escaping).
- `Keyboard(text string, keyboard *InlineKeyboard) *AnswerMessage`: Sends a message with parse_mode none and inline keyboard.
- `KeyboardLong(text string, keyboard *InlineKeyboard) []*AnswerMessage`: Splits long plain text into multiple messages and attaches the keyboard to the final chunk.
- `KeyboardMarkdown(text string, keyboard *InlineKeyboard) *AnswerMessage`: Sends a message formatted with MarkdownV2 (you handle escaping) and inline keyboard.
- `AnswerPhoto(photoId, text string) *AnswerMessage`: Sends a message with photo with parse_mode none.
- `AnswerPhotoMarkdown(photoId, text string) *AnswerMessage`: Sends a photo with MarkdownV2 caption (you handle escaping).
@@ -161,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.
### 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
type MyDB struct { /* ... */ }
@@ -172,9 +179,46 @@ bot, err := laniakea.NewBot[*MyDB](opts)
if err != nil {
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 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.
@@ -223,7 +267,7 @@ func adminOnlyMiddleware(ctx *laniakea.MsgContext, db *MyDB) bool {
- Middleware can modify the MsgContext (e.g., add custom fields) before the command runs.
## ⚙️ Advanced Configuration
- **Inline Keyboards**: Build keyboards using `laniakea.NewInlineKeyboardJson`, `laniakea.NewInlineKeyboardBase64`, or `laniakea.NewInlineKeyboard`.
- **Inline Keyboards**: Build keyboards using `laniakea.NewInlineKeyboardJson`, `laniakea.NewInlineKeyboardBase64`, or `laniakea.NewInlineKeyboard`. `Bot.SetPayloadType(...)` defines the default payload format, and `InlineKeyboard.SetPayloadType(...)` overrides it for one keyboard.
- **Rate Limiting**: Pass a configured utils.RateLimiter via BotOpts to handle Telegram's rate limits gracefully.
- **Localization**: `L10n` is safe for concurrent use once attached to the bot.
- **Custom Update Handlers**: Use `plugin.AddUpdateHandler(...)` for Telegram update types that are not part of the command/payload flow.
@@ -240,7 +284,9 @@ func adminOnlyMiddleware(ctx *laniakea.MsgContext, db *MyDB) bool {
This project is licensed under the GNU General Public License v3.0 — see the [LICENSE](LICENSE) file for details.
## 📚 Learn More
[GoDoc](https://pkg.go.dev/git.nix13.pw/scuroneko/laniakea)
[GoDoc](https://pkg.go.dev/git.scuroneko.dev/scuroneko/laniakea)
[Wiki](https://git.scuroneko.dev/ScuroNeko/Laniakea/wiki)
[Telegram Bot API](https://core.telegram.org/bots/api)
+69 -23
View File
@@ -4,12 +4,14 @@
[![Go Version](https://img.shields.io/badge/Go-1.24+-00ADD8?logo=go&style=flat-square)](https://go.dev/)
[![License: GPL-3.0](https://img.shields.io/badge/License-GPL%203.0-blue.svg?style=flat-square)](LICENSE)
![Gitea Release](https://img.shields.io/gitea/v/release/ScuroNeko/Laniakea?gitea_url=https%3A%2F%2Fgit.nix13.pw&sort=semver&display_name=release&style=flat-square&color=purple&link=https%3A%2F%2Fgit.nix13.pw%2FScuroNeko%2FLaniakea%2Freleases)
![Gitea Release](https://img.shields.io/gitea/v/release/ScuroNeko/Laniakea?gitea_url=https%3A%2F%2Fgit.scuroneko.dev&sort=semver&display_name=release&style=flat-square&color=purple&link=https%3A%2F%2Fgit.scuroneko.dev%2FScuroNeko%2FLaniakea%2Freleases)
Легковесная, простая в использовании и производительная обёртка для Telegram Bot API на Go. Она упрощает разработку ботов благодаря чистой системе плагинов, поддержке Middleware, автоматической генерации команд и встроенному рейтлимитеру.
[English](README.md)
[Wiki](https://git.scuroneko.dev/ScuroNeko/Laniakea/wiki)
---
## ✨ Возможности
@@ -20,15 +22,15 @@
* **Поддержка промежуточных слоёв (Middleware):** Выполняйте код до или после команд (например, логирование, проверка доступа).
* **Автоматическая генерация команд:** Генерируйте справку и списки команд автоматически.
* **Встроенный ограничитель запросов (Rate Limiter):** Защитите бота от превышения лимитов Telegram API (с обработкой `retry_after`).
* **Контекст данных:** Передавайте свой контекст базы данных или состояния в обработчики.
* **Текучий интерфейс (Fluent Interface):** Стройте цепочки методов для чистой конфигурации (например, `bot.ErrorTemplate(...).AddPlugins(...)`).
* **Контекст данных:** Передавайте общие данные приложения или state в обработчики.
* **Настраиваемый API:** Комбинируйте `Set...` и `Add...` helper-методы для понятной конфигурации, например `bot.SetErrorTemplate(...).AddPlugins(...)`.
---
## 📦 Установка
```bash
go get git.nix13.pw/scuroneko/laniakea
go get git.scuroneko.dev/scuroneko/laniakea
```
или
@@ -46,17 +48,18 @@ package main
import (
"log"
"git.nix13.pw/scuroneko/laniakea" // Импортируем библиотеку Laniakea
"git.scuroneko.dev/scuroneko/laniakea" // Импортируем библиотеку Laniakea
)
// echo — это функция-обработчик команды.
// Она получает два параметра:
// - ctx: контекст сообщения (содержит информацию о сообщении, отправителе, чате и т.д.)
// - db: ваш пользовательский контекст базы данных (здесь мы используем NoDB — заглушку)
func echo(ctx *laniakea.MsgContext, db laniakea.NoDB) {
// - data: ваши общие данные приложения (здесь мы используем NoData — заглушку без общих зависимостей)
func echo(ctx *laniakea.MsgContext, data laniakea.NoData) error {
// Отвечаем пользователю текстом, который он прислал, без префикса команды.
// ctx.Text содержит сообщение пользователя, из которого удалена часть с командой.
ctx.Answer(ctx.Text) // Ввод пользователя БЕЗ команды
return nil
}
func main() {
@@ -64,8 +67,8 @@ func main() {
opts := &laniakea.BotOpts{Token: "TOKEN"}
// 2. Инициализируем новый экземпляр бота.
// Используем laniakea.NoDB как тип контекста базы данных (база не нужна для примера).
bot, err := laniakea.NewBot[laniakea.NoDB](opts)
// Используем laniakea.NoData как тип данных приложения (общие зависимости не нужны для примера).
bot, err := laniakea.NewBot[laniakea.NoData](opts)
if err != nil {
log.Fatal(err)
}
@@ -74,7 +77,7 @@ func main() {
// 3. Создаём новый плагин с именем "ping".
// Плагины помогают группировать связанные команды и промежуточные обработчики.
p := laniakea.NewPlugin[laniakea.NoDB]("ping")
p := laniakea.NewPlugin[laniakea.NoData]("ping")
// 4. Добавляем команду в плагин.
// p.NewCommand(echo, "echo") создаёт команду, которая вызывает функцию 'echo' по команде "/echo".
@@ -82,14 +85,15 @@ func main() {
// 5. Добавляем ещё одну команду, используя анонимную функцию (замыкание).
// Эта команда просто отвечает "Pong", когда пользователь отправляет "/ping".
p.AddCommand(p.NewCommand(func(ctx *laniakea.MsgContext, db laniakea.NoDB) {
p.AddCommand(p.NewCommand(func(ctx *laniakea.MsgContext, data laniakea.NoData) error {
ctx.Answer("Pong")
return nil
}, "ping"))
// 6. Настраиваем бота: задаём шаблон ошибки и добавляем плагин.
// ErrorTemplate устанавливает формат для сообщений об ошибках (где %s будет заменён на текст ошибки).
// SetErrorTemplate устанавливает формат для сообщений об ошибках (где %s будет заменён на текст ошибки).
// AddPlugins(p) регистрирует наш плагин "ping" в боте.
bot = bot.ErrorTemplate("Ошибка\n\n%s").AddPlugins(p)
bot = bot.SetErrorTemplate("Ошибка\n\n%s").AddPlugins(p)
// 7. Автоматически генерируем команды, такие как /start, /help и список всех зарегистрированных команд.
// Это необязательно, но очень полезно для большинства ботов.
@@ -106,11 +110,11 @@ func main() {
### Как это работает
1. `BotOpts`: Содержит конфигурацию, например, токен API.
2. `NewBot[T]`: Создаёт экземпляр бота. Параметр типа T позволяет передать пользовательский контекст базы данных (например, *sql.DB), который будет доступен во всех обработчиках. Используйте laniakea.NoDB, если он не нужен.
2. `NewBot[T]`: Создаёт экземпляр бота. Параметр типа T позволяет передать общие данные приложения (например, *sql.DB или контейнер сервисов), которые будут доступны во всех обработчиках. Используйте laniakea.NoData, если они не нужны.
3. `NewPlugin`: Создаёт логическую группу для команд и Middleware.
4. `AddCommand`: Регистрирует команду. Первый аргумент — функция-обработчик (func(*MsgContext, T)), второй — имя команды (без слеша).
5. **Функции-обработчики**: Получают *MsgContext (детали сообщения, методы типа Answer) и ваш контекст базы данных T.
6. `ErrorTemplate`: Устанавливает шаблон для сообщений об ошибках. Плейсхолдер %s заменяется на текст ошибки.
4. `AddCommand`: Регистрирует команду. Первый аргумент — функция-обработчик (`func(*MsgContext, T) error`), второй — имя команды (без слеша).
5. **Функции-обработчики**: Получают *MsgContext (детали сообщения, методы типа Answer) и ваши данные приложения типа T, а ошибку возвращают для централизованной обработки.
6. `SetErrorTemplate`: Устанавливает шаблон для сообщений об ошибках. Плейсхолдер %s заменяется на текст ошибки.
7. `AutoGenerateCommands`: Регистрирует команды из плагинов в Telegram для поддерживаемых scope.
8. `Run()`: Запускает цикл опроса обновлений бота и возвращает ошибку, если старт или polling завершился неуспешно.
9. Экземпляр `Bot` одноразовый. После завершения `Run()` или `RunWithContext()` для следующего запуска создавайте новый бот.
@@ -129,9 +133,10 @@ bot.AddPlugins(plugin)
Команда — это функция, которая обрабатывает конкретную команду бота (например, /start).
```go
func myHandler(ctx *laniakea.MsgContext, db *MyDB) {
func myHandler(ctx *laniakea.MsgContext, db *MyDB) error {
// Доступ к аргументам команды через ctx.Args ([]string)
// Ответ пользователю: ctx.Answer("какой-то текст")
return nil
}
```
@@ -139,8 +144,10 @@ func myHandler(ctx *laniakea.MsgContext, db *MyDB) {
Предоставляет доступ к входящему сообщению и полезные методы для ответа:
- `Answer(text string)`: Отправляет сообщение с parse_mode none.
- `AnswerLong(text string) []*AnswerMessage`: Разбивает длинный plain text на несколько сообщений.
- `AnswerMarkdown(text string)`: Отправляет сообщение, отформатированное MarkdownV2 (экранирование на вашей стороне).
- `Keyboard(text string, keyboard *InlineKeyboard) *AnswerMessage`: Отправляет сообщение с parse_mode none и Inline клавиатурой.
- `KeyboardLong(text string, keyboard *InlineKeyboard) []*AnswerMessage`: Разбивает длинный plain text на несколько сообщений и вешает клавиатуру на последний chunk.
- `KeyboardMarkdown(text string, keyboard *InlineKeyboard) *AnswerMessage`: Отправляет сообщение, отформатированное MarkdownV2 (экранирование на вашей стороне), и Inline клавиатурой.
- `AnswerPhoto(photoId, text string) *AnswerMessage`: Отправляет фотографию с подписью и parse_mode none.
- `AnswerPhotoMarkdown(photoId, text string) *AnswerMessage`: Отправляет фотографию с подписью, отформатированной MarkdownV2 (экранирование на вашей стороне).
@@ -150,8 +157,8 @@ func myHandler(ctx *laniakea.MsgContext, db *MyDB) {
- Поля: `Text`, `Args`, `From`, `FromID`, `Msg`, `InlineMsgId`, `CallbackQueryId` и другие.
- И много других методов и полей!
### Контекст базы данных (Database Context)
Параметр типа `T` в `NewBot[T]` — мощная функция. Вы можете передать любой тип, но для разделяемых зависимостей вроде пула соединений с БД обычно стоит использовать pointer type.
### App Data
Параметр типа `T` в `NewBot[T]` — мощная возможность. Вы можете передать любой тип, но для разделяемых зависимостей вроде пула соединений с БД, контейнера сервисов или API-клиента обычно стоит использовать pointer type.
```go
type MyDB struct { /* ... */ }
@@ -160,9 +167,46 @@ bot, err := laniakea.NewBot[*MyDB](opts)
if err != nil {
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` есть два клиента:
@@ -220,7 +264,7 @@ func adminOnlyMiddleware(ctx *laniakea.MsgContext, db *MyDB) bool {
- Middleware может изменять MsgContext (например, добавлять пользовательские поля) перед запуском команды.
## ⚙️ Расширенная настройка
- **Инлайн-клавиатуры**: Создавайте клавиатуры с помощью `laniakea.NewInlineKeyboardJson`, `laniakea.NewInlineKeyboardBase64` или `laniakea.NewInlineKeyboard`.
- **Инлайн-клавиатуры**: Создавайте клавиатуры с помощью `laniakea.NewInlineKeyboardJson`, `laniakea.NewInlineKeyboardBase64` или `laniakea.NewInlineKeyboard`. `Bot.SetPayloadType(...)` задаёт payload format по умолчанию, а `InlineKeyboard.SetPayloadType(...)` переопределяет его для конкретной клавиатуры.
- **Ограничение запросов**: Передайте настроенный `utils.RateLimiter` через `BotOpts` для корректной обработки лимитов Telegram.
- **Локализация**: `L10n` безопасен для конкурентного использования после подключения к боту.
- **Пользовательские update handlers**: Используйте `plugin.AddUpdateHandler(...)` для Telegram update types вне command/payload flow.
@@ -236,7 +280,9 @@ func adminOnlyMiddleware(ctx *laniakea.MsgContext, db *MyDB) bool {
Этот проект лицензирован под GNU General Public License v3.0 - подробности см. в файле [LICENSE](LICENSE).
## 📚 Дополнительная информация
[GoDoc Laniakea](https://pkg.go.dev/git.nix13.pw/scuroneko/laniakea)
[GoDoc Laniakea](https://pkg.go.dev/git.scuroneko.dev/scuroneko/laniakea)
[Wiki](https://git.scuroneko.dev/ScuroNeko/Laniakea/wiki)
[Telegram Bot API](https://core.telegram.org/bots/api)
+27
View File
@@ -0,0 +1,27 @@
# TODO
The framework backlog has moved to the wiki.
Primary page:
- https://git.scuroneko.dev/ScuroNeko/Laniakea/wiki/Framework-Backlog
Russian page:
- https://git.scuroneko.dev/ScuroNeko/Laniakea/wiki/Framework-Backlog-RU
Current priority split:
- `Priority 2`: service layer and dependency graph model.
- `Partial`: webhook runtime model, plugin composition contract.
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`.
+252 -441
View File
@@ -4,21 +4,24 @@ import (
"context"
"errors"
"fmt"
"maps"
"reflect"
"sort"
"strings"
"sync"
"time"
"git.nix13.pw/scuroneko/extypes"
"git.nix13.pw/scuroneko/laniakea/tgapi"
"git.nix13.pw/scuroneko/laniakea/utils"
"git.nix13.pw/scuroneko/slog"
"git.scuroneko.dev/scuroneko/extypes"
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
"git.scuroneko.dev/scuroneko/laniakea/utils"
"git.scuroneko.dev/scuroneko/slog"
"github.com/alitto/pond/v2"
)
// DbContext is the generic dependency type injected into bots, plugins, and handlers.
// Use it for shared application state such as database handles or service containers.
// AppData is the generic shared application data type injected into bots,
// plugins, and handlers.
//
// Use it for long-lived shared dependencies such as database handles, service
// containers, API clients, or immutable configuration snapshots.
//
// Example:
//
@@ -28,18 +31,22 @@ import (
// if err != nil {
// return err
// }
// bot.DatabaseContext(myDB)
// bot.SetAppData(myDB)
//
// Use NoDB if no database is needed.
type DbContext any
// Use NoData if no shared application data is needed.
type AppData any
// NoDB is a placeholder type for bots that do not use a database.
// Use Bot[NoDB] to indicate no dependency injection is required.
type NoDB struct{ DbContext }
// NoData is a placeholder type for bots that do not use shared application
// data.
//
// 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.
// Used to inject database-specific log output (e.g., SQL queries, ORM events).
type DbLogger[T DbContext] func(db T) slog.LoggerWriter
// AppDataLogger builds a slog.LoggerWriter from injected application data.
//
// 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.
type BotPayloadType string
@@ -76,13 +83,14 @@ var (
//
// 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.
type Bot[T DbContext] struct {
token string
debug bool
errorTemplate string
username string
payloadType BotPayloadType
maxWorkers int
type Bot[T AppData] struct {
token string
debug bool
errorTemplate string
username string
payloadType BotPayloadType
strictPayloadType bool
maxWorkers int
logger *slog.Logger // Main bot logger (JSON stdout + optional file)
RequestLogger *slog.Logger // Optional request-level API logging
@@ -95,11 +103,16 @@ type Bot[T DbContext] struct {
api *tgapi.API // Telegram API client
uploader *tgapi.Uploader // File uploader
dbContext T // Injected database context
hasDBContext bool
warnedValueDB bool
l10n *L10n // Localization manager
draftProvider *DraftProvider // Draft message builder
l10n *L10n // Localization manager
draftProvider *DraftProvider // Draft message builder
observer Observer // Optional event observer for instrumentation
appData T // Injected application data
hasAppData bool
warnedValueData bool
sessionStore SessionStore // Session store for scene management
sceneScopePriority []SceneScope
updateOffsetMu sync.Mutex
updateOffset int // Last processed update ID
@@ -112,6 +125,18 @@ type Bot[T DbContext] struct {
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.
//
// Automatically:
@@ -156,22 +181,26 @@ func NewBot[T any](opts *BotOpts) (*Bot[T], error) {
}
bot := &Bot[T]{
updateOffset: 0,
errorTemplate: "%s",
payloadType: BotPayloadBase64,
maxWorkers: workers,
updateQueue: updateQueue,
api: api,
uploader: uploader,
debug: opts.Debug,
prefixes: prefixes,
token: opts.Token,
plugins: make([]Plugin[T], 0),
updateTypes: append([]tgapi.UpdateType{}, opts.UpdateTypes...),
runners: make([]Runner[T], 0),
extraLoggers: make([]*slog.Logger, 0),
l10n: &L10n{},
draftProvider: NewRandomDraftProvider(api),
updateOffset: 0,
errorTemplate: "%s",
payloadType: BotPayloadBase64,
strictPayloadType: opts.StrictPayloadType,
maxWorkers: workers,
updateQueue: updateQueue,
api: api,
uploader: uploader,
debug: opts.Debug,
prefixes: prefixes,
token: opts.Token,
plugins: make([]Plugin[T], 0),
updateTypes: append([]tgapi.UpdateType{}, opts.UpdateTypes...),
runners: make([]Runner[T], 0),
extraLoggers: make([]*slog.Logger, 0),
l10n: &L10n{},
draftProvider: NewRandomDraftProvider(api),
sessionStore: NewMemorySessionStore(),
sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser},
}
// Add API and Uploader loggers to extraLoggers for unified output
@@ -252,7 +281,158 @@ func (bot *Bot[T]) CloseRemote(ctx context.Context) error {
return nil
}
// Internal logger setup for the bot and optional request logger.
// GetUpdateOffset returns the current update offset (thread-safe).
func (bot *Bot[T]) GetUpdateOffset() int {
bot.updateOffsetMu.Lock()
defer bot.updateOffsetMu.Unlock()
return bot.updateOffset
}
// SetUpdateOffset sets the update offset for next GetUpdates call (thread-safe).
func (bot *Bot[T]) SetUpdateOffset(offset int) {
bot.updateOffsetMu.Lock()
defer bot.updateOffsetMu.Unlock()
bot.updateOffset = offset
}
// GetLogger returns the main bot logger.
func (bot *Bot[T]) GetLogger() *slog.Logger { return bot.logger }
// GetLoggerLevel returns the effective log level derived from the bot's debug
// flag.
func (bot *Bot[T]) GetLoggerLevel() slog.LogLevel {
level := slog.FATAL
if bot.debug {
level = slog.DEBUG
}
return level
}
// L10n translates a key in the given language.
// Returns empty string if translation not found.
func (bot *Bot[T]) L10n(lang, key string) string {
return bot.l10n.Translate(lang, key)
}
// RunWithContext starts the bot with a given context for graceful shutdown.
//
// This is the main entry point for bot execution. It:
// - Validates required configuration (prefixes, plugins)
// - Starts all registered runners as background goroutines
// - Begins polling for updates via Telegram's GetUpdates API
// - Processes updates concurrently using a worker pool with size configurable via BotOpts.MaxWorkers
//
// The context controls graceful shutdown. When canceled, the bot:
// - Stops polling for new updates
// - Finishes processing currently queued updates
// - Waits for registered runners to exit
//
// RunWithContext does not close API, uploader, or logger resources on return.
// The caller must invoke Close after RunWithContext finishes.
//
// A Bot is single-use. After RunWithContext returns, later calls return ErrBotAlreadyRun.
func (bot *Bot[T]) RunWithContext(ctx context.Context) error {
if len(bot.prefixes) == 0 {
return ErrNoPrefixes
}
if len(bot.plugins) == 0 {
return ErrNoPlugins
}
if err := bot.beginRun(); err != nil {
return err
}
defer bot.finishRun()
bot.ExecRunners(ctx)
bot.logger.Infoln("Bot running. Press CTRL+C to exit.")
// Start update polling in a goroutine
go func() {
defer func() {
if r := recover(); r != nil {
bot.logger.Errorln(fmt.Sprintf("panic in update polling: %v", r))
}
close(bot.updateQueue)
}()
retryDelay := time.Duration(0)
retryCount := 0
for {
select {
case <-ctx.Done():
return
default:
updates, err := bot.Updates(ctx)
if err != nil {
if errors.Is(err, context.Canceled) {
return
}
bot.logger.Errorln("failed to fetch updates:", err)
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)
select {
case <-ctx.Done():
if !timer.Stop() {
<-timer.C
}
return
case <-timer.C:
}
continue
}
retryDelay = 0
retryCount = 0
for _, update := range updates {
u := update // copy loop variable to avoid race condition
select {
case bot.updateQueue <- &u:
case <-ctx.Done():
return
}
}
}
}
}()
// Start worker pool for concurrent update handling
pool := pond.NewPool(bot.maxWorkers)
for update := range bot.updateQueue {
u := update // capture loop variable
pool.Submit(func() {
bot.handle(ctx, u)
})
}
pool.Stop() // Wait for all tasks to complete and stop the pool
bot.runnerOnceWG.Wait()
bot.runnerBgWG.Wait()
return nil
}
// Run starts the bot using a background context.
//
// Equivalent to RunWithContext(context.Background()).
// Use this for simple bots where graceful shutdown is not required.
//
// For production use, prefer RunWithContext to handle SIGINT/SIGTERM gracefully.
func (bot *Bot[T]) Run() error {
return bot.RunWithContext(context.Background())
}
func (bot *Bot[T]) initLoggers(opts *BotOpts) {
level := slog.FATAL
if opts.Debug {
@@ -284,396 +464,6 @@ func (bot *Bot[T]) initLoggers(opts *BotOpts) {
}
}
// GetUpdateOffset returns the current update offset (thread-safe).
func (bot *Bot[T]) GetUpdateOffset() int {
bot.updateOffsetMu.Lock()
defer bot.updateOffsetMu.Unlock()
return bot.updateOffset
}
// SetUpdateOffset sets the update offset for next GetUpdates call (thread-safe).
func (bot *Bot[T]) SetUpdateOffset(offset int) {
bot.updateOffsetMu.Lock()
defer bot.updateOffsetMu.Unlock()
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.
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
// flag.
func (bot *Bot[T]) GetLoggerLevel() slog.LogLevel {
level := slog.FATAL
if bot.debug {
level = slog.DEBUG
}
return level
}
// L10n translates a key in the given language.
// Returns empty string if translation not found.
func (bot *Bot[T]) L10n(lang, key string) string {
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
}
// 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 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.
func (bot *Bot[T]) SetPayloadType(t BotPayloadType) *Bot[T] {
bot.payloadType = 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] {
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.
//
// This is the main entry point for bot execution. It:
// - Validates required configuration (prefixes, plugins)
// - Starts all registered runners as background goroutines
// - Begins polling for updates via Telegram's GetUpdates API
// - Processes updates concurrently using a worker pool with size configurable via BotOpts.MaxWorkers
//
// The context controls graceful shutdown. When canceled, the bot:
// - Stops polling for new updates
// - Finishes processing currently queued updates
// - Waits for registered runners to exit
//
// RunWithContext does not close API, uploader, or logger resources on return.
// 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.
func (bot *Bot[T]) RunWithContext(ctx context.Context) error {
if len(bot.prefixes) == 0 {
return ErrNoPrefixes
}
if len(bot.plugins) == 0 {
return ErrNoPlugins
}
if err := bot.beginRun(); err != nil {
return err
}
defer bot.finishRun()
bot.ExecRunners(ctx)
bot.logger.Infoln("Bot running. Press CTRL+C to exit.")
// Start update polling in a goroutine
go func() {
defer func() {
if r := recover(); r != nil {
bot.logger.Errorln(fmt.Sprintf("panic in update polling: %v", r))
}
close(bot.updateQueue)
}()
retryDelay := time.Duration(0)
for {
select {
case <-ctx.Done():
return
default:
updates, err := bot.Updates(ctx)
if err != nil {
if errors.Is(err, context.Canceled) {
return
}
bot.logger.Errorln("failed to fetch updates:", err)
retryDelay = nextPollRetryDelay(retryDelay)
timer := time.NewTimer(retryDelay)
select {
case <-ctx.Done():
if !timer.Stop() {
<-timer.C
}
return
case <-timer.C:
}
continue
}
retryDelay = 0
for _, update := range updates {
u := update // copy loop variable to avoid race condition
select {
case bot.updateQueue <- &u:
case <-ctx.Done():
return
}
}
}
}
}()
// Start worker pool for concurrent update handling
pool := pond.NewPool(bot.maxWorkers)
for update := range bot.updateQueue {
u := update // capture loop variable
pool.Submit(func() {
bot.handle(u)
})
}
pool.Stop() // Wait for all tasks to complete and stop the pool
bot.runnerOnceWG.Wait()
bot.runnerBgWG.Wait()
return nil
}
// Run starts the bot using a background context.
//
// Equivalent to RunWithContext(context.Background()).
// Use this for simple bots where graceful shutdown is not required.
//
// For production use, prefer RunWithContext to handle SIGINT/SIGTERM gracefully.
func (bot *Bot[T]) Run() error {
return bot.RunWithContext(context.Background())
}
func (bot *Bot[T]) beginRun() error {
bot.runStateMu.Lock()
defer bot.runStateMu.Unlock()
@@ -715,9 +505,9 @@ func isNilValue[T any](v T) bool {
}
}
func shouldWarnOnValueDBContext[T any]() bool {
func shouldWarnOnValueAppData[T any]() bool {
t := reflect.TypeFor[T]()
if t == reflect.TypeFor[NoDB]() {
if t == reflect.TypeFor[NoData]() {
return false
}
switch t.Kind() {
@@ -728,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]{
name: p.name,
commands: make(map[string]*Command[T], len(p.commands)),
payloads: make(map[string]*Command[T], len(p.payloads)),
scenes: make(map[string]*Scene[T], len(p.scenes)),
middlewares: append(extypes.Slice[Middleware[T]](nil), p.middlewares...),
skipAutoCmd: p.skipAutoCmd,
logger: p.logger,
@@ -746,14 +537,15 @@ func clonePlugin[T DbContext](p *Plugin[T]) Plugin[T] {
for name, command := range p.payloads {
cloned.payloads[name] = cloneCommand(command)
}
for t, handler := range p.handlers {
cloned.handlers[t] = handler
for name, scene := range p.scenes {
cloned.scenes[name] = cloneScene(scene)
}
maps.Copy(cloned.handlers, p.handlers)
return cloned
}
func cloneCommand[T DbContext](command *Command[T]) *Command[T] {
func cloneCommand[T AppData](command *Command[T]) *Command[T] {
if command == nil {
return nil
}
@@ -763,3 +555,22 @@ func cloneCommand[T DbContext](command *Command[T]) *Command[T] {
cloned.middlewares = append(extypes.Slice[Middleware[T]](nil), command.middlewares...)
return &cloned
}
func cloneScene[T 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
}
+16 -3
View File
@@ -5,7 +5,7 @@ import (
"strconv"
"strings"
"git.nix13.pw/scuroneko/laniakea/tgapi"
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
)
// BotOpts holds configuration options for initializing a Bot.
@@ -56,6 +56,10 @@ type BotOpts struct {
// Use this to prioritize responsiveness over reliability.
DropRLOverflow bool
// StrictPayloadType disables callback payload fallback decoding.
// When enabled, the bot accepts only the configured default payload type.
StrictPayloadType bool
// MaxWorkers is the maximum number of update handlers that may run concurrently.
MaxWorkers int
}
@@ -75,6 +79,7 @@ type BotOpts struct {
// - API_URL: custom API endpoint
// - RATE_LIMIT: max requests per second (default: 30)
// - DROP_RL_OVERFLOW: "true" to drop updates on rate limit overflow
// - STRICT_PAYLOAD_TYPE: "true" to reject callback payloads encoded in a different format
// - MAX_WORKERS: maximum number of concurrent update handlers (default: 32)
//
// Returns a populated BotOpts.
@@ -116,8 +121,9 @@ func LoadOptsFromEnv() *BotOpts {
UseTestServer: os.Getenv("USE_TEST_SERVER") == "true",
APIUrl: os.Getenv("API_URL"),
RateLimit: rateLimit,
DropRLOverflow: os.Getenv("DROP_RL_OVERFLOW") == "true",
RateLimit: rateLimit,
DropRLOverflow: os.Getenv("DROP_RL_OVERFLOW") == "true",
StrictPayloadType: os.Getenv("STRICT_PAYLOAD_TYPE") == "true",
MaxWorkers: maxWorkers,
}
@@ -208,6 +214,13 @@ func (opts *BotOpts) SetDropRLOverflow(drop bool) *BotOpts {
return opts
}
// SetStrictPayloadType enables or disables strict callback payload decoding.
// When enabled, the bot accepts only the configured default payload type.
func (opts *BotOpts) SetStrictPayloadType(strict bool) *BotOpts {
opts.StrictPayloadType = strict
return opts
}
// SetMaxWorkers sets the maximum number of concurrent update handlers.
// Must be called before NewBot, as the value is captured during bot creation.
//
+10 -1
View File
@@ -4,7 +4,7 @@ import (
"reflect"
"testing"
"git.nix13.pw/scuroneko/laniakea/tgapi"
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
)
func TestLoadOptsFromEnvIgnoresEmptyUpdateTypes(t *testing.T) {
@@ -45,3 +45,12 @@ func TestLoadPrefixesFromEnvDropsEmptyValues(t *testing.T) {
t.Fatalf("unexpected prefixes: got %v want %v", got, want)
}
}
func TestLoadOptsFromEnvReadsStrictPayloadType(t *testing.T) {
t.Setenv("STRICT_PAYLOAD_TYPE", "true")
opts := LoadOptsFromEnv()
if !opts.StrictPayloadType {
t.Fatal("expected StrictPayloadType to be enabled")
}
}
+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
}
+59
View File
@@ -0,0 +1,59 @@
package laniakea
func (bot *Bot[T]) getSession(key string) (SceneSession, error) {
return bot.sessionStore.Get(key)
}
func (bot *Bot[T]) setSession(key string, session SceneSession) error {
return bot.sessionStore.Set(key, session)
}
func (bot *Bot[T]) deleteSession(key string) error {
return bot.sessionStore.Delete(key)
}
func (bot *Bot[T]) findScene(name string) (*sceneMeta, bool) {
for _, plugin := range bot.plugins {
scene, ok := plugin.scenes[name]
if !ok {
continue
}
steps := make(map[string]struct{}, len(scene.steps))
for step := range scene.steps {
steps[step] = struct{}{}
}
return &sceneMeta{
Name: scene.Name,
Scope: scene.Scope,
Entry: scene.Entry,
Steps: steps,
}, true
}
return nil, false
}
func (bot *Bot[T]) findSceneSession(ctx *MsgContext) (string, SceneSession, error) {
var zero SceneSession
for _, scope := range bot.sceneScopePriority {
key, ok := buildSceneKey(scope, ctx)
if !ok {
continue
}
session, err := bot.sessionStore.Get(key)
if err != nil {
return "", zero, err
}
if session.Scene != "" {
return key, session, nil
}
}
return "", zero, ErrCantFindSession
}
func (bot *Bot[T]) buildSceneKey(scope SceneScope, ctx *MsgContext) (string, bool) {
return buildSceneKey(scope, ctx)
}
+391 -37
View File
@@ -3,17 +3,51 @@ package laniakea
import (
"context"
"errors"
"io"
"net/http"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
"git.nix13.pw/scuroneko/laniakea/tgapi"
"git.nix13.pw/scuroneko/slog"
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
"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) {
bot := &Bot[NoDB]{updateTypes: []tgapi.UpdateType{tgapi.UpdateTypeMessage}}
bot := &Bot[NoData]{updateTypes: []tgapi.UpdateType{tgapi.UpdateTypeMessage}}
got := bot.GetUpdateTypes()
got[0] = tgapi.UpdateTypeCallbackQuery
@@ -24,17 +58,17 @@ func TestGetUpdateTypesReturnsCopy(t *testing.T) {
}
func TestAddPluginsSnapshotsConfiguration(t *testing.T) {
bot := &Bot[NoDB]{logger: slog.CreateLogger()}
plugin := NewPlugin[NoDB]("demo")
bot := &Bot[NoData]{logger: slog.CreateLogger()}
plugin := NewPlugin[NoData]("demo")
cmd := plugin.NewCommand(func(ctx *MsgContext, db NoDB) {}, "start")
plugin.AddMiddleware(NewMiddleware("base", func(ctx *MsgContext, db NoDB) bool { return true }))
cmd := plugin.NewCommand(func(ctx *MsgContext, db NoData) error { return nil }, "start")
plugin.AddMiddleware(NewMiddleware("base", func(ctx *MsgContext, db NoData) bool { return true }))
bot.AddPlugins(plugin)
cmd.SetDescription("mutated after registration")
plugin.NewCommand(func(ctx *MsgContext, db NoDB) {}, "late")
plugin.AddMiddleware(NewMiddleware("late", func(ctx *MsgContext, db NoDB) bool { return true }))
plugin.NewCommand(func(ctx *MsgContext, db NoData) error { return nil }, "late")
plugin.AddMiddleware(NewMiddleware("late", func(ctx *MsgContext, db NoData) bool { return true }))
registered := bot.plugins[0]
if _, exists := registered.commands["late"]; exists {
@@ -48,9 +82,25 @@ func TestAddPluginsSnapshotsConfiguration(t *testing.T) {
}
}
func TestBotPayloadTypeConfiguration(t *testing.T) {
bot := &Bot[NoData]{payloadType: BotPayloadBase64}
if got := bot.GetPayloadType(); got != BotPayloadBase64 {
t.Fatalf("unexpected initial payload type: %q", got)
}
bot.SetPayloadType(BotPayloadJson)
if got := bot.GetPayloadType(); got != BotPayloadJson {
t.Fatalf("unexpected updated payload type: %q", got)
}
bot.SetStrictPayloadType(true)
if !bot.strictPayloadType {
t.Fatal("expected strict payload type to be enabled")
}
}
func TestAddPluginsSkipsNilPlugin(t *testing.T) {
bot := &Bot[NoDB]{logger: slog.CreateLogger()}
plugin := NewPlugin[NoDB]("demo")
bot := &Bot[NoData]{logger: slog.CreateLogger()}
plugin := NewPlugin[NoData]("demo")
bot.AddPlugins(nil, plugin)
@@ -63,7 +113,7 @@ func TestAddPluginsSkipsNilPlugin(t *testing.T) {
}
func TestInitLoggersFallsBackToStdoutLoggerOnFileError(t *testing.T) {
bot := &Bot[NoDB]{}
bot := &Bot[NoData]{}
bot.initLoggers(&BotOpts{
Debug: true,
@@ -106,39 +156,39 @@ func TestNextPollRetryDelay(t *testing.T) {
}
}
func TestAddDatabaseLoggerWriterSkipsWhenDBContextIsUnset(t *testing.T) {
bot := &Bot[NoDB]{logger: slog.CreateLogger()}
func TestAddDatabaseLoggerWriterSkipsWhenAppDataIsUnset(t *testing.T) {
bot := &Bot[NoData]{logger: slog.CreateLogger()}
called := false
bot.AddDatabaseLoggerWriter(func(db NoDB) slog.LoggerWriter {
bot.AddAppDataLoggerWriter(func(db NoData) slog.LoggerWriter {
called = true
return nil
})
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{}
bot := &Bot[*testDB]{logger: slog.CreateLogger()}
var db *testDB
bot.DatabaseContext(db)
bot.SetAppData(db)
called := false
bot.AddDatabaseLoggerWriter(func(db *testDB) slog.LoggerWriter {
bot.AddAppDataLoggerWriter(func(db *testDB) slog.LoggerWriter {
called = true
return nil
})
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 dbIface interface{ Ping() error }
@@ -147,36 +197,64 @@ func TestShouldWarnOnValueDBContext(t *testing.T) {
got bool
want bool
}{
{name: "NoDB", got: shouldWarnOnValueDBContext[NoDB](), want: false},
{name: "pointer", got: shouldWarnOnValueDBContext[*testDB](), want: false},
{name: "interface", got: shouldWarnOnValueDBContext[dbIface](), want: false},
{name: "map", got: shouldWarnOnValueDBContext[map[string]int](), want: false},
{name: "struct", got: shouldWarnOnValueDBContext[testDB](), want: true},
{name: "int", got: shouldWarnOnValueDBContext[int](), want: true},
{name: "NoData", got: shouldWarnOnValueAppData[NoData](), want: false},
{name: "pointer", got: shouldWarnOnValueAppData[*testDB](), want: false},
{name: "interface", got: shouldWarnOnValueAppData[dbIface](), want: false},
{name: "map", got: shouldWarnOnValueAppData[map[string]int](), want: false},
{name: "struct", got: shouldWarnOnValueAppData[testDB](), want: true},
{name: "int", got: shouldWarnOnValueAppData[int](), want: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
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{}
bot := &Bot[testDB]{logger: slog.CreateLogger()}
bot.DatabaseContext(testDB{})
if !bot.warnedValueDB {
t.Fatal("expected value-typed database context to mark warning state")
bot.SetAppData(testDB{})
if !bot.warnedValueData {
t.Fatal("expected value-typed app data to mark warning state")
}
ptrBot := &Bot[*testDB]{logger: slog.CreateLogger()}
ptrBot.DatabaseContext(&testDB{})
if ptrBot.warnedValueDB {
t.Fatal("did not expect pointer-typed database context to mark warning state")
ptrBot.SetAppData(&testDB{})
if ptrBot.warnedValueData {
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)
}
}
@@ -184,10 +262,10 @@ func TestRunWithContextRejectsSecondRun(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
bot := &Bot[NoDB]{
bot := &Bot[NoData]{
logger: slog.CreateLogger(),
prefixes: []string{"/"},
plugins: []Plugin[NoDB]{{name: "demo"}},
plugins: []Plugin[NoData]{{name: "demo"}},
updateQueue: make(chan *tgapi.Update, 1),
maxWorkers: 1,
}
@@ -199,3 +277,279 @@ func TestRunWithContextRejectsSecondRun(t *testing.T) {
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"
"strings"
"git.nix13.pw/scuroneko/laniakea/tgapi"
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
)
// CmdRegexp matches command names allowed for Telegram command registration.
+8 -8
View File
@@ -10,8 +10,8 @@ import (
"sync/atomic"
"testing"
"git.nix13.pw/scuroneko/laniakea/tgapi"
"git.nix13.pw/scuroneko/slog"
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
"git.scuroneko.dev/scuroneko/slog"
)
type roundTripFunc func(*http.Request) (*http.Response, error)
@@ -43,16 +43,16 @@ func TestAutoGenerateCommandsChecksLimitBeforeDelete(t *testing.T) {
}
}()
plugin := NewPlugin[NoDB]("overflow")
exec := func(ctx *MsgContext, db NoDB) {}
plugin := NewPlugin[NoData]("overflow")
exec := func(ctx *MsgContext, db NoData) error { return nil }
for i := 0; i < 101; i++ {
plugin.AddCommand(NewCommand(exec, "cmd"+strconv.Itoa(i)))
}
bot := &Bot[NoDB]{
bot := &Bot[NoData]{
api: api,
logger: slog.CreateLogger(),
plugins: []Plugin[NoDB]{*plugin},
plugins: []Plugin[NoData]{*plugin},
}
err := bot.AutoGenerateCommands()
@@ -65,8 +65,8 @@ func TestAutoGenerateCommandsChecksLimitBeforeDelete(t *testing.T) {
}
func TestGatherCommandsForPluginReturnsSortedCommands(t *testing.T) {
plugin := NewPlugin[NoDB]("sorted")
exec := func(ctx *MsgContext, db NoDB) {}
plugin := NewPlugin[NoData]("sorted")
exec := func(ctx *MsgContext, db NoData) error { return nil }
plugin.AddCommand(NewCommand(exec, "zeta"))
plugin.AddCommand(NewCommand(exec, "alpha"))
+3 -3
View File
@@ -13,17 +13,17 @@ Core concepts:
Example usage:
bot, err := laniakea.NewBot[*mydb.DBContext](laniakea.LoadOptsFromEnv())
bot, err := laniakea.NewBot[*mydb.AppData](laniakea.LoadOptsFromEnv())
if err != nil {
return err
}
bot.DatabaseContext(myDB).
bot.SetAppData(myDB).
AddUpdateType(tgapi.UpdateTypeMessage).
AddPrefixes("/", "!").
AddPlugins(&startPlugin, &helpPlugin).
AddMiddleware(authMiddleware, logMiddleware).
AddRunner(cleanupRunner).
AddL10n(l10n.New())
SetL10n(l10n.New())
return bot.Run()
+7 -5
View File
@@ -1,17 +1,13 @@
package laniakea
import (
"errors"
"math/rand/v2"
"sync"
"sync/atomic"
"git.nix13.pw/scuroneko/laniakea/tgapi"
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
)
// ErrDraftChatIDZero is returned when a draft is used without setting a chat ID.
var ErrDraftChatIDZero = errors.New("zero draft chat ID")
// Interface for generating unique draft IDs.
type draftIdGenerator interface {
// Next returns the next unique draft ID.
@@ -221,6 +217,9 @@ func (d *Draft) Flush() error {
if d.chatID == 0 {
return ErrDraftChatIDZero
}
if err := validateMessageText(d.Message); err != nil {
return err
}
params := tgapi.SendMessageP{
ChatID: d.chatID,
@@ -245,6 +244,9 @@ func (d *Draft) push(text string) error {
return ErrDraftChatIDZero
}
d.Message += text
if err := validateMessageText(d.Message); err != nil {
return err
}
params := tgapi.SendMessageDraftP{
ChatID: d.chatID,
DraftID: d.ID,
+22 -3
View File
@@ -1,10 +1,12 @@
package laniakea
import (
"errors"
"strings"
"testing"
"git.nix13.pw/scuroneko/laniakea/tgapi"
"git.nix13.pw/scuroneko/slog"
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
"git.scuroneko.dev/scuroneko/slog"
)
func TestDraftFlushRequiresChatID(t *testing.T) {
@@ -20,7 +22,7 @@ func TestMsgContextNewDraftWorksWithoutLimiter(t *testing.T) {
ctx := &MsgContext{
Api: &tgapi.API{},
Msg: &tgapi.Message{
Chat: &tgapi.Chat{ID: 42, Type: string(tgapi.ChatTypePrivate)},
Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate},
},
Logger: slog.CreateLogger(),
draftProvider: NewRandomDraftProvider(&tgapi.API{}),
@@ -34,3 +36,20 @@ func TestMsgContextNewDraftWorksWithoutLimiter(t *testing.T) {
t.Fatalf("unexpected chat id: %d", draft.chatID)
}
}
func TestDraftFlushRejectsLongMessage(t *testing.T) {
draft := NewRandomDraftProvider(&tgapi.API{}).NewDraft(tgapi.ParseNone).SetChat(42, 0)
draft.Message = strings.Repeat("a", maxMessageTextLen+1)
if err := draft.Flush(); !errors.Is(err, ErrMessageTooLong) {
t.Fatalf("expected ErrMessageTooLong, got %v", err)
}
}
func TestDraftPushRejectsLongMessage(t *testing.T) {
draft := NewRandomDraftProvider(&tgapi.API{}).NewDraft(tgapi.ParseNone).SetChat(42, 0)
if err := draft.Push(strings.Repeat("a", maxMessageTextLen+1)); !errors.Is(err, ErrMessageTooLong) {
t.Fatalf("expected ErrMessageTooLong, got %v", err)
}
}
+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
}
+81
View File
@@ -0,0 +1,81 @@
package laniakea
import (
"errors"
"fmt"
"unicode/utf8"
)
const (
maxMessageTextLen = 4096
maxMessageCaptionLen = 1024
)
var (
// ErrEmptyMessage reports that a required message text is empty.
ErrEmptyMessage = errors.New("empty message")
// ErrMessageTooLong reports that a message exceeds Telegram's text limit.
ErrMessageTooLong = errors.New("message too long")
// ErrCaptionTooLong reports that a caption exceeds Telegram's caption limit.
ErrCaptionTooLong = errors.New("caption too long")
// ErrMessageSplitImpossible reports that automatic message splitting cannot preserve semantics.
ErrMessageSplitImpossible = errors.New("message split is impossible")
// ErrPayloadTypeMismatch reports that callback payload encoding does not match bot policy.
ErrPayloadTypeMismatch = errors.New("payload type mismatch")
// ErrDraftChatIDZero reports that a draft has no target chat ID.
ErrDraftChatIDZero = errors.New("zero draft chat ID")
// ErrMessageNil reports that a required message value is nil.
ErrMessageNil = errors.New("message is nil")
// ErrMessageContextNil reports that an operation requires ctx.Msg but none is set.
ErrMessageContextNil = errors.New("message context is nil")
// ErrEditTargetMissing reports that an edit operation has no message target.
ErrEditTargetMissing = errors.New("edit target is missing")
// ErrCallbackMessageMissing reports that a callback operation has no callback message target.
ErrCallbackMessageMissing = errors.New("callback message is missing")
// ErrDraftProviderNil reports that draft creation was requested without a draft provider.
ErrDraftProviderNil = errors.New("draft provider is nil")
// ErrAPIIsNil reports that an operation requires an API client but none is set.
ErrAPIIsNil = errors.New("api is nil")
// ErrMessageIDZero reports that an operation requires a non-zero message ID.
ErrMessageIDZero = errors.New("message ID is zero")
// ErrBindArgsTargetNotPointer reports that BindArgs received a nil or non-pointer destination.
ErrBindArgsTargetNotPointer = errors.New("bind args: dst must be a non-nil pointer")
// ErrBindArgsTargetNotStruct reports that BindArgs received a pointer to a non-struct value.
ErrBindArgsTargetNotStruct = errors.New("bind args: dst must point to a struct")
// ErrBindArgsUnsupportedFieldType reports that BindArgs encountered an unsupported field kind.
ErrBindArgsUnsupportedFieldType = errors.New("bind args: unsupported field type")
// ErrBindArgsConversion reports that BindArgs could not convert a string argument into a field type.
ErrBindArgsConversion = errors.New("bind args: conversion failed")
// ErrCantFindSession reports that no scene session matches the current context.
ErrCantFindSession = errors.New("can't find session for this context")
// ErrSceneNotFound reports that the requested scene is not registered.
ErrSceneNotFound = errors.New("scene not found")
// ErrSceneStepNotFound reports that the requested scene step is not registered.
ErrSceneStepNotFound = errors.New("scene step not found")
// ErrNotInScene reports that the current context has no active scene session.
ErrNotInScene = errors.New("not in scene")
// ErrSceneEntryNotSet reports that a scene has no configured entry step.
ErrSceneEntryNotSet = errors.New("scene entry step not set")
// ErrSceneRuntimeNil reports that scene APIs were used without an attached runtime.
ErrSceneRuntimeNil = errors.New("scene runtime is nil")
)
func validateMessageText(text string) error {
length := utf8.RuneCountInString(text)
switch {
case length == 0:
return ErrEmptyMessage
case length > maxMessageTextLen:
return fmt.Errorf("%w: got %d, limit %d", ErrMessageTooLong, length, maxMessageTextLen)
default:
return nil
}
}
func validateCaptionText(text string) error {
length := utf8.RuneCountInString(text)
if length > maxMessageCaptionLen {
return fmt.Errorf("%w: got %d, limit %d", ErrCaptionTooLong, length, maxMessageCaptionLen)
}
return nil
}
+3 -3
View File
@@ -1,12 +1,12 @@
module git.nix13.pw/scuroneko/laniakea
module git.scuroneko.dev/scuroneko/laniakea
go 1.26
retract v1.0.0-rc.5
require (
git.nix13.pw/scuroneko/extypes v1.2.2
git.nix13.pw/scuroneko/slog v1.1.2
git.scuroneko.dev/scuroneko/extypes v1.2.3
git.scuroneko.dev/scuroneko/slog v1.1.3
github.com/alitto/pond/v2 v2.7.0
golang.org/x/time v0.15.0
)
+4 -4
View File
@@ -1,7 +1,7 @@
git.nix13.pw/scuroneko/extypes v1.2.2 h1:N54c1ejrPs1yfIkvYuwqI7B1+8S9mDv2GqQA6sct4dk=
git.nix13.pw/scuroneko/extypes v1.2.2/go.mod h1:b4XYk1OW1dVSiE2MT/OMuX/K/UItf1swytX6eroVYnk=
git.nix13.pw/scuroneko/slog v1.1.2 h1:pl7tV5FN25Yso7sLYoOgBXi9+jLo5BDJHWmHlNPjpY0=
git.nix13.pw/scuroneko/slog v1.1.2/go.mod h1:UcfRIHDqpVQHahBGM93awLDK8//AsAvOqBwwbWqMkjM=
git.scuroneko.dev/scuroneko/extypes v1.2.3 h1:n7QsfTZEn9fJNZLXGH/LkNq4cADaRk+LTu6LNMv9y6s=
git.scuroneko.dev/scuroneko/extypes v1.2.3/go.mod h1:MhYpXC6sloLOpoM2guf64eSOrz+ET/QJZ8toobc3Ors=
git.scuroneko.dev/scuroneko/slog v1.1.3 h1:vI4GZykn8gDb6OJ2xq+KLcEk38M7O4e/z1kzpeRHEHw=
git.scuroneko.dev/scuroneko/slog v1.1.3/go.mod h1:gnDap54sfZv3EuSyZd7fjOH46aLbDFpvtN2wgFcWkgE=
github.com/alitto/pond/v2 v2.7.0 h1:c76L+yN916m/DRXjGCeUBHHu92uWnh/g1bwVk4zyyXg=
github.com/alitto/pond/v2 v2.7.0/go.mod h1:xkjYEgQ05RSpWdfSd1nM3OVv7TBhLdy7rMp3+2Nq+yE=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
+104 -253
View File
@@ -1,163 +1,108 @@
package laniakea
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"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.
var ErrInvalidPayloadType = errors.New("invalid payload type")
func (bot *Bot[T]) handle(u *tgapi.Update) {
func (bot *Bot[T]) handle(parentCtx context.Context, u *tgapi.Update) {
defer func() {
if r := recover(); r != nil {
bot.logger.Errorln(fmt.Sprintf("panic in handle: %v", r))
}
}()
startTime := time.Now()
ctx := &MsgContext{
ctx, cancel := context.WithCancel(parentCtx)
defer cancel()
msgCtx := &MsgContext{
Update: *u, Api: bot.api,
Logger: bot.logger,
errorTemplate: bot.errorTemplate,
l10n: bot.l10n,
draftProvider: bot.draftProvider,
sceneRuntime: bot,
observer: bot.observer,
payloadType: bot.payloadType,
ctx: ctx,
}
bot.prepareUpdateCtx(u, ctx)
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 {
if !middleware.Execute(ctx, bot.dbContext) {
if !middleware.Execute(msgCtx, bot.appData) {
return
}
}
switch u.Type {
case tgapi.UpdateTypeMessage, tgapi.UpdateTypeChannelPost:
bot.handleMessage(u, ctx)
case tgapi.UpdateTypeCallbackQuery:
bot.handleCallback(u, ctx)
default:
bot.handleUpdate(u, ctx)
}
}
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
}
text = strings.TrimSpace(text)
prefix, hasPrefix := bot.checkPrefixes(text)
if !hasPrefix {
return
}
ctx.Prefix = prefix
ctx.Update = *update
// Убираем префикс
text = strings.TrimSpace(text[len(prefix):])
// Извлекаем команду как первое слово
spaceIndex := strings.Index(text, " ")
var cmd string
var args string
if spaceIndex == -1 {
cmd = text
args = ""
} else {
cmd = text[:spaceIndex]
args = strings.TrimSpace(text[spaceIndex:])
}
if strings.Contains(cmd, "@") {
botUsername := bot.username
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)
sceneHandled, err := bot.tryHandleScene(msgCtx)
if err != nil {
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
}
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
}
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
}
handler(pluginCtx, bot.dbContext)
handled := false
switch u.Type {
case tgapi.UpdateTypeMessage, tgapi.UpdateTypeChannelPost:
handled = bot.handleMessage(u, msgCtx)
case tgapi.UpdateTypeCallbackQuery:
handled = bot.handleCallback(u, msgCtx)
default:
handled = bot.handleUpdate(u, msgCtx)
}
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 {
@@ -168,120 +113,6 @@ func cloneMsgContext(src *MsgContext) *MsgContext {
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
}
ctx.Update = *u
}
func (bot *Bot[T]) checkPrefixes(text string) (string, bool) {
for _, prefix := range bot.prefixes {
if prefix == "" {
continue
}
if strings.HasPrefix(text, prefix) {
return prefix, true
}
}
return "", false
}
func encodeJsonPayload(d CallbackData) (string, error) {
b, err := json.Marshal(d)
if err != nil {
@@ -289,11 +120,13 @@ func encodeJsonPayload(d CallbackData) (string, error) {
}
return string(b), nil
}
func decodeJsonPayload(s string) (CallbackData, error) {
var data CallbackData
err := json.Unmarshal([]byte(s), &data)
return data, err
}
func encodeBase64Payload(d CallbackData) (string, error) {
data, err := encodeJsonPayload(d)
if err != nil {
@@ -304,15 +137,6 @@ func encodeBase64Payload(d CallbackData) (string, error) {
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) {
b, err := base64.RawURLEncoding.DecodeString(s)
if err != nil {
@@ -320,19 +144,46 @@ func decodeBase64Payload(s string) (CallbackData, error) {
}
return decodeJsonPayload(string(b))
}
func decodePayload(payloadType BotPayloadType, s string) (CallbackData, error) {
func decodePayload(payloadType BotPayloadType, s string, strict bool) (CallbackData, BotPayloadType, error) {
switch payloadType {
case BotPayloadBase64:
return decodeBase64Payload(s)
data, err := decodeBase64Payload(s)
if err == nil {
return data, BotPayloadBase64, nil
}
if strict {
return CallbackData{}, "", fmt.Errorf("%w: expected %s", ErrPayloadTypeMismatch, BotPayloadBase64)
}
data, err = decodeJsonPayload(s)
if err != nil {
return CallbackData{}, "", err
}
return data, BotPayloadJson, nil
case BotPayloadJson:
return decodeJsonPayload(s)
data, err := decodeJsonPayload(s)
if err == nil {
return data, BotPayloadJson, nil
}
if strict {
return CallbackData{}, "", fmt.Errorf("%w: expected %s", ErrPayloadTypeMismatch, BotPayloadJson)
}
data, err = decodeBase64Payload(s)
if err != nil {
return CallbackData{}, "", err
}
return data, BotPayloadBase64, nil
}
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) {
return decodePayload(bot.payloadType, s)
data, decodedType, err := decodePayload(bot.payloadType, s, bot.strictPayloadType)
if err != nil {
return CallbackData{}, err
}
if decodedType == BotPayloadBase64 && bot.debug && bot.logger != nil {
bot.logger.Debugf("decoded callback payload base64->json: raw=%q json=%s", s, data.ToJson())
}
return data, nil
}
+858 -25
View File
@@ -1,14 +1,51 @@
package laniakea
import (
"context"
"errors"
"testing"
"git.nix13.pw/scuroneko/laniakea/tgapi"
"git.nix13.pw/scuroneko/slog"
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
"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) {
bot := &Bot[NoDB]{prefixes: []string{"", "/"}}
bot := &Bot[NoData]{prefixes: []string{"", "/"}}
if prefix, ok := bot.checkPrefixes("hello"); ok {
t.Fatalf("unexpected prefix match for plain text: %q", prefix)
@@ -22,10 +59,10 @@ func TestBotMiddlewareReceivesLogger(t *testing.T) {
logger := slog.CreateLogger()
called := false
bot := &Bot[NoDB]{
bot := &Bot[NoData]{
logger: logger,
middlewares: []Middleware[NoDB]{
NewMiddleware("logger-check", func(ctx *MsgContext, db NoDB) bool {
middlewares: []Middleware[NoData]{
NewMiddleware("logger-check", func(ctx *MsgContext, db NoData) bool {
called = true
if ctx.Logger != logger {
t.Fatalf("expected bot logger in middleware context, got %#v", ctx.Logger)
@@ -35,7 +72,7 @@ func TestBotMiddlewareReceivesLogger(t *testing.T) {
},
}
bot.handle(&tgapi.Update{
bot.handle(context.Background(), &tgapi.Update{
UpdateID: 1,
Type: tgapi.UpdateTypePoll,
Poll: &tgapi.Poll{
@@ -50,8 +87,8 @@ func TestBotMiddlewareReceivesLogger(t *testing.T) {
}
func TestAddUpdateHandlerRejectsReservedUpdateTypes(t *testing.T) {
plugin := NewPlugin[NoDB]("test")
handler := func(ctx *MsgContext, db NoDB) {}
plugin := NewPlugin[NoData]("test")
handler := func(ctx *MsgContext, db NoData) error { return nil }
for _, updateType := range []tgapi.UpdateType{
tgapi.UpdateTypeMessage,
@@ -73,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) {
tests := []struct {
name string
@@ -110,7 +448,7 @@ func TestHandleUpdateHandlersPopulateFromContext(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
called := false
plugin := NewPlugin[NoDB]("test").AddUpdateHandler(tt.update.Type, func(ctx *MsgContext, db NoDB) {
plugin := NewPlugin[NoData]("test").AddUpdateHandler(tt.update.Type, func(ctx *MsgContext, db NoData) error {
called = true
if ctx.Update.UpdateID != tt.update.UpdateID {
t.Fatalf("unexpected update in context: got %d want %d", ctx.Update.UpdateID, tt.update.UpdateID)
@@ -127,14 +465,15 @@ func TestHandleUpdateHandlersPopulateFromContext(t *testing.T) {
if ctx.Msg != nil {
t.Fatalf("did not expect message context for %s", tt.name)
}
return nil
})
bot := &Bot[NoDB]{
bot := &Bot[NoData]{
logger: slog.CreateLogger(),
plugins: []Plugin[NoDB]{clonePlugin(plugin)},
plugins: []Plugin[NoData]{clonePlugin(plugin)},
}
bot.handle(tt.update)
bot.handle(context.Background(), tt.update)
if !called {
t.Fatalf("expected update handler for %s to be called", tt.name)
@@ -147,7 +486,7 @@ func TestHandleUpdateHandlersReceiveIsolatedContexts(t *testing.T) {
firstCalled := false
secondCalled := false
first := NewPlugin[NoDB]("first").AddUpdateHandler(tgapi.UpdateTypeInlineQuery, func(ctx *MsgContext, db NoDB) {
first := NewPlugin[NoData]("first").AddUpdateHandler(tgapi.UpdateTypeInlineQuery, func(ctx *MsgContext, db NoData) error {
firstCalled = true
if ctx.FromID != 41 {
t.Fatalf("unexpected FromID in first handler: got %d want 41", ctx.FromID)
@@ -156,8 +495,9 @@ func TestHandleUpdateHandlersReceiveIsolatedContexts(t *testing.T) {
ctx.FromID = 999
ctx.Text = "mutated"
ctx.Args = []string{"mutated"}
return nil
})
second := NewPlugin[NoDB]("second").AddUpdateHandler(tgapi.UpdateTypeInlineQuery, func(ctx *MsgContext, db NoDB) {
second := NewPlugin[NoData]("second").AddUpdateHandler(tgapi.UpdateTypeInlineQuery, func(ctx *MsgContext, db NoData) error {
secondCalled = true
if ctx.From == nil {
t.Fatal("expected ctx.From to remain populated for second handler")
@@ -171,17 +511,18 @@ func TestHandleUpdateHandlersReceiveIsolatedContexts(t *testing.T) {
if len(ctx.Args) != 0 {
t.Fatalf("unexpected leaked Args in second handler: %v", ctx.Args)
}
return nil
})
bot := &Bot[NoDB]{
bot := &Bot[NoData]{
logger: slog.CreateLogger(),
plugins: []Plugin[NoDB]{
plugins: []Plugin[NoData]{
clonePlugin(first),
clonePlugin(second),
},
}
bot.handle(&tgapi.Update{
bot.handle(context.Background(), &tgapi.Update{
UpdateID: 3,
Type: tgapi.UpdateTypeInlineQuery,
InlineQuery: &tgapi.InlineQuery{
@@ -196,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) {
called := false
plugin := NewPlugin[NoDB]("test")
plugin.NewCommand(func(ctx *MsgContext, db NoDB) {
plugin := NewPlugin[NoData]("test")
plugin.NewCommand(func(ctx *MsgContext, db NoData) error {
called = true
if ctx.Msg == nil {
t.Fatal("expected message context")
@@ -213,22 +605,23 @@ func TestHandleChannelPostCommandWithSenderChat(t *testing.T) {
if ctx.FromID != 0 {
t.Fatalf("expected zero FromID for sender_chat updates, got %d", ctx.FromID)
}
return nil
}, "ping")
bot := &Bot[NoDB]{
bot := &Bot[NoData]{
logger: slog.CreateLogger(),
prefixes: []string{"/"},
plugins: []Plugin[NoDB]{clonePlugin(plugin)},
plugins: []Plugin[NoData]{clonePlugin(plugin)},
}
bot.handle(&tgapi.Update{
bot.handle(context.Background(), &tgapi.Update{
UpdateID: 10,
Type: tgapi.UpdateTypeChannelPost,
ChannelPost: &tgapi.Message{
MessageID: 55,
Text: "/ping",
SenderChat: &tgapi.Chat{ID: -1001, Type: string(tgapi.ChatTypeChannel)},
Chat: &tgapi.Chat{ID: -1001, Type: string(tgapi.ChatTypeChannel)},
SenderChat: &tgapi.Chat{ID: -1001, Type: tgapi.ChatTypeChannel},
Chat: &tgapi.Chat{ID: -1001, Type: tgapi.ChatTypeChannel},
},
})
@@ -236,3 +629,443 @@ func TestHandleChannelPostCommandWithSenderChat(t *testing.T) {
t.Fatal("expected channel post command handler to be called")
}
}
func TestCommandHandlerBindArgsEndToEnd(t *testing.T) {
type banInput struct {
UserID int
Reason string
}
var got banInput
plugin := NewPlugin[NoData]("test")
plugin.NewCommand(func(ctx *MsgContext, db NoData) error {
return ctx.BindArgs(&got)
}, "ban",
NewCommandArg("user_id").SetValueType(CommandValueIntType).SetRequired(),
NewCommandArg("reason").SetRequired(),
)
bot := &Bot[NoData]{
logger: slog.CreateLogger(),
prefixes: []string{"/"},
plugins: []Plugin[NoData]{clonePlugin(plugin)},
}
bot.handle(context.Background(), &tgapi.Update{
UpdateID: 11,
Type: tgapi.UpdateTypeMessage,
Message: &tgapi.Message{
MessageID: 1,
Text: "/ban 42 too loud",
Chat: &tgapi.Chat{ID: 99, Type: tgapi.ChatTypePrivate},
},
})
want := banInput{UserID: 42, Reason: "too loud"}
if got != want {
t.Fatalf("unexpected bound input: got %#v want %#v", got, want)
}
}
func TestPayloadHandlerBindArgsEndToEnd(t *testing.T) {
type payloadInput struct {
ID int
Note string
}
var got payloadInput
plugin := NewPlugin[NoData]("test")
plugin.NewPayload(func(ctx *MsgContext, db NoData) error {
return ctx.BindArgs(&got)
}, "approve",
NewCommandArg("id").SetValueType(CommandValueIntType).SetRequired(),
NewCommandArg("note").SetRequired(),
)
bot := &Bot[NoData]{
logger: slog.CreateLogger(),
payloadType: BotPayloadJson,
plugins: []Plugin[NoData]{clonePlugin(plugin)},
}
data, err := encodeJsonPayload(CallbackData{
Command: "approve",
Args: []string{"7", "looks", "good"},
})
if err != nil {
t.Fatalf("encodeJsonPayload returned error: %v", err)
}
bot.handle(context.Background(), &tgapi.Update{
UpdateID: 12,
Type: tgapi.UpdateTypeCallbackQuery,
CallbackQuery: &tgapi.CallbackQuery{
ID: "cb-1",
Data: data,
From: tgapi.User{ID: 1},
},
})
want := payloadInput{ID: 7, Note: "looks good"}
if got != want {
t.Fatalf("unexpected bound payload input: got %#v want %#v", got, want)
}
}
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")
}
}
+12 -4
View File
@@ -3,8 +3,8 @@ package laniakea
import (
"fmt"
"git.nix13.pw/scuroneko/extypes"
"git.nix13.pw/scuroneko/laniakea/tgapi"
"git.scuroneko.dev/scuroneko/extypes"
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
)
const (
@@ -136,14 +136,22 @@ func NewInlineKeyboard(payloadType BotPayloadType, maxRow int) *InlineKeyboard {
}
}
// SetPayloadType sets the serialization format for callback data added via
// SetPayloadType sets the keyboard-local serialization format for callback data added via
// AddCallbackButton and AddCallbackButtonStyle methods.
// It should be one of BotPayloadJson or BotPayloadBase64.
// It overrides the bot's default payload type for this keyboard only.
func (in *InlineKeyboard) SetPayloadType(t BotPayloadType) *InlineKeyboard {
in.payloadType = t
return in
}
// GetPayloadType returns the keyboard-local callback payload encoding type.
func (in *InlineKeyboard) GetPayloadType() BotPayloadType { return in.payloadType }
func (in *InlineKeyboard) SetMaxRow(maxRow int) *InlineKeyboard {
in.maxRow = maxRow
return in
}
// Internal helper that appends a button and auto-flushes a full row.
func (in *InlineKeyboard) append(button tgapi.InlineKeyboardButton) *InlineKeyboard {
if in.CurrentLine.Len() == in.maxRow {
+53
View File
@@ -1,6 +1,8 @@
package laniakea
import (
"errors"
"reflect"
"strings"
"testing"
)
@@ -42,3 +44,54 @@ func TestInlineKeyboardBuilderPreservesConfiguredButtonFields(t *testing.T) {
t.Fatalf("unexpected url: %q", button.URL)
}
}
func TestInlineKeyboardGetPayloadTypeReturnsLocalOverride(t *testing.T) {
kb := NewInlineKeyboardJson(2)
if got := kb.GetPayloadType(); got != BotPayloadJson {
t.Fatalf("unexpected initial payload type: %q", got)
}
kb.SetPayloadType(BotPayloadBase64)
if got := kb.GetPayloadType(); got != BotPayloadBase64 {
t.Fatalf("unexpected updated payload type: %q", got)
}
}
func TestDecodePayloadAcceptsBase64KeyboardPayloadWhenBotPrefersJSON(t *testing.T) {
kb := NewInlineKeyboardBase64(1).
AddCallbackButton("A", "cmd", 1, "two")
got, _, err := decodePayload(BotPayloadJson, kb.Get().InlineKeyboard[0][0].CallbackData, false)
if err != nil {
t.Fatalf("decodePayload returned error: %v", err)
}
want := CallbackData{Command: "cmd", Args: []string{"1", "two"}}
if !reflect.DeepEqual(got, want) {
t.Fatalf("unexpected payload: got %#v want %#v", got, want)
}
}
func TestDecodePayloadAcceptsJSONKeyboardPayloadWhenBotPrefersBase64(t *testing.T) {
kb := NewInlineKeyboardJson(1).
AddCallbackButton("A", "cmd", 1, "two")
got, _, err := decodePayload(BotPayloadBase64, kb.Get().InlineKeyboard[0][0].CallbackData, false)
if err != nil {
t.Fatalf("decodePayload returned error: %v", err)
}
want := CallbackData{Command: "cmd", Args: []string{"1", "two"}}
if !reflect.DeepEqual(got, want) {
t.Fatalf("unexpected payload: got %#v want %#v", got, want)
}
}
func TestDecodePayloadStrictRejectsMismatchedType(t *testing.T) {
kb := NewInlineKeyboardBase64(1).
AddCallbackButton("A", "cmd", 1)
_, _, err := decodePayload(BotPayloadJson, kb.Get().InlineKeyboard[0][0].CallbackData, true)
if !errors.Is(err, ErrPayloadTypeMismatch) {
t.Fatalf("expected ErrPayloadTypeMismatch, got %v", err)
}
}
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"context"
"encoding/json"
"git.nix13.pw/scuroneko/laniakea/tgapi"
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
)
// Updates fetches new updates from Telegram API using long polling.
+358 -32
View File
@@ -2,39 +2,87 @@ package laniakea
import (
"context"
"errors"
"fmt"
"log"
"reflect"
"strconv"
"strings"
"time"
"git.nix13.pw/scuroneko/laniakea/tgapi"
"git.nix13.pw/scuroneko/slog"
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
"git.scuroneko.dev/scuroneko/slog"
)
// MsgContext holds the context for handling a Telegram message or callback query.
// It provides methods to respond, edit, delete, and translate messages, as well as
// manage inline keyboards and message drafts.
// MsgContext holds the normalized per-update context passed to command, payload,
// scene, middleware, and generic update handlers.
//
// 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 {
Api *tgapi.API
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
// 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.
// It may fall back to the bot logger when the plugin has no dedicated logger.
Logger *slog.Logger
InlineMsgId string
CallbackMsgId int
// InlineMsgId is the inline message identifier for callback queries that target
// 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
FromID int64
Prefix string
Text string
Args []string
// FromID is the normalized sender ID when the current update exposes a user.
// It is zero when the update has no user identity.
FromID int64
// 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
l10n *L10n
draftProvider *DraftProvider
payloadType BotPayloadType
sceneRuntime sceneRuntime
observer Observer
ctx context.Context
}
// AnswerMessage represents a message sent or edited via MsgContext.
@@ -48,6 +96,10 @@ type AnswerMessage struct {
// Internal helper for text edits with optional keyboard and parse mode.
func (ctx *MsgContext) edit(messageId int, text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage {
if err := validateMessageText(text); err != nil {
ctx.Logger.Errorln(err)
return nil
}
params := tgapi.EditMessageTextP{
Text: text,
ParseMode: parseMode,
@@ -59,13 +111,13 @@ func (ctx *MsgContext) edit(messageId int, text string, keyboard *InlineKeyboard
case ctx.InlineMsgId != "":
params.InlineMessageID = ctx.InlineMsgId
default:
ctx.Logger.Errorln("Can't edit message: no valid message target")
ctx.Logger.Errorln(ErrEditTargetMissing)
return nil
}
if keyboard != nil {
params.ReplyMarkup = keyboard.Get()
}
msg, _, err := ctx.Api.EditMessageText(params)
msg, _, err := ctx.Api.EditMessageTextWithContext(ctx.Context(), params)
if err != nil {
ctx.Logger.Errorln(err)
return nil
@@ -96,7 +148,7 @@ func (m *AnswerMessage) EditMarkdown(text string) *AnswerMessage {
// Internal helper for editing callback-linked messages.
func (ctx *MsgContext) editCallback(text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage {
if ctx.CallbackMsgId == 0 && ctx.InlineMsgId == "" {
ctx.Logger.Errorln("Can't edit non-callback update message")
ctx.Logger.Errorln(ErrCallbackMessageMissing)
return nil
}
return ctx.edit(ctx.CallbackMsgId, text, keyboard, parseMode)
@@ -128,6 +180,10 @@ func (ctx *MsgContext) EditCallbackfMarkdown(format string, keyboard *InlineKeyb
// Internal helper for media-caption edits.
func (ctx *MsgContext) editPhotoText(messageId int, text string, kb *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage {
if err := validateCaptionText(text); err != nil {
ctx.Logger.Errorln(err)
return nil
}
params := tgapi.EditMessageCaptionP{
Caption: text,
ParseMode: parseMode,
@@ -139,14 +195,14 @@ func (ctx *MsgContext) editPhotoText(messageId int, text string, kb *InlineKeybo
case ctx.InlineMsgId != "":
params.InlineMessageID = ctx.InlineMsgId
default:
ctx.Logger.Errorln("Can't edit caption: no valid message target")
ctx.Logger.Errorln(ErrEditTargetMissing)
return nil
}
if kb != nil {
params.ReplyMarkup = kb.Get()
}
msg, _, err := ctx.Api.EditMessageCaption(params)
msg, _, err := ctx.Api.EditMessageCaptionWithContext(ctx.Context(), params)
if err != nil {
ctx.Logger.Errorln(err)
return nil
@@ -187,7 +243,11 @@ func (m *AnswerMessage) EditCaptionKeyboardMarkdown(text string, kb *InlineKeybo
// Internal helper for message replies with optional keyboard and parse mode.
func (ctx *MsgContext) answer(text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage {
if ctx.Msg == nil {
ctx.Logger.Errorln("Can't answer message without a message")
ctx.Logger.Errorln(ErrMessageContextNil)
return nil
}
if err := validateMessageText(text); err != nil {
ctx.Logger.Errorln(err)
return nil
}
params := tgapi.SendMessageP{
@@ -205,7 +265,7 @@ func (ctx *MsgContext) answer(text string, keyboard *InlineKeyboard, parseMode t
params.DirectMessagesTopicID = ctx.Msg.DirectMessageTopic.TopicID
}
msg, err := ctx.Api.SendMessage(params)
msg, err := ctx.Api.SendMessageWithContext(ctx.Context(), params)
if err != nil {
ctx.Logger.Errorln(err)
return nil
@@ -220,6 +280,14 @@ func (ctx *MsgContext) Answer(text string) *AnswerMessage {
return ctx.answer(text, nil, tgapi.ParseNone)
}
// AnswerLong sends one or more plain-text messages if text exceeds Telegram's limit.
//
// The text is split into Telegram-safe chunks. Returned messages preserve send
// order. If a chunk fails to send, already-sent messages are returned.
func (ctx *MsgContext) AnswerLong(text string) []*AnswerMessage {
return ctx.answerLong(text, nil, tgapi.ParseNone)
}
// AnswerMarkdown sends a message using MarkdownV2 formatting.
//
// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here.
@@ -232,6 +300,11 @@ func (ctx *MsgContext) Answerf(template string, args ...any) *AnswerMessage {
return ctx.answer(fmt.Sprintf(template, args...), nil, tgapi.ParseNone)
}
// AnswerLongf formats a string using fmt.Sprintf and sends it as one or more plain-text messages.
func (ctx *MsgContext) AnswerLongf(template string, args ...any) []*AnswerMessage {
return ctx.answerLong(fmt.Sprintf(template, args...), nil, tgapi.ParseNone)
}
// AnswerfMarkdown formats a string using fmt.Sprintf and sends it using MarkdownV2.
//
// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here.
@@ -244,6 +317,13 @@ func (ctx *MsgContext) Keyboard(text string, kb *InlineKeyboard) *AnswerMessage
return ctx.answer(text, kb, tgapi.ParseNone)
}
// KeyboardLong sends long plain text split across multiple messages.
//
// The inline keyboard is attached only to the final chunk.
func (ctx *MsgContext) KeyboardLong(text string, kb *InlineKeyboard) []*AnswerMessage {
return ctx.answerLong(text, kb, tgapi.ParseNone)
}
// KeyboardMarkdown sends a message with an inline keyboard using MarkdownV2.
//
// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here.
@@ -251,10 +331,53 @@ func (ctx *MsgContext) KeyboardMarkdown(text string, keyboard *InlineKeyboard) *
return ctx.answer(text, keyboard, tgapi.ParseMDV2)
}
func (ctx *MsgContext) answerLong(text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) []*AnswerMessage {
if parseMode != tgapi.ParseNone {
ctx.Logger.Errorln(ErrMessageSplitImpossible)
return nil
}
if ctx.Msg == nil {
ctx.Logger.Errorln(ErrMessageContextNil)
return nil
}
if err := validateMessageText(text); err == nil {
msg := ctx.answer(text, keyboard, parseMode)
if msg == nil {
return nil
}
return []*AnswerMessage{msg}
} else if !errors.Is(err, ErrMessageTooLong) {
ctx.Logger.Errorln(err)
return nil
}
parts := SplitMessageText(text)
messages := make([]*AnswerMessage, 0, len(parts))
for i, part := range parts {
partKeyboard := (*InlineKeyboard)(nil)
if i == len(parts)-1 {
partKeyboard = keyboard
}
msg := ctx.answer(part, partKeyboard, parseMode)
if msg == nil {
break
}
messages = append(messages, msg)
}
if len(messages) == 0 {
return nil
}
return messages
}
// Internal helper for photo replies with optional caption and keyboard.
func (ctx *MsgContext) answerPhoto(photoId, text string, kb *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage {
if ctx.Msg == nil {
ctx.Logger.Errorln("Can't answer message without a message")
ctx.Logger.Errorln(ErrMessageContextNil)
return nil
}
if err := validateCaptionText(text); err != nil {
ctx.Logger.Errorln(err)
return nil
}
params := tgapi.SendPhotoP{
@@ -273,7 +396,7 @@ func (ctx *MsgContext) answerPhoto(photoId, text string, kb *InlineKeyboard, par
params.DirectMessagesTopicID = int(ctx.Msg.DirectMessageTopic.TopicID)
}
msg, err := ctx.Api.SendPhoto(params)
msg, err := ctx.Api.SendPhotoWithContext(ctx.Context(), params)
if err != nil {
ctx.Logger.Errorln(err)
return nil
@@ -322,14 +445,14 @@ func (ctx *MsgContext) AnswerPhotofMarkdown(photoId, template string, args ...an
// Internal helper that deletes a message by ID.
func (ctx *MsgContext) delete(messageId int) {
if messageId == 0 {
ctx.Logger.Errorln("Can't delete message: message ID zero")
ctx.Logger.Errorln(ErrMessageIDZero)
return
}
if ctx.Msg == nil {
ctx.Logger.Errorln("Can't delete message: no chat message context")
ctx.Logger.Errorln(ErrMessageContextNil)
return
}
_, err := ctx.Api.DeleteMessage(tgapi.DeleteMessageP{
_, err := ctx.Api.DeleteMessageWithContext(ctx.Context(), tgapi.DeleteMessageP{
ChatID: ctx.Msg.Chat.ID,
MessageID: messageId,
})
@@ -344,7 +467,7 @@ func (m *AnswerMessage) Delete() { m.ctx.delete(m.MessageID) }
// CallbackDelete deletes the message that triggered the callback query.
func (ctx *MsgContext) CallbackDelete() {
if ctx.CallbackMsgId == 0 {
ctx.Logger.Errorln("Can't delete callback message: no callback message ID")
ctx.Logger.Errorln(ErrCallbackMessageMissing)
return
}
ctx.delete(ctx.CallbackMsgId)
@@ -355,7 +478,7 @@ func (ctx *MsgContext) answerCallbackQuery(url, text string, showAlert bool) {
if len(ctx.CallbackQueryId) == 0 {
return
}
_, err := ctx.Api.AnswerCallbackQuery(tgapi.AnswerCallbackQueryP{
_, err := ctx.Api.AnswerCallbackQueryWithContext(ctx.Context(), tgapi.AnswerCallbackQueryP{
CallbackQueryID: ctx.CallbackQueryId,
Text: text, ShowAlert: showAlert, URL: url,
})
@@ -388,7 +511,7 @@ func (ctx *MsgContext) SendAction(action tgapi.ChatActionType) {
if ctx.Msg.MessageThreadID > 0 {
params.MessageThreadID = ctx.Msg.MessageThreadID
}
_, err := ctx.Api.SendChatAction(params)
_, err := ctx.Api.SendChatActionWithContext(ctx.Context(), params)
if err != nil {
ctx.Logger.Errorln(err)
}
@@ -396,6 +519,13 @@ func (ctx *MsgContext) SendAction(action tgapi.ChatActionType) {
// Internal helper that formats, sends, and logs an 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())
if ctx.CallbackQueryId != "" {
@@ -403,7 +533,6 @@ func (ctx *MsgContext) error(err error) {
} else {
ctx.answer(text, nil, tgapi.ParseNone)
}
ctx.Logger.Errorln(err)
}
// Error is an alias for error().
@@ -411,20 +540,20 @@ func (ctx *MsgContext) Error(err error) { ctx.error(err) }
func (ctx *MsgContext) newDraft(parseMode tgapi.ParseMode) *Draft {
if ctx.Msg == nil {
ctx.Logger.Errorln("can't create draft: ctx.Msg is nil")
ctx.Logger.Errorln(ErrMessageContextNil)
return nil
}
if ctx.Api == nil {
ctx.Logger.Errorln("can't create draft: ctx.Api is nil")
ctx.Logger.Errorln(ErrAPIIsNil)
return nil
}
if ctx.draftProvider == nil {
ctx.Logger.Errorln("can't create draft: ctx.draftProvider is nil")
ctx.Logger.Errorln(ErrDraftProviderNil)
return nil
}
if ctx.Api.Limiter != nil {
c, cancel := context.WithTimeout(context.Background(), 5*time.Second)
c, cancel := context.WithTimeout(ctx.Context(), 5*time.Second)
defer cancel()
if err := ctx.Api.Limiter.Wait(c, ctx.Msg.Chat.ID); err != nil {
ctx.Logger.Errorln(err)
@@ -464,3 +593,200 @@ func (ctx *MsgContext) Translate(key string) string {
func (ctx *MsgContext) NewInlineKeyboard(maxRow int) *InlineKeyboard {
return NewInlineKeyboard(ctx.payloadType, maxRow)
}
func bindPositional(args []string, dst any) error {
v := reflect.ValueOf(dst)
if v.Kind() != reflect.Pointer || v.IsNil() {
return ErrBindArgsTargetNotPointer
}
v = v.Elem()
if v.Kind() != reflect.Struct {
return ErrBindArgsTargetNotStruct
}
t := v.Type()
fields := make([]int, 0, v.NumField())
for i := 0; i < v.NumField(); i++ {
field := v.Field(i)
if !field.CanSet() {
continue
}
fields = append(fields, i)
}
argIndex := 0
for fieldPos, fieldIndex := range fields {
field := v.Field(fieldIndex)
fieldType := t.Field(fieldIndex)
if argIndex >= len(args) {
// Leave trailing fields at their zero values when arguments run out.
break
}
isLastBindableField := fieldPos == len(fields)-1
raw := args[argIndex]
if isLastBindableField && field.Kind() == reflect.String {
raw = strings.Join(args[argIndex:], " ")
}
switch field.Kind() {
case reflect.String:
field.SetString(raw)
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
n, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return fmt.Errorf("%w: field %s: %v", ErrBindArgsConversion, fieldType.Name, err)
}
field.SetInt(n)
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
n, err := strconv.ParseUint(raw, 10, 64)
if err != nil {
return fmt.Errorf("%w: field %s: %v", ErrBindArgsConversion, fieldType.Name, err)
}
field.SetUint(n)
case reflect.Float32, reflect.Float64:
f, err := strconv.ParseFloat(raw, 64)
if err != nil {
return fmt.Errorf("%w: field %s: %v", ErrBindArgsConversion, fieldType.Name, err)
}
field.SetFloat(f)
case reflect.Bool:
b, err := strconv.ParseBool(raw)
if err != nil {
return fmt.Errorf("%w: field %s: %v", ErrBindArgsConversion, fieldType.Name, err)
}
field.SetBool(b)
default:
return fmt.Errorf("%w: field %s: %s", ErrBindArgsUnsupportedFieldType, fieldType.Name, field.Kind())
}
if isLastBindableField && field.Kind() == reflect.String {
break
}
argIndex++
}
return nil
}
// BindArgs binds positional command arguments from ctx.Args into dst.
//
// Exported struct fields are filled in declaration order. When fewer arguments
// are provided than fields, the remaining fields keep their zero values. If the
// final bindable field is a string, it receives the remaining arguments joined
// with spaces.
func (ctx *MsgContext) BindArgs(dst any) error {
return bindPositional(ctx.Args, dst)
}
// Context returns the request-scoped context associated with the current update.
func (ctx *MsgContext) Context() context.Context {
if ctx.ctx == nil {
return context.Background()
}
return ctx.ctx
}
func (ctx *MsgContext) 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 {
if ctx.sceneRuntime == nil {
return ErrSceneRuntimeNil
}
scene, ok := ctx.sceneRuntime.findScene(name)
if !ok {
return ErrSceneNotFound
}
key, ok := ctx.sceneRuntime.buildSceneKey(scene.Scope, ctx)
if !ok {
return ErrCantFindSession
}
if scene.Entry == "" {
return ErrSceneEntryNotSet
}
if _, ok := scene.Steps[scene.Entry]; !ok {
return ErrSceneStepNotFound
}
session := SceneSession{
Scene: scene.Name,
Step: scene.Entry,
}
return ctx.sceneRuntime.setSession(key, session)
}
// EnterSceneStep enters the named scene at a specific step.
func (ctx *MsgContext) EnterSceneStep(name, step string) error {
if ctx.sceneRuntime == nil {
return ErrSceneRuntimeNil
}
scene, ok := ctx.sceneRuntime.findScene(name)
if !ok {
return ErrSceneNotFound
}
if _, ok := scene.Steps[step]; !ok {
return ErrSceneStepNotFound
}
key, ok := ctx.sceneRuntime.buildSceneKey(scene.Scope, ctx)
if !ok {
return ErrCantFindSession
}
session := SceneSession{
Scene: scene.Name,
Step: step,
}
return ctx.sceneRuntime.setSession(key, session)
}
// ExitScene leaves the currently active scene for this context.
func (ctx *MsgContext) ExitScene() error {
if ctx.sceneRuntime == nil {
return ErrSceneRuntimeNil
}
_, session, err := ctx.sceneRuntime.findSceneSession(ctx)
if err != nil {
return err
}
if session.Scene == "" {
return ErrNotInScene
}
scene, ok := ctx.sceneRuntime.findScene(session.Scene)
if !ok {
return ErrSceneNotFound
}
key, ok := ctx.sceneRuntime.buildSceneKey(scene.Scope, ctx)
if !ok {
return ErrCantFindSession
}
return ctx.sceneRuntime.deleteSession(key)
}
+407 -3
View File
@@ -2,13 +2,15 @@ package laniakea
import (
"encoding/json"
"errors"
"io"
"net/http"
"reflect"
"strings"
"testing"
"git.nix13.pw/scuroneko/laniakea/tgapi"
"git.nix13.pw/scuroneko/slog"
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
"git.scuroneko.dev/scuroneko/slog"
)
func TestAnswerPhotoIncludesDirectMessagesTopicID(t *testing.T) {
@@ -45,7 +47,7 @@ func TestAnswerPhotoIncludesDirectMessagesTopicID(t *testing.T) {
ctx := &MsgContext{
Api: api,
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},
},
Logger: slog.CreateLogger(),
@@ -62,3 +64,405 @@ func TestAnswerPhotoIncludesDirectMessagesTopicID(t *testing.T) {
t.Fatalf("unexpected direct_messages_topic_id: %v", got)
}
}
func TestBindArgsBindsScalarFields(t *testing.T) {
type input struct {
ID int
Active bool
Score float64
Name string
}
ctx := &MsgContext{Args: []string{"42", "true", "3.5", "Ada", "Lovelace"}}
var got input
if err := ctx.BindArgs(&got); err != nil {
t.Fatalf("BindArgs returned error: %v", err)
}
want := input{
ID: 42,
Active: true,
Score: 3.5,
Name: "Ada Lovelace",
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("unexpected bound value: got %#v want %#v", got, want)
}
}
func TestBindArgsLeavesTrailingFieldsZeroWhenArgsRunOut(t *testing.T) {
type input struct {
ID int
Reason string
Admin bool
}
ctx := &MsgContext{Args: []string{"7"}}
var got input
if err := ctx.BindArgs(&got); err != nil {
t.Fatalf("BindArgs returned error: %v", err)
}
if got.ID != 7 {
t.Fatalf("unexpected ID: got %d want 7", got.ID)
}
if got.Reason != "" {
t.Fatalf("expected zero-value Reason, got %q", got.Reason)
}
if got.Admin {
t.Fatal("expected zero-value Admin")
}
}
func TestBindArgsRejectsInvalidTargets(t *testing.T) {
ctx := &MsgContext{Args: []string{"1"}}
if err := ctx.BindArgs(nil); !errors.Is(err, ErrBindArgsTargetNotPointer) {
t.Fatalf("expected ErrBindArgsTargetNotPointer for nil target, got %v", err)
}
var notStruct int
if err := ctx.BindArgs(&notStruct); !errors.Is(err, ErrBindArgsTargetNotStruct) {
t.Fatalf("expected ErrBindArgsTargetNotStruct for non-struct target, got %v", err)
}
}
func TestBindArgsReportsConversionFailures(t *testing.T) {
type input struct {
ID int
}
ctx := &MsgContext{Args: []string{"oops"}}
var got input
err := ctx.BindArgs(&got)
if err == nil {
t.Fatal("expected BindArgs to fail")
}
if !errors.Is(err, ErrBindArgsConversion) {
t.Fatalf("expected ErrBindArgsConversion, got %v", err)
}
if !strings.Contains(err.Error(), "field ID") {
t.Fatalf("expected field name in error, got %v", err)
}
}
func TestBindArgsRejectsUnsupportedFieldTypes(t *testing.T) {
type input struct {
Tags []string
}
ctx := &MsgContext{Args: []string{"tag"}}
var got input
err := ctx.BindArgs(&got)
if err == nil {
t.Fatal("expected BindArgs to fail")
}
if !errors.Is(err, ErrBindArgsUnsupportedFieldType) {
t.Fatalf("expected ErrBindArgsUnsupportedFieldType, got %v", err)
}
}
func 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) {
ctx := &MsgContext{
Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}},
Logger: slog.CreateLogger(),
}
if answer := ctx.Answer(""); answer != nil {
t.Fatal("expected nil answer for empty message")
}
}
func TestAnswerRejectsLongMessageWithoutSendingRequest(t *testing.T) {
client := &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
t.Fatal("unexpected HTTP request")
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(),
}
if answer := ctx.Answer(strings.Repeat("a", maxMessageTextLen+1)); answer != nil {
t.Fatal("expected nil answer for long message")
}
}
func TestValidateMessageText(t *testing.T) {
if err := validateMessageText(""); !errors.Is(err, ErrEmptyMessage) {
t.Fatalf("expected ErrEmptyMessage, got %v", err)
}
if err := validateMessageText(strings.Repeat("a", maxMessageTextLen+1)); !errors.Is(err, ErrMessageTooLong) {
t.Fatalf("expected ErrMessageTooLong, got %v", err)
}
if err := validateMessageText("ok"); err != nil {
t.Fatalf("expected nil error, got %v", err)
}
}
func TestValidateCaptionText(t *testing.T) {
if err := validateCaptionText(strings.Repeat("a", maxMessageCaptionLen+1)); !errors.Is(err, ErrCaptionTooLong) {
t.Fatalf("expected ErrCaptionTooLong, got %v", err)
}
if err := validateCaptionText(""); err != nil {
t.Fatalf("expected nil error, got %v", err)
}
}
func TestSplitMessageTextPreservesContent(t *testing.T) {
text := "alpha beta\n" + strings.Repeat("x", maxMessageTextLen) + " omega"
parts := SplitMessageText(text)
if len(parts) < 2 {
t.Fatalf("expected multiple parts, got %d", len(parts))
}
for i, part := range parts {
if got := len([]rune(part)); got > maxMessageTextLen {
t.Fatalf("part %d exceeded limit: %d", i, got)
}
}
if got := strings.Join(parts, ""); got != text {
t.Fatalf("split/join mismatch: got %q want %q", got, text)
}
}
func TestAnswerLongSplitsRequestsAndAttachesKeyboardToLastChunk(t *testing.T) {
var requests []map[string]any
client := &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
body, err := io.ReadAll(req.Body)
if err != nil {
t.Fatalf("failed to read request body: %v", err)
}
var got map[string]any
if err := json.Unmarshal(body, &got); err != nil {
t.Fatalf("failed to decode request body: %v", err)
}
requests = append(requests, got)
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(),
}
kb := NewInlineKeyboardJson(1).AddCallbackButton("A", "cmd")
text := strings.Repeat("a", maxMessageTextLen) + " " + strings.Repeat("b", 32)
messages := ctx.KeyboardLong(text, kb)
if got := len(messages); got != 2 {
t.Fatalf("expected 2 sent messages, got %d", got)
}
if got := len(requests); got != 2 {
t.Fatalf("expected 2 requests, got %d", got)
}
if _, ok := requests[0]["reply_markup"]; ok {
t.Fatal("did not expect keyboard on first chunk")
}
if _, ok := requests[1]["reply_markup"]; !ok {
t.Fatal("expected keyboard on final chunk")
}
gotTexts := []string{requests[0]["text"].(string), requests[1]["text"].(string)}
wantTexts := SplitMessageText(text)
if !reflect.DeepEqual(gotTexts, wantTexts) {
t.Fatalf("unexpected chunk texts: got %q want %q", gotTexts, wantTexts)
}
}
+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)
}
}
+55 -26
View File
@@ -4,10 +4,10 @@ import (
"errors"
"regexp"
"git.nix13.pw/scuroneko/extypes"
"git.nix13.pw/scuroneko/laniakea/tgapi"
"git.nix13.pw/scuroneko/laniakea/utils"
"git.nix13.pw/scuroneko/slog"
"git.scuroneko.dev/scuroneko/extypes"
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
"git.scuroneko.dev/scuroneko/laniakea/utils"
"git.scuroneko.dev/scuroneko/slog"
)
// CommandValueType defines the expected type of command argument.
@@ -40,6 +40,11 @@ var ErrCmdArgCountMismatch = errors.New("command arg count mismatch")
// ErrCmdArgRegexpMismatch is returned when an argument fails regex validation.
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,
// and whether it is required.
type CommandArg struct {
@@ -80,12 +85,13 @@ func (c CommandArg) SetRequired() CommandArg {
}
// CommandExecutor is the function type that executes a command.
// It receives the message context and a database context (generic).
type CommandExecutor[T DbContext] func(ctx *MsgContext, dbContext T)
// It receives the message context and injected application data.
// Returning a non-nil error routes it through the bot's error handler.
type CommandExecutor[T AppData] func(ctx *MsgContext, dbContext T) error
// Command represents a bot command with arguments, description, and executor.
// 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")
description string // Human-readable description for help
exec CommandExecutor[T] // Function to execute when command is triggered
@@ -156,10 +162,11 @@ func (c *Command[T]) validateArgs(args []string) error {
// 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.
// 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")
commands map[string]*Command[T] // Registered commands (triggered by message)
payloads map[string]*Command[T] // Registered payloads (triggered by callback data)
scenes map[string]*Scene[T] // Optional scenes for multi-step interactions
middlewares extypes.Slice[Middleware[T]] // Shared middlewares for all commands/payloads
skipAutoCmd bool // If true, all commands in this plugin are excluded from auto-help
logger *slog.Logger
@@ -170,12 +177,13 @@ type Plugin[T DbContext] struct {
}
// 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]{
name: name,
commands: make(map[string]*Command[T]),
payloads: make(map[string]*Command[T]),
middlewares: make(extypes.Slice[Middleware[T]], 0),
scenes: make(map[string]*Scene[T]),
skipAutoCmd: false,
logger: nil,
handlers: make(map[tgapi.UpdateType]CommandExecutor[T]),
@@ -212,6 +220,31 @@ func (p *Plugin[T]) NewPayload(exec CommandExecutor[T], command string, args ...
return cmd
}
// AddScene registers a multi-step scene in the plugin.
func (p *Plugin[T]) AddScene(scene *Scene[T]) *Plugin[T] {
if scene == nil {
return p
}
scene.PluginName = p.name
scene.setPluginName(p.name)
p.scenes[scene.Name] = scene
return p
}
// 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] {
scene := NewScene[T](name)
scene.setPluginName(p.name)
p.AddScene(scene)
return scene
}
// AddUpdateHandler registers a handler for a non-command update type.
// Message, channel post, and callback query updates stay on the command/payload flow.
func (p *Plugin[T]) AddUpdateHandler(t tgapi.UpdateType, handler CommandExecutor[T]) *Plugin[T] {
@@ -289,51 +322,47 @@ func (p *Plugin[T]) Close() error {
}
// 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]
if !exists {
ctx.error(errors.New("command not found"))
return
return AsInternalError(errCommandNotFound)
}
if err := command.validateArgs(ctx.Args); err != nil {
ctx.error(err)
return
return AsUserError(err)
}
// Run command-specific middlewares
for _, m := range command.middlewares {
if !m.Execute(ctx, db) {
return
return AsInternalError(errors.New("middleware blocked call"))
}
}
// Execute command
command.exec(ctx, db)
return command.exec(ctx, db)
}
// 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]
if !exists {
ctx.error(errors.New("payload not found"))
return
return AsInternalError(errPayloadNotFound)
}
if err := command.validateArgs(ctx.Args); err != nil {
ctx.error(err)
return
return AsUserError(err)
}
// Run command-specific middlewares
for _, m := range command.middlewares {
if !m.Execute(ctx, db) {
return
return AsInternalError(errors.New("middleware blocked call"))
}
}
// Execute payload
command.exec(ctx, db)
return command.exec(ctx, db)
}
// Internal helper that runs plugin middlewares in order.
@@ -349,11 +378,11 @@ func (p *Plugin[T]) executeMiddlewares(ctx *MsgContext, db T) bool {
// MiddlewareExecutor is the function type for middleware logic.
// Returns true to continue execution, false to block it.
// 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.
// 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
executor MiddlewareExecutor[T] // Function to execute
order int // Optional sort order (not used yet)
@@ -361,7 +390,7 @@ type Middleware[T DbContext] struct {
}
// 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}
}
+4 -4
View File
@@ -6,7 +6,7 @@ import (
)
func TestValidateArgsRequiresFullMatch(t *testing.T) {
intCmd := NewCommand[NoDB](func(ctx *MsgContext, db NoDB) {}, "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 {
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)
}
boolCmd := NewCommand[NoDB](func(ctx *MsgContext, db NoDB) {}, "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 {
t.Fatalf("expected valid bool argument, got %v", err)
}
@@ -24,8 +24,8 @@ func TestValidateArgsRequiresFullMatch(t *testing.T) {
}
func TestValidateArgsEnforcesRequiredArgIndex(t *testing.T) {
cmd := NewCommand[NoDB](
func(ctx *MsgContext, db NoDB) {},
cmd := NewCommand[NoData](
func(ctx *MsgContext, db NoData) error { return nil },
"mixed",
NewCommandArg("optional"),
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
// 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
// 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=false, async=true: Run repeatedly in a goroutine with timeout.
// - 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
onetime bool // If true, runs once; if false, runs periodically
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.
// 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]{
name: name,
fn: fn,
@@ -107,8 +107,21 @@ func (bot *Bot[T]) ExecRunners(ctx context.Context) {
bot.runnerOnceWG.Add(1)
go func(r Runner[T]) {
defer bot.runnerOnceWG.Done()
startedAt := time.Now()
err := r.fn(bot)
bot.safeEmitEvent(ctx, RunnerFinishedEvent{
Name: r.name,
Duration: time.Since(startedAt),
Err: err,
})
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)
}
}(runner)
@@ -116,10 +129,22 @@ func (bot *Bot[T]) ExecRunners(ctx context.Context) {
// One-time sync: block until done
t := time.Now()
err := runner.fn(bot)
elapsed := time.Since(t)
bot.safeEmitEvent(ctx, RunnerFinishedEvent{
Name: runner.name,
Duration: elapsed,
Err: err,
})
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)
}
elapsed := time.Since(t)
if elapsed > time.Second*2 {
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():
return
case <-ticker.C:
startedAt := time.Now()
err := r.fn(bot)
bot.safeEmitEvent(ctx, RunnerFinishedEvent{
Name: r.name,
Duration: time.Since(startedAt),
Err: err,
})
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)
}
}
+42 -7
View File
@@ -2,19 +2,24 @@ package laniakea
import (
"context"
"errors"
"sync/atomic"
"testing"
"time"
"git.nix13.pw/scuroneko/slog"
"git.scuroneko.dev/scuroneko/slog"
)
type runnerObserver struct {
recordingObserver
}
func TestExecRunnersRunsOnetimeSyncRunner(t *testing.T) {
var calls atomic.Int32
bot := &Bot[NoDB]{
bot := &Bot[NoData]{
logger: slog.CreateLogger(),
runners: []Runner[NoDB]{
NewRunner("sync-once", func(*Bot[NoDB]) error {
runners: []Runner[NoData]{
NewRunner("sync-once", func(*Bot[NoData]) error {
calls.Add(1)
return nil
}).Onetime(true).Async(false),
@@ -33,10 +38,10 @@ func TestExecRunnersStopsBackgroundRunnerOnCancel(t *testing.T) {
triggered := make(chan struct{}, 1)
ctx, cancel := context.WithCancel(context.Background())
bot := &Bot[NoDB]{
bot := &Bot[NoData]{
logger: slog.CreateLogger(),
runners: []Runner[NoDB]{
NewRunner("background", func(*Bot[NoDB]) error {
runners: []Runner[NoData]{
NewRunner("background", func(*Bot[NoData]) error {
if calls.Add(1) == 1 {
triggered <- struct{}{}
}
@@ -60,3 +65,33 @@ func TestExecRunnersStopsBackgroundRunnerOnCancel(t *testing.T) {
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)
}
}
+238
View File
@@ -0,0 +1,238 @@
package laniakea
import (
"encoding/json"
"sync"
)
// SceneHandler handles a scene step, scene command, or fallback message.
type SceneHandler[T any] func(ctx *SceneContext, db T) (SceneResult, error)
// Scene defines a multi-step conversational flow.
type Scene[T any] struct {
// Name identifies the scene in plugin registration and session state.
Name string
// Scope controls how active scene sessions are keyed and shared.
Scope SceneScope
// Entry names the first step used by MsgContext.EnterScene.
Entry string
// PluginName stores the owning plugin name for scene resolution.
PluginName string
steps map[string]SceneHandler[T]
commands map[string]SceneHandler[T]
message SceneHandler[T]
}
// NewScene creates a new scene with user-chat scope by default.
func NewScene[T any](name string) *Scene[T] {
return &Scene[T]{
Name: name,
Scope: SceneScopeUserChat,
Entry: "",
steps: make(map[string]SceneHandler[T]),
commands: make(map[string]SceneHandler[T]),
message: nil,
}
}
// SetScope changes how scene sessions are keyed and shared.
func (s *Scene[T]) SetScope(scope SceneScope) *Scene[T] {
s.Scope = scope
return s
}
// SetEntry sets the initial step entered by MsgContext.EnterScene.
func (s *Scene[T]) SetEntry(step string) *Scene[T] {
s.Entry = step
return s
}
func (s *Scene[T]) setPluginName(name string) *Scene[T] {
s.PluginName = name
return s
}
// OnStep registers a handler for a named scene step.
func (s *Scene[T]) OnStep(step string, handler SceneHandler[T]) *Scene[T] {
s.steps[step] = handler
return s
}
// OnCommand registers a command handler active while the scene is running.
func (s *Scene[T]) OnCommand(cmd string, handler SceneHandler[T]) *Scene[T] {
s.commands[cmd] = handler
return s
}
// OnMessage registers a fallback handler used when no scene command or step matches.
func (s *Scene[T]) OnMessage(handler SceneHandler[T]) *Scene[T] {
s.message = handler
return s
}
func (s *Scene[T]) executeCommand(cmd string, ctx *SceneContext, db T) (SceneResult, bool, error) {
handler, ok := s.commands[cmd]
if !ok {
return SceneResult{}, false, nil
}
result, err := handler(ctx, db)
return result, true, err
}
func (s *Scene[T]) executeStep(step string, ctx *SceneContext, db T) (SceneResult, bool, error) {
handler, ok := s.steps[step]
if !ok {
return SceneResult{}, false, nil
}
result, err := handler(ctx, db)
return result, true, err
}
func (s *Scene[T]) executeMessage(ctx *SceneContext, db T) (SceneResult, bool, error) {
if s.message == nil {
return SceneResult{}, false, nil
}
result, err := s.message(ctx, db)
return result, true, err
}
// SceneSession stores the active scene state for one session key.
type SceneSession struct {
// Scene is the registered scene name for the active session.
Scene string
// Step is the current step name inside the active scene.
Step string
// Data stores opaque session payload bytes, typically JSON.
Data []byte
}
// SetData stores arbitrary opaque session data.
func (s *SceneSession) SetData(data []byte) {
s.Data = data
}
// GetData returns the raw session data payload.
func (s *SceneSession) GetData() []byte {
return s.Data
}
// HasData reports whether the session has a non-empty data payload.
func (s *SceneSession) HasData() bool {
return len(s.Data) > 0
}
// ClearData removes any stored session data.
func (s *SceneSession) ClearData() {
s.Data = nil
}
// BindData unmarshals the stored JSON payload into v.
func (s *SceneSession) BindData(v any) error {
if len(s.Data) == 0 {
return nil
}
return json.Unmarshal(s.Data, v)
}
// SaveData marshals v as JSON and stores it in the session.
func (s *SceneSession) SaveData(v any) error {
data, err := json.Marshal(v)
if err != nil {
return err
}
s.Data = data
return nil
}
// SessionStore persists scene sessions by key.
type SessionStore interface {
Get(key string) (SceneSession, error)
Set(key string, session SceneSession) error
Delete(key string) error
}
// MemorySessionStore stores scene sessions in memory.
type MemorySessionStore struct {
store map[string]SceneSession
mu sync.RWMutex
}
// NewMemorySessionStore creates an empty in-memory session store.
func NewMemorySessionStore() *MemorySessionStore {
return &MemorySessionStore{
store: make(map[string]SceneSession),
}
}
// Get returns the session stored under key, or the zero session when absent.
func (s *MemorySessionStore) Get(key string) (SceneSession, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if session, ok := s.store[key]; ok {
return session, nil
}
return SceneSession{}, nil
}
// Set stores session under key.
func (s *MemorySessionStore) Set(key string, session SceneSession) error {
s.mu.Lock()
s.store[key] = session
s.mu.Unlock()
return nil
}
// Delete removes the session stored under key.
func (s *MemorySessionStore) Delete(key string) error {
s.mu.Lock()
delete(s.store, key)
s.mu.Unlock()
return nil
}
// SceneResult describes how scene execution should proceed after a handler returns.
type SceneResult struct {
Action SceneAction
Next string
}
// SceneAction controls how the bot updates scene state after a handler returns.
type SceneAction int
const (
// SceneActionStay keeps the current scene and step active.
SceneActionStay SceneAction = iota
// SceneActionNext moves the session to another named step.
SceneActionNext
// SceneActionExit removes the current scene session.
SceneActionExit
// SceneActionPass lets normal bot routing continue after the scene handler.
SceneActionPass
)
// SceneScope defines how scene sessions are keyed.
type SceneScope int
const (
// SceneScopeUser shares a scene across all chats for one user.
SceneScopeUser SceneScope = iota
// SceneScopeChat shares a scene across all users in one chat.
SceneScopeChat
// SceneScopeUserChat isolates a scene per user-chat pair.
SceneScopeUserChat
)
type sceneRuntime interface {
findScene(name string) (*sceneMeta, bool)
getSession(key string) (SceneSession, error)
setSession(key string, session SceneSession) error
deleteSession(key string) error
buildSceneKey(scope SceneScope, ctx *MsgContext) (string, bool)
findSceneSession(ctx *MsgContext) (string, SceneSession, error)
}
type sceneMeta struct {
Name string
Scope SceneScope
Entry string
Steps map[string]struct{}
}
+41
View File
@@ -0,0 +1,41 @@
package laniakea
// SceneContext wraps MsgContext with scene session state for scene handlers.
type SceneContext struct {
*MsgContext
sess SceneSession
key string
}
// Next advances the current scene to step.
func (ctx *SceneContext) Next(step string) SceneResult {
return SceneResult{
Action: SceneActionNext,
Next: step,
}
}
// Stay keeps the current scene step active.
func (ctx *SceneContext) Stay() SceneResult {
return SceneResult{Action: SceneActionStay}
}
// Exit leaves the current scene.
func (ctx *SceneContext) Exit() SceneResult {
return SceneResult{Action: SceneActionExit}
}
// Pass stops scene handling and lets normal routing continue.
func (ctx *SceneContext) Pass() SceneResult {
return SceneResult{Action: SceneActionPass}
}
// BindData unmarshals the current scene session payload into v.
func (ctx *SceneContext) BindData(v any) error {
return ctx.sess.BindData(v)
}
// SaveData marshals v and stores it in the current scene session payload.
func (ctx *SceneContext) SaveData(v any) error {
return ctx.sess.SaveData(v)
}
+255
View File
@@ -0,0 +1,255 @@
package laniakea
import (
"errors"
"fmt"
"strings"
"time"
)
func (bot *Bot[T]) tryHandleScene(ctx *MsgContext) (bool, error) {
key, session, err := bot.findSceneSession(ctx)
if err != nil {
if errors.Is(err, ErrCantFindSession) || errors.Is(err, ErrMessageNil) {
return false, nil
}
return false, err
}
if session.Scene == "" {
return false, nil
}
for _, plugin := range bot.plugins {
scene, ok := plugin.scenes[session.Scene]
if !ok {
continue
}
if scene.PluginName != "" && scene.PluginName != plugin.name {
continue
}
if !plugin.executeMiddlewares(ctx, bot.appData) {
return false, nil
}
sceneCtx := &SceneContext{
MsgContext: ctx,
sess: session,
key: key,
}
return bot.executeScene(sceneCtx, scene)
}
return false, ErrSceneNotFound
}
func (bot *Bot[T]) executeScene(ctx *SceneContext, scene *Scene[T]) (bool, error) {
if ctx.MsgContext == nil || ctx.sess.Scene == "" {
return false, nil
}
var text string
if ctx.Msg != nil {
text = ctx.Msg.Text
if text == "" {
text = ctx.Msg.Caption
}
}
text = strings.TrimSpace(text)
prefix, cmd, args := bot.parseCommand(text)
if cmd != "" {
ctx.Prefix = prefix
ctx.Text = args
ctx.Args = strings.Fields(args)
if _, ok := scene.commands[cmd]; ok {
startTime := time.Now()
bot.emitSceneStarted(ctx, scene, HandlerSceneCommandKind, cmd)
res, _, err := scene.executeCommand(cmd, ctx, bot.appData)
if err != nil {
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.Args = nil
ctx.Prefix = ""
if ctx.sess.Step != "" {
step := ctx.sess.Step
if _, ok := scene.steps[step]; ok {
startTime := time.Now()
bot.emitSceneStarted(ctx, scene, HandlerSceneStepKind, step)
res, _, err := scene.executeStep(step, ctx, bot.appData)
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
}
}
if scene.message != nil {
startTime := time.Now()
bot.emitSceneStarted(ctx, scene, HandlerSceneMessageKind, "message_fallback")
res, _, err := scene.executeMessage(ctx, bot.appData)
if err != nil {
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
}
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) {
switch result.Action {
case SceneActionStay:
if err := bot.sessionStore.Set(ctx.key, ctx.sess); err != nil {
return false, err
}
return true, nil
case SceneActionNext:
if result.Next == "" {
return false, ErrSceneStepNotFound
}
if _, ok := scene.steps[result.Next]; !ok {
return false, ErrSceneStepNotFound
}
ctx.sess.Step = result.Next
if err := bot.sessionStore.Set(ctx.key, ctx.sess); err != nil {
return false, err
}
return true, nil
case SceneActionExit:
if err := bot.sessionStore.Delete(ctx.key); err != nil {
return false, err
}
return true, nil
case SceneActionPass:
return false, nil
default:
return false, nil
}
}
func buildSceneKey(scope SceneScope, ctx *MsgContext) (string, bool) {
if ctx == nil {
return "", false
}
switch scope {
case SceneScopeUserChat:
if ctx.Msg == nil || ctx.Msg.Chat == nil || ctx.FromID == 0 {
return "", false
}
return fmt.Sprintf("user_id:%d:chat_id:%d", ctx.FromID, ctx.Msg.Chat.ID), true
case SceneScopeChat:
if ctx.Msg == nil || ctx.Msg.Chat == nil {
return "", false
}
return fmt.Sprintf("chat_id:%d", ctx.Msg.Chat.ID), true
case SceneScopeUser:
if ctx.FromID == 0 {
return "", false
}
return fmt.Sprintf("user_id:%d", ctx.FromID), true
default:
return "", false
}
}
+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)
}
})
}
+44
View File
@@ -0,0 +1,44 @@
package laniakea
// SplitMessageText splits plain text into Telegram-safe message chunks.
//
// The function preserves the original text exactly: concatenating all returned
// chunks reconstructs text byte-for-byte. It prefers splitting at newlines or
// spaces within the Telegram message limit and falls back to hard rune-based
// splits when no separator is available.
func SplitMessageText(text string) []string {
return splitTextByLimit(text, maxMessageTextLen)
}
func splitTextByLimit(text string, limit int) []string {
if text == "" {
return nil
}
runes := []rune(text)
chunks := make([]string, 0, len(runes)/limit+1)
for start := 0; start < len(runes); {
end := start + limit
if end >= len(runes) {
chunks = append(chunks, string(runes[start:]))
break
}
splitAt := -1
for i := end - 1; i > start; i-- {
if runes[i] == '\n' || runes[i] == ' ' {
splitAt = i + 1
break
}
}
if splitAt == -1 {
splitAt = end
}
chunks = append(chunks, string(runes[start:splitAt]))
start = splitAt
}
return chunks
}
+2 -2
View File
@@ -9,8 +9,8 @@ import (
"net/http"
"time"
"git.nix13.pw/scuroneko/laniakea/utils"
"git.nix13.pw/scuroneko/slog"
"git.scuroneko.dev/scuroneko/laniakea/utils"
"git.scuroneko.dev/scuroneko/slog"
)
// APIOpts holds configuration options for initializing the Telegram API client.
+8 -8
View File
@@ -3,14 +3,14 @@ package tgapi
// Chat represents a chat (private, group, supergroup, channel).
// See https://core.telegram.org/bots/api#chat
type Chat struct {
ID int64 `json:"id"`
Type string `json:"type"`
Title *string `json:"title,omitempty"`
Username *string `json:"username,omitempty"`
FirstName *string `json:"first_name,omitempty"`
LastName *string `json:"last_name,omitempty"`
IsForum *bool `json:"is_forum,omitempty"`
IsDirectMessages *bool `json:"is_direct_messages,omitempty"`
ID int64 `json:"id"`
Type ChatType `json:"type"`
Title *string `json:"title,omitempty"`
Username *string `json:"username,omitempty"`
FirstName *string `json:"first_name,omitempty"`
LastName *string `json:"last_name,omitempty"`
IsForum *bool `json:"is_forum,omitempty"`
IsDirectMessages *bool `json:"is_direct_messages,omitempty"`
}
// ChatType represents the type of a chat.
+1 -1
View File
@@ -1,6 +1,6 @@
package tgapi
import "git.nix13.pw/scuroneko/extypes"
import "git.scuroneko.dev/scuroneko/extypes"
// MessageID represents a message identifier wrapper returned by some API methods.
type MessageID struct {
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"io"
"net/http"
"git.nix13.pw/scuroneko/laniakea/utils"
"git.scuroneko.dev/scuroneko/laniakea/utils"
)
// UpdateParams holds parameters for the getUpdates method.
+2 -2
View File
@@ -10,8 +10,8 @@ import (
"strings"
"time"
"git.nix13.pw/scuroneko/laniakea/utils"
"git.nix13.pw/scuroneko/slog"
"git.scuroneko.dev/scuroneko/laniakea/utils"
"git.scuroneko.dev/scuroneko/slog"
)
const (
+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 (
"strings"
"git.nix13.pw/scuroneko/laniakea/utils"
"git.scuroneko.dev/scuroneko/laniakea/utils"
)
// Ptr returns a pointer to v.
+2 -2
View File
@@ -6,8 +6,8 @@ import (
"mime/multipart"
"testing"
"git.nix13.pw/scuroneko/laniakea/tgapi"
"git.nix13.pw/scuroneko/laniakea/utils"
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
"git.scuroneko.dev/scuroneko/laniakea/utils"
)
type multipartEncodeParams struct {
+1 -1
View File
@@ -3,7 +3,7 @@ package utils
import (
"os"
"git.nix13.pw/scuroneko/slog"
"git.scuroneko.dev/scuroneko/slog"
)
// GetLoggerLevel returns DEBUG when DEBUG=true in env, otherwise FATAL.
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"strings"
"testing"
"git.nix13.pw/scuroneko/slog"
"git.scuroneko.dev/scuroneko/slog"
)
func TestCreateFileLoggerWritesToConfiguredFile(t *testing.T) {
+2 -2
View File
@@ -2,7 +2,7 @@ package utils
const (
// VersionString is the module version string.
VersionString = "1.0.0-rc.11"
VersionString = "1.0.0-rc.13"
// VersionMajor is the module major version.
VersionMajor = 1
// VersionMinor is the module minor version.
@@ -10,5 +10,5 @@ const (
// VersionPatch is the module patch version.
VersionPatch = 0
// VersionBeta is the prerelease counter for the current version.
VersionBeta = 11
VersionBeta = 13
)