REPOSITORY / ScuroNeko/Laniakea
Compare commits
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ad9e48d71
|
||
|
|
4f8d583b03
|
||
|
|
68e7529f16
|
||
|
|
0ee0917af5
|
||
|
|
8618397bc1
|
||
|
|
945b8240e6
|
||
|
|
5d3199dc21
|
@@ -1,2 +1,5 @@
|
|||||||
.idea/
|
.idea/
|
||||||
|
.wiki/
|
||||||
|
.vscode/
|
||||||
test/
|
test/
|
||||||
|
.codex/
|
||||||
|
|||||||
@@ -22,6 +22,18 @@ Review the codebase with focus on:
|
|||||||
- When feasible, make small, high-confidence improvements directly.
|
- When feasible, make small, high-confidence improvements directly.
|
||||||
- When uncertain, state confidence level and evidence.
|
- 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
|
## Go review expectations
|
||||||
Check for:
|
Check for:
|
||||||
- bugs, fragile logic, invalid assumptions, nil handling issues, resource leaks;
|
- 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 repository’s documented commands. If multiple choices exist, use the most standard and least destructive ones first.
|
Prefer the repository’s 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
|
## Output format
|
||||||
For repo-wide review tasks, structure the result as:
|
For repo-wide review tasks, structure the result as:
|
||||||
|
|
||||||
|
|||||||
+48
-1
@@ -1,6 +1,53 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## Unreleased
|
## 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`.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- `CommandExecutor` now returns `error`, and command, payload, and non-command update handlers now use centralized bot error handling for returned errors.
|
||||||
|
- README and README_RU examples now use the new handler signature and document the long-message helpers.
|
||||||
|
- README and README_RU now link to the project wiki, and the wiki now includes a page-priority tracker while content is being filled in.
|
||||||
|
- `AGENTS.md` now requires every change to be recorded in `CHANGELOG.md`, enforces version alignment with `utils/version.go`, and blocks breaking changes without a major-version bump.
|
||||||
|
- `AGENTS.md` now also defines a short commit-message format: one summary line plus up to three high-signal detail lines.
|
||||||
|
- `AGENTS.md` now explicitly requires each commit-message detail line to be placed on its own new line.
|
||||||
|
- `AGENTS.md` now also requires commit messages to be emitted as a plain multiline block instead of collapsed prose or list formatting.
|
||||||
|
- `AGENTS.md` now requires new or expanded project documentation to be maintained in both English and Russian whenever reasonably possible.
|
||||||
|
- `AGENTS.md` now requires all agent-created commits to be GPG-signed and to fail fast instead of falling back to unsigned commits when signing cannot be completed.
|
||||||
|
- `AGENTS.md` now also links the wiki backlog flow more tightly to `TODO.md` and `CHANGELOG.md`, requiring draft-wiki confirmation for large new ideas and synchronized completion records for backlog items.
|
||||||
|
- Added `TODO.md` to track missing framework-level concepts, with detailed notes for scenes, typed handler input, and request-scoped cancellation.
|
||||||
|
- Payload-type comments and docs now distinguish between the bot's default payload type and keyboard-local overrides.
|
||||||
|
- `MsgContext.Context()` now safely falls back to `context.Background()` when no request-scoped context is attached.
|
||||||
|
- `MsgContext` reply, edit, callback, delete, action, and draft-limiter paths now use the context accessor instead of reaching into raw internal state.
|
||||||
|
- `TODO.md` is now a short pointer file, while the detailed framework backlog lives in the wiki as `Framework-Backlog` / `Framework-Backlog-RU`.
|
||||||
|
- Version constants were bumped to `v1.0.0-rc.12`.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Message and caption validation now runs before Telegram API calls, rejecting empty messages, oversized message text, and oversized captions with stable sentinel errors.
|
||||||
|
- Draft flushing and draft updates now reject oversized messages before sending invalid requests.
|
||||||
|
- Callback payload decoding now optionally enforces strict type matching, while the default tolerant mode logs Base64-to-JSON decoding in debug mode and still accepts keyboard-local payload overrides.
|
||||||
|
- Positional argument binding now leaves missing trailing struct fields at zero values, joins the remaining arguments into the final string field, and returns clearer binding errors.
|
||||||
|
- Request-scoped contexts are now created per update handler execution and safely reused through `MsgContext.Context()` even for manually constructed test contexts.
|
||||||
|
- Command and payload handlers now have regression coverage for end-to-end typed argument binding through the normal routing path.
|
||||||
|
|
||||||
|
### Breaking Changes
|
||||||
|
- `CommandExecutor[T]` changed from `func(ctx *MsgContext, db T)` to `func(ctx *MsgContext, db T) error`.
|
||||||
|
- `Plugin.NewCommand(...)`, `Plugin.NewPayload(...)`, and `Plugin.AddUpdateHandler(...)` now require handlers with the new error-returning signature.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
- Added regression tests for `MsgContext.BindArgs(...)`, including scalar conversion, tail-string binding, zero-value trailing fields, invalid targets, unsupported field types, and end-to-end command/payload binding.
|
||||||
|
|
||||||
|
## v1.0.0-rc.11
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- `chat_boost` update decoding now accepts string `boost_id` values, matching the current Telegram Bot API schema and preventing polling failures on boosted-chat updates.
|
||||||
|
|
||||||
|
## v1.0.0-rc.10
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- `Plugin.AddUpdateHandler` for routing non-command Telegram updates by `tgapi.UpdateType`.
|
- `Plugin.AddUpdateHandler` for routing non-command Telegram updates by `tgapi.UpdateType`.
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ A lightweight, easy-to-use, and performant Telegram Bot API wrapper for Go. It s
|
|||||||
|
|
||||||
[На русском](README_RU.md)
|
[На русском](README_RU.md)
|
||||||
|
|
||||||
|
[Wiki](https://git.nix13.pw/ScuroNeko/Laniakea/wiki)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ✨ Features
|
## ✨ Features
|
||||||
@@ -52,10 +54,11 @@ import (
|
|||||||
// It receives two parameters:
|
// It receives two parameters:
|
||||||
// - ctx: the message context (contains info about the message, sender, chat, etc.)
|
// - ctx: the message context (contains info about the message, sender, chat, etc.)
|
||||||
// - db: your custom database context (here we use NoDB, a placeholder for no database)
|
// - db: your custom database context (here we use NoDB, a placeholder for no database)
|
||||||
func echo(ctx *laniakea.MsgContext, db laniakea.NoDB) {
|
func echo(ctx *laniakea.MsgContext, db laniakea.NoDB) error {
|
||||||
// Answer the user with the text they sent, without any command prefix.
|
// Answer the user with the text they sent, without any command prefix.
|
||||||
// ctx.Text contains the user's message with the command part stripped off.
|
// ctx.Text contains the user's message with the command part stripped off.
|
||||||
ctx.Answer(ctx.Text) // User input WITHOUT command
|
ctx.Answer(ctx.Text) // User input WITHOUT command
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -81,8 +84,9 @@ func main() {
|
|||||||
|
|
||||||
// 5. Add another command using an anonymous function (closure).
|
// 5. Add another command using an anonymous function (closure).
|
||||||
// This command simply replies "Pong" when the user sends "/ping".
|
// This command simply replies "Pong" when the user sends "/ping".
|
||||||
p.AddCommand(p.NewCommand(func(ctx *laniakea.MsgContext, db laniakea.NoDB) {
|
p.AddCommand(p.NewCommand(func(ctx *laniakea.MsgContext, db laniakea.NoDB) error {
|
||||||
ctx.Answer("Pong")
|
ctx.Answer("Pong")
|
||||||
|
return nil
|
||||||
}, "ping"))
|
}, "ping"))
|
||||||
|
|
||||||
// 6. Configure the bot with a custom error template and add the plugin.
|
// 6. Configure the bot with a custom error template and add the plugin.
|
||||||
@@ -107,8 +111,8 @@ func main() {
|
|||||||
1. `BotOpts`: Holds configuration like the API token.
|
1. `BotOpts`: Holds configuration like the API token.
|
||||||
2. `NewBot[T]`: Creates a bot instance. The type parameter T allows you to pass a custom database context (e.g., *sql.DB) that will be available in all handlers. Use laniakea.NoDB if you don't need it.
|
2. `NewBot[T]`: Creates a bot instance. The type parameter T allows you to pass a custom database context (e.g., *sql.DB) that will be available in all handlers. Use laniakea.NoDB if you don't need it.
|
||||||
3. `NewPlugin`: Creates a logical group for commands and middlewares.
|
3. `NewPlugin`: Creates a logical group for commands and middlewares.
|
||||||
4. `AddCommand`: Registers a command. The first argument is the handler function (func(*MsgContext, T)), the second is the command name (without the slash).
|
4. `AddCommand`: Registers a command. The first argument is the handler function (`func(*MsgContext, T) error`), the second is the command name (without the slash).
|
||||||
5. **Handler Functions**: Receive *MsgContext (message details, methods like Answer) and your custom database context T.
|
5. **Handler Functions**: Receive *MsgContext (message details, methods like Answer) and your custom database context T, and return an error for centralized error handling.
|
||||||
6. `ErrorTemplate`: Sets a template for error messages. The %s placeholder is replaced by the actual error.
|
6. `ErrorTemplate`: Sets a template for error messages. The %s placeholder is replaced by the actual error.
|
||||||
7. `AutoGenerateCommands`: Registers plugin-defined commands with Telegram across the supported scopes.
|
7. `AutoGenerateCommands`: Registers plugin-defined commands with Telegram across the supported scopes.
|
||||||
8. `Run()`: Starts the bot's update polling loop and returns an error if startup or polling fails.
|
8. `Run()`: Starts the bot's update polling loop and returns an error if startup or polling fails.
|
||||||
@@ -128,9 +132,10 @@ bot.AddPlugins(plugin)
|
|||||||
|
|
||||||
A command is a function that handles a specific bot command (e.g., /start).
|
A command is a function that handles a specific bot command (e.g., /start).
|
||||||
```go
|
```go
|
||||||
func myHandler(ctx *laniakea.MsgContext, db *MyDB) {
|
func myHandler(ctx *laniakea.MsgContext, db *MyDB) error {
|
||||||
// Access command arguments via ctx.Args ([]string)
|
// Access command arguments via ctx.Args ([]string)
|
||||||
// Reply to the user: ctx.Answer("some text")
|
// 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:
|
Provides access to the incoming message and useful reply methods:
|
||||||
|
|
||||||
- `Answer(text string) *AnswerMessage`: Sends a message with parse_mode none.
|
- `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).
|
- `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.
|
- `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.
|
- `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.
|
- `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).
|
- `AnswerPhotoMarkdown(photoId, text string) *AnswerMessage`: Sends a photo with MarkdownV2 caption (you handle escaping).
|
||||||
@@ -223,7 +230,7 @@ func adminOnlyMiddleware(ctx *laniakea.MsgContext, db *MyDB) bool {
|
|||||||
- Middleware can modify the MsgContext (e.g., add custom fields) before the command runs.
|
- Middleware can modify the MsgContext (e.g., add custom fields) before the command runs.
|
||||||
|
|
||||||
## ⚙️ Advanced Configuration
|
## ⚙️ 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.
|
- **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.
|
- **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.
|
- **Custom Update Handlers**: Use `plugin.AddUpdateHandler(...)` for Telegram update types that are not part of the command/payload flow.
|
||||||
@@ -242,6 +249,8 @@ This project is licensed under the GNU General Public License v3.0 — see the [
|
|||||||
## 📚 Learn More
|
## 📚 Learn More
|
||||||
[GoDoc](https://pkg.go.dev/git.nix13.pw/scuroneko/laniakea)
|
[GoDoc](https://pkg.go.dev/git.nix13.pw/scuroneko/laniakea)
|
||||||
|
|
||||||
|
[Wiki](https://git.nix13.pw/ScuroNeko/Laniakea/wiki)
|
||||||
|
|
||||||
[Telegram Bot API](https://core.telegram.org/bots/api)
|
[Telegram Bot API](https://core.telegram.org/bots/api)
|
||||||
|
|
||||||
✅ Built with ❤️ by scuroneko
|
✅ Built with ❤️ by scuroneko
|
||||||
|
|||||||
+15
-6
@@ -10,6 +10,8 @@
|
|||||||
|
|
||||||
[English](README.md)
|
[English](README.md)
|
||||||
|
|
||||||
|
[Wiki](https://git.nix13.pw/ScuroNeko/Laniakea/wiki)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ✨ Возможности
|
## ✨ Возможности
|
||||||
@@ -53,10 +55,11 @@ import (
|
|||||||
// Она получает два параметра:
|
// Она получает два параметра:
|
||||||
// - ctx: контекст сообщения (содержит информацию о сообщении, отправителе, чате и т.д.)
|
// - ctx: контекст сообщения (содержит информацию о сообщении, отправителе, чате и т.д.)
|
||||||
// - db: ваш пользовательский контекст базы данных (здесь мы используем NoDB — заглушку)
|
// - db: ваш пользовательский контекст базы данных (здесь мы используем NoDB — заглушку)
|
||||||
func echo(ctx *laniakea.MsgContext, db laniakea.NoDB) {
|
func echo(ctx *laniakea.MsgContext, db laniakea.NoDB) error {
|
||||||
// Отвечаем пользователю текстом, который он прислал, без префикса команды.
|
// Отвечаем пользователю текстом, который он прислал, без префикса команды.
|
||||||
// ctx.Text содержит сообщение пользователя, из которого удалена часть с командой.
|
// ctx.Text содержит сообщение пользователя, из которого удалена часть с командой.
|
||||||
ctx.Answer(ctx.Text) // Ввод пользователя БЕЗ команды
|
ctx.Answer(ctx.Text) // Ввод пользователя БЕЗ команды
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -82,8 +85,9 @@ func main() {
|
|||||||
|
|
||||||
// 5. Добавляем ещё одну команду, используя анонимную функцию (замыкание).
|
// 5. Добавляем ещё одну команду, используя анонимную функцию (замыкание).
|
||||||
// Эта команда просто отвечает "Pong", когда пользователь отправляет "/ping".
|
// Эта команда просто отвечает "Pong", когда пользователь отправляет "/ping".
|
||||||
p.AddCommand(p.NewCommand(func(ctx *laniakea.MsgContext, db laniakea.NoDB) {
|
p.AddCommand(p.NewCommand(func(ctx *laniakea.MsgContext, db laniakea.NoDB) error {
|
||||||
ctx.Answer("Pong")
|
ctx.Answer("Pong")
|
||||||
|
return nil
|
||||||
}, "ping"))
|
}, "ping"))
|
||||||
|
|
||||||
// 6. Настраиваем бота: задаём шаблон ошибки и добавляем плагин.
|
// 6. Настраиваем бота: задаём шаблон ошибки и добавляем плагин.
|
||||||
@@ -108,8 +112,8 @@ func main() {
|
|||||||
1. `BotOpts`: Содержит конфигурацию, например, токен API.
|
1. `BotOpts`: Содержит конфигурацию, например, токен API.
|
||||||
2. `NewBot[T]`: Создаёт экземпляр бота. Параметр типа T позволяет передать пользовательский контекст базы данных (например, *sql.DB), который будет доступен во всех обработчиках. Используйте laniakea.NoDB, если он не нужен.
|
2. `NewBot[T]`: Создаёт экземпляр бота. Параметр типа T позволяет передать пользовательский контекст базы данных (например, *sql.DB), который будет доступен во всех обработчиках. Используйте laniakea.NoDB, если он не нужен.
|
||||||
3. `NewPlugin`: Создаёт логическую группу для команд и Middleware.
|
3. `NewPlugin`: Создаёт логическую группу для команд и Middleware.
|
||||||
4. `AddCommand`: Регистрирует команду. Первый аргумент — функция-обработчик (func(*MsgContext, T)), второй — имя команды (без слеша).
|
4. `AddCommand`: Регистрирует команду. Первый аргумент — функция-обработчик (`func(*MsgContext, T) error`), второй — имя команды (без слеша).
|
||||||
5. **Функции-обработчики**: Получают *MsgContext (детали сообщения, методы типа Answer) и ваш контекст базы данных T.
|
5. **Функции-обработчики**: Получают *MsgContext (детали сообщения, методы типа Answer) и ваш контекст базы данных T, а ошибку возвращают для централизованной обработки.
|
||||||
6. `ErrorTemplate`: Устанавливает шаблон для сообщений об ошибках. Плейсхолдер %s заменяется на текст ошибки.
|
6. `ErrorTemplate`: Устанавливает шаблон для сообщений об ошибках. Плейсхолдер %s заменяется на текст ошибки.
|
||||||
7. `AutoGenerateCommands`: Регистрирует команды из плагинов в Telegram для поддерживаемых scope.
|
7. `AutoGenerateCommands`: Регистрирует команды из плагинов в Telegram для поддерживаемых scope.
|
||||||
8. `Run()`: Запускает цикл опроса обновлений бота и возвращает ошибку, если старт или polling завершился неуспешно.
|
8. `Run()`: Запускает цикл опроса обновлений бота и возвращает ошибку, если старт или polling завершился неуспешно.
|
||||||
@@ -129,9 +133,10 @@ bot.AddPlugins(plugin)
|
|||||||
Команда — это функция, которая обрабатывает конкретную команду бота (например, /start).
|
Команда — это функция, которая обрабатывает конкретную команду бота (например, /start).
|
||||||
|
|
||||||
```go
|
```go
|
||||||
func myHandler(ctx *laniakea.MsgContext, db *MyDB) {
|
func myHandler(ctx *laniakea.MsgContext, db *MyDB) error {
|
||||||
// Доступ к аргументам команды через ctx.Args ([]string)
|
// Доступ к аргументам команды через ctx.Args ([]string)
|
||||||
// Ответ пользователю: ctx.Answer("какой-то текст")
|
// Ответ пользователю: ctx.Answer("какой-то текст")
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -139,8 +144,10 @@ func myHandler(ctx *laniakea.MsgContext, db *MyDB) {
|
|||||||
Предоставляет доступ к входящему сообщению и полезные методы для ответа:
|
Предоставляет доступ к входящему сообщению и полезные методы для ответа:
|
||||||
|
|
||||||
- `Answer(text string)`: Отправляет сообщение с parse_mode none.
|
- `Answer(text string)`: Отправляет сообщение с parse_mode none.
|
||||||
|
- `AnswerLong(text string) []*AnswerMessage`: Разбивает длинный plain text на несколько сообщений.
|
||||||
- `AnswerMarkdown(text string)`: Отправляет сообщение, отформатированное MarkdownV2 (экранирование на вашей стороне).
|
- `AnswerMarkdown(text string)`: Отправляет сообщение, отформатированное MarkdownV2 (экранирование на вашей стороне).
|
||||||
- `Keyboard(text string, keyboard *InlineKeyboard) *AnswerMessage`: Отправляет сообщение с parse_mode none и Inline клавиатурой.
|
- `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 клавиатурой.
|
- `KeyboardMarkdown(text string, keyboard *InlineKeyboard) *AnswerMessage`: Отправляет сообщение, отформатированное MarkdownV2 (экранирование на вашей стороне), и Inline клавиатурой.
|
||||||
- `AnswerPhoto(photoId, text string) *AnswerMessage`: Отправляет фотографию с подписью и parse_mode none.
|
- `AnswerPhoto(photoId, text string) *AnswerMessage`: Отправляет фотографию с подписью и parse_mode none.
|
||||||
- `AnswerPhotoMarkdown(photoId, text string) *AnswerMessage`: Отправляет фотографию с подписью, отформатированной MarkdownV2 (экранирование на вашей стороне).
|
- `AnswerPhotoMarkdown(photoId, text string) *AnswerMessage`: Отправляет фотографию с подписью, отформатированной MarkdownV2 (экранирование на вашей стороне).
|
||||||
@@ -220,7 +227,7 @@ func adminOnlyMiddleware(ctx *laniakea.MsgContext, db *MyDB) bool {
|
|||||||
- Middleware может изменять MsgContext (например, добавлять пользовательские поля) перед запуском команды.
|
- Middleware может изменять MsgContext (например, добавлять пользовательские поля) перед запуском команды.
|
||||||
|
|
||||||
## ⚙️ Расширенная настройка
|
## ⚙️ Расширенная настройка
|
||||||
- **Инлайн-клавиатуры**: Создавайте клавиатуры с помощью `laniakea.NewInlineKeyboardJson`, `laniakea.NewInlineKeyboardBase64` или `laniakea.NewInlineKeyboard`.
|
- **Инлайн-клавиатуры**: Создавайте клавиатуры с помощью `laniakea.NewInlineKeyboardJson`, `laniakea.NewInlineKeyboardBase64` или `laniakea.NewInlineKeyboard`. `Bot.SetPayloadType(...)` задаёт payload format по умолчанию, а `InlineKeyboard.SetPayloadType(...)` переопределяет его для конкретной клавиатуры.
|
||||||
- **Ограничение запросов**: Передайте настроенный `utils.RateLimiter` через `BotOpts` для корректной обработки лимитов Telegram.
|
- **Ограничение запросов**: Передайте настроенный `utils.RateLimiter` через `BotOpts` для корректной обработки лимитов Telegram.
|
||||||
- **Локализация**: `L10n` безопасен для конкурентного использования после подключения к боту.
|
- **Локализация**: `L10n` безопасен для конкурентного использования после подключения к боту.
|
||||||
- **Пользовательские update handlers**: Используйте `plugin.AddUpdateHandler(...)` для Telegram update types вне command/payload flow.
|
- **Пользовательские update handlers**: Используйте `plugin.AddUpdateHandler(...)` для Telegram update types вне command/payload flow.
|
||||||
@@ -238,6 +245,8 @@ func adminOnlyMiddleware(ctx *laniakea.MsgContext, db *MyDB) bool {
|
|||||||
## 📚 Дополнительная информация
|
## 📚 Дополнительная информация
|
||||||
[GoDoc Laniakea](https://pkg.go.dev/git.nix13.pw/scuroneko/laniakea)
|
[GoDoc Laniakea](https://pkg.go.dev/git.nix13.pw/scuroneko/laniakea)
|
||||||
|
|
||||||
|
[Wiki](https://git.nix13.pw/ScuroNeko/Laniakea/wiki)
|
||||||
|
|
||||||
[Telegram Bot API](https://core.telegram.org/bots/api)
|
[Telegram Bot API](https://core.telegram.org/bots/api)
|
||||||
|
|
||||||
✅ Создано с ❤️ scuroneko
|
✅ Создано с ❤️ scuroneko
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# TODO
|
||||||
|
|
||||||
|
The framework backlog has moved to the wiki.
|
||||||
|
|
||||||
|
Primary page:
|
||||||
|
|
||||||
|
- https://git.nix13.pw/ScuroNeko/Laniakea/wiki/Framework-Backlog
|
||||||
|
|
||||||
|
Russian page:
|
||||||
|
|
||||||
|
- https://git.nix13.pw/ScuroNeko/Laniakea/wiki/Framework-Backlog-RU
|
||||||
|
|
||||||
|
Current high-priority status:
|
||||||
|
|
||||||
|
- `1. Conversation / Scene Model`: not implemented yet.
|
||||||
|
- `2. Typed Handler Input Model`: completed in `v1.0.0-rc.12`.
|
||||||
|
- `3. Request Context / Cancellation Model`: completed in `v1.0.0-rc.12`.
|
||||||
@@ -4,7 +4,9 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"maps"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"slices"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -77,12 +79,13 @@ var (
|
|||||||
// Runtime accessors are safe for concurrent use. Configure the bot before Run.
|
// Runtime accessors are safe for concurrent use. Configure the bot before Run.
|
||||||
// A Bot is single-use: after Run or RunWithContext returns, create a new Bot for the next session.
|
// A Bot is single-use: after Run or RunWithContext returns, create a new Bot for the next session.
|
||||||
type Bot[T DbContext] struct {
|
type Bot[T DbContext] struct {
|
||||||
token string
|
token string
|
||||||
debug bool
|
debug bool
|
||||||
errorTemplate string
|
errorTemplate string
|
||||||
username string
|
username string
|
||||||
payloadType BotPayloadType
|
payloadType BotPayloadType
|
||||||
maxWorkers int
|
strictPayloadType bool
|
||||||
|
maxWorkers int
|
||||||
|
|
||||||
logger *slog.Logger // Main bot logger (JSON stdout + optional file)
|
logger *slog.Logger // Main bot logger (JSON stdout + optional file)
|
||||||
RequestLogger *slog.Logger // Optional request-level API logging
|
RequestLogger *slog.Logger // Optional request-level API logging
|
||||||
@@ -101,6 +104,9 @@ type Bot[T DbContext] struct {
|
|||||||
l10n *L10n // Localization manager
|
l10n *L10n // Localization manager
|
||||||
draftProvider *DraftProvider // Draft message builder
|
draftProvider *DraftProvider // Draft message builder
|
||||||
|
|
||||||
|
sessionStore SessionStore // Session store for scene management
|
||||||
|
sceneScopePriority []SceneScope
|
||||||
|
|
||||||
updateOffsetMu sync.Mutex
|
updateOffsetMu sync.Mutex
|
||||||
updateOffset int // Last processed update ID
|
updateOffset int // Last processed update ID
|
||||||
updateTypes []tgapi.UpdateType // Types of updates to fetch
|
updateTypes []tgapi.UpdateType // Types of updates to fetch
|
||||||
@@ -156,22 +162,26 @@ func NewBot[T any](opts *BotOpts) (*Bot[T], error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bot := &Bot[T]{
|
bot := &Bot[T]{
|
||||||
updateOffset: 0,
|
updateOffset: 0,
|
||||||
errorTemplate: "%s",
|
errorTemplate: "%s",
|
||||||
payloadType: BotPayloadBase64,
|
payloadType: BotPayloadBase64,
|
||||||
maxWorkers: workers,
|
strictPayloadType: opts.StrictPayloadType,
|
||||||
updateQueue: updateQueue,
|
maxWorkers: workers,
|
||||||
api: api,
|
updateQueue: updateQueue,
|
||||||
uploader: uploader,
|
api: api,
|
||||||
debug: opts.Debug,
|
uploader: uploader,
|
||||||
prefixes: prefixes,
|
debug: opts.Debug,
|
||||||
token: opts.Token,
|
prefixes: prefixes,
|
||||||
plugins: make([]Plugin[T], 0),
|
token: opts.Token,
|
||||||
updateTypes: append([]tgapi.UpdateType{}, opts.UpdateTypes...),
|
plugins: make([]Plugin[T], 0),
|
||||||
runners: make([]Runner[T], 0),
|
updateTypes: append([]tgapi.UpdateType{}, opts.UpdateTypes...),
|
||||||
extraLoggers: make([]*slog.Logger, 0),
|
runners: make([]Runner[T], 0),
|
||||||
l10n: &L10n{},
|
extraLoggers: make([]*slog.Logger, 0),
|
||||||
draftProvider: NewRandomDraftProvider(api),
|
l10n: &L10n{},
|
||||||
|
draftProvider: NewRandomDraftProvider(api),
|
||||||
|
|
||||||
|
sessionStore: NewMemorySessionStore(),
|
||||||
|
sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add API and Uploader loggers to extraLoggers for unified output
|
// Add API and Uploader loggers to extraLoggers for unified output
|
||||||
@@ -332,6 +342,34 @@ func (bot *Bot[T]) SetDraftProvider(p *DraftProvider) *Bot[T] {
|
|||||||
bot.draftProvider = p
|
bot.draftProvider = p
|
||||||
return bot
|
return bot
|
||||||
}
|
}
|
||||||
|
func (bot *Bot[T]) GetDraftProvider() *DraftProvider {
|
||||||
|
return bot.draftProvider
|
||||||
|
}
|
||||||
|
|
||||||
|
func (bot *Bot[T]) SetSettionStore(store SessionStore) *Bot[T] {
|
||||||
|
bot.sessionStore = store
|
||||||
|
return bot
|
||||||
|
}
|
||||||
|
func (bot *Bot[T]) GetSessionStore() SessionStore {
|
||||||
|
return bot.sessionStore
|
||||||
|
}
|
||||||
|
|
||||||
|
func (bot *Bot[T]) SetSceneScopePriority(priority []SceneScope) *Bot[T] {
|
||||||
|
newPriority := make([]SceneScope, 0, 3)
|
||||||
|
for _, scope := range priority {
|
||||||
|
if slices.Index(newPriority, scope) >= 0 {
|
||||||
|
bot.logger.Warnln(fmt.Sprintf("duplicate scope %v in scene scope priority; ignoring duplicates", scope))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
newPriority = append(newPriority, scope)
|
||||||
|
}
|
||||||
|
if len(newPriority) == 0 || len(newPriority) > 3 {
|
||||||
|
bot.logger.Warnln("scene scope priority must have 1 to 3 scopes; ignoring invalid input")
|
||||||
|
return bot
|
||||||
|
}
|
||||||
|
bot.sceneScopePriority = append([]SceneScope(nil), newPriority...)
|
||||||
|
return bot
|
||||||
|
}
|
||||||
|
|
||||||
// DatabaseContext injects a database context into the bot.
|
// DatabaseContext injects a database context into the bot.
|
||||||
// This context is accessible to plugins and middleware via GetDBContext().
|
// This context is accessible to plugins and middleware via GetDBContext().
|
||||||
@@ -356,14 +394,25 @@ func (bot *Bot[T]) UpdateTypes(t ...tgapi.UpdateType) *Bot[T] {
|
|||||||
return bot
|
return bot
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetPayloadType sets the payload encoding type used for callback data.
|
// SetPayloadType sets the default payload encoding type used for callback data.
|
||||||
// JSON stores payload as a string: `{"cmd":"command","args":[...]}`.
|
// JSON stores payload as a string: `{"cmd":"command","args":[...]}`.
|
||||||
// Base64 stores the same JSON encoded as a Base64URL string.
|
// 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] {
|
func (bot *Bot[T]) SetPayloadType(t BotPayloadType) *Bot[T] {
|
||||||
bot.payloadType = t
|
bot.payloadType = t
|
||||||
return bot
|
return bot
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetPayloadType returns the bot's default callback payload encoding type.
|
||||||
|
func (bot *Bot[T]) GetPayloadType() BotPayloadType { return bot.payloadType }
|
||||||
|
|
||||||
|
// SetStrictPayloadType enables or disables strict callback payload decoding.
|
||||||
|
// When enabled, callback payloads must match the bot's default payload type.
|
||||||
|
func (bot *Bot[T]) SetStrictPayloadType(strict bool) *Bot[T] {
|
||||||
|
bot.strictPayloadType = strict
|
||||||
|
return bot
|
||||||
|
}
|
||||||
|
|
||||||
// AddUpdateType adds one or more update types to the list.
|
// AddUpdateType adds one or more update types to the list.
|
||||||
// Does not overwrite existing types.
|
// Does not overwrite existing types.
|
||||||
func (bot *Bot[T]) AddUpdateType(t ...tgapi.UpdateType) *Bot[T] {
|
func (bot *Bot[T]) AddUpdateType(t ...tgapi.UpdateType) *Bot[T] {
|
||||||
@@ -655,7 +704,7 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) error {
|
|||||||
for update := range bot.updateQueue {
|
for update := range bot.updateQueue {
|
||||||
u := update // capture loop variable
|
u := update // capture loop variable
|
||||||
pool.Submit(func() {
|
pool.Submit(func() {
|
||||||
bot.handle(u)
|
bot.handle(ctx, u)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
pool.Stop() // Wait for all tasks to complete and stop the pool
|
pool.Stop() // Wait for all tasks to complete and stop the pool
|
||||||
@@ -746,9 +795,7 @@ func clonePlugin[T DbContext](p *Plugin[T]) Plugin[T] {
|
|||||||
for name, command := range p.payloads {
|
for name, command := range p.payloads {
|
||||||
cloned.payloads[name] = cloneCommand(command)
|
cloned.payloads[name] = cloneCommand(command)
|
||||||
}
|
}
|
||||||
for t, handler := range p.handlers {
|
maps.Copy(cloned.handlers, p.handlers)
|
||||||
cloned.handlers[t] = handler
|
|
||||||
}
|
|
||||||
|
|
||||||
return cloned
|
return cloned
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-2
@@ -56,6 +56,10 @@ type BotOpts struct {
|
|||||||
// Use this to prioritize responsiveness over reliability.
|
// Use this to prioritize responsiveness over reliability.
|
||||||
DropRLOverflow bool
|
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 is the maximum number of update handlers that may run concurrently.
|
||||||
MaxWorkers int
|
MaxWorkers int
|
||||||
}
|
}
|
||||||
@@ -75,6 +79,7 @@ type BotOpts struct {
|
|||||||
// - API_URL: custom API endpoint
|
// - API_URL: custom API endpoint
|
||||||
// - RATE_LIMIT: max requests per second (default: 30)
|
// - RATE_LIMIT: max requests per second (default: 30)
|
||||||
// - DROP_RL_OVERFLOW: "true" to drop updates on rate limit overflow
|
// - 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)
|
// - MAX_WORKERS: maximum number of concurrent update handlers (default: 32)
|
||||||
//
|
//
|
||||||
// Returns a populated BotOpts.
|
// Returns a populated BotOpts.
|
||||||
@@ -116,8 +121,9 @@ func LoadOptsFromEnv() *BotOpts {
|
|||||||
UseTestServer: os.Getenv("USE_TEST_SERVER") == "true",
|
UseTestServer: os.Getenv("USE_TEST_SERVER") == "true",
|
||||||
APIUrl: os.Getenv("API_URL"),
|
APIUrl: os.Getenv("API_URL"),
|
||||||
|
|
||||||
RateLimit: rateLimit,
|
RateLimit: rateLimit,
|
||||||
DropRLOverflow: os.Getenv("DROP_RL_OVERFLOW") == "true",
|
DropRLOverflow: os.Getenv("DROP_RL_OVERFLOW") == "true",
|
||||||
|
StrictPayloadType: os.Getenv("STRICT_PAYLOAD_TYPE") == "true",
|
||||||
|
|
||||||
MaxWorkers: maxWorkers,
|
MaxWorkers: maxWorkers,
|
||||||
}
|
}
|
||||||
@@ -208,6 +214,13 @@ func (opts *BotOpts) SetDropRLOverflow(drop bool) *BotOpts {
|
|||||||
return opts
|
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.
|
// SetMaxWorkers sets the maximum number of concurrent update handlers.
|
||||||
// Must be called before NewBot, as the value is captured during bot creation.
|
// Must be called before NewBot, as the value is captured during bot creation.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -45,3 +45,12 @@ func TestLoadPrefixesFromEnvDropsEmptyValues(t *testing.T) {
|
|||||||
t.Fatalf("unexpected prefixes: got %v want %v", got, want)
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package laniakea
|
||||||
|
|
||||||
|
func (bot *Bot[T]) GetSession(key string) (SceneSession, error) {
|
||||||
|
return bot.sessionStore.Get(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (bot *Bot[T]) SetSession(key string, session SceneSession) error {
|
||||||
|
return bot.sessionStore.Set(key, session)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (bot *Bot[T]) DeleteSession(key string) error {
|
||||||
|
return bot.sessionStore.Delete(key)
|
||||||
|
}
|
||||||
|
func (bot *Bot[T]) FindScene(name string) (*sceneMeta, bool) {
|
||||||
|
for _, plugin := range bot.plugins {
|
||||||
|
scene, ok := plugin.scenes[name]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
steps := make(map[string]struct{}, len(scene.steps))
|
||||||
|
for step := range scene.steps {
|
||||||
|
steps[step] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &sceneMeta{
|
||||||
|
Name: scene.Name,
|
||||||
|
Scope: scene.Scope,
|
||||||
|
Entry: scene.Entry,
|
||||||
|
Steps: steps,
|
||||||
|
}, true
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (bot *Bot[T]) FindSceneSession(ctx *MsgContext) (string, SceneSession, error) {
|
||||||
|
var zero SceneSession
|
||||||
|
if ctx.Msg == nil {
|
||||||
|
return "", zero, ErrMessageNil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, scope := range bot.sceneScopePriority {
|
||||||
|
key, ok := buildSceneKey(scope, ctx)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
session, err := bot.sessionStore.Get(key)
|
||||||
|
if err != nil {
|
||||||
|
return "", zero, err
|
||||||
|
}
|
||||||
|
if session.Scene != "" {
|
||||||
|
return key, session, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", zero, ErrCantFindSession
|
||||||
|
}
|
||||||
|
func (bot *Bot[T]) BuildSceneKey(scope SceneScope, ctx *MsgContext) (string, bool) {
|
||||||
|
return buildSceneKey(scope, ctx)
|
||||||
|
}
|
||||||
+18
-2
@@ -27,13 +27,13 @@ func TestAddPluginsSnapshotsConfiguration(t *testing.T) {
|
|||||||
bot := &Bot[NoDB]{logger: slog.CreateLogger()}
|
bot := &Bot[NoDB]{logger: slog.CreateLogger()}
|
||||||
plugin := NewPlugin[NoDB]("demo")
|
plugin := NewPlugin[NoDB]("demo")
|
||||||
|
|
||||||
cmd := plugin.NewCommand(func(ctx *MsgContext, db NoDB) {}, "start")
|
cmd := plugin.NewCommand(func(ctx *MsgContext, db NoDB) error { return nil }, "start")
|
||||||
plugin.AddMiddleware(NewMiddleware("base", func(ctx *MsgContext, db NoDB) bool { return true }))
|
plugin.AddMiddleware(NewMiddleware("base", func(ctx *MsgContext, db NoDB) bool { return true }))
|
||||||
|
|
||||||
bot.AddPlugins(plugin)
|
bot.AddPlugins(plugin)
|
||||||
|
|
||||||
cmd.SetDescription("mutated after registration")
|
cmd.SetDescription("mutated after registration")
|
||||||
plugin.NewCommand(func(ctx *MsgContext, db NoDB) {}, "late")
|
plugin.NewCommand(func(ctx *MsgContext, db NoDB) error { return nil }, "late")
|
||||||
plugin.AddMiddleware(NewMiddleware("late", func(ctx *MsgContext, db NoDB) bool { return true }))
|
plugin.AddMiddleware(NewMiddleware("late", func(ctx *MsgContext, db NoDB) bool { return true }))
|
||||||
|
|
||||||
registered := bot.plugins[0]
|
registered := bot.plugins[0]
|
||||||
@@ -48,6 +48,22 @@ func TestAddPluginsSnapshotsConfiguration(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBotPayloadTypeConfiguration(t *testing.T) {
|
||||||
|
bot := &Bot[NoDB]{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) {
|
func TestAddPluginsSkipsNilPlugin(t *testing.T) {
|
||||||
bot := &Bot[NoDB]{logger: slog.CreateLogger()}
|
bot := &Bot[NoDB]{logger: slog.CreateLogger()}
|
||||||
plugin := NewPlugin[NoDB]("demo")
|
plugin := NewPlugin[NoDB]("demo")
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ func TestAutoGenerateCommandsChecksLimitBeforeDelete(t *testing.T) {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
plugin := NewPlugin[NoDB]("overflow")
|
plugin := NewPlugin[NoDB]("overflow")
|
||||||
exec := func(ctx *MsgContext, db NoDB) {}
|
exec := func(ctx *MsgContext, db NoDB) error { return nil }
|
||||||
for i := 0; i < 101; i++ {
|
for i := 0; i < 101; i++ {
|
||||||
plugin.AddCommand(NewCommand(exec, "cmd"+strconv.Itoa(i)))
|
plugin.AddCommand(NewCommand(exec, "cmd"+strconv.Itoa(i)))
|
||||||
}
|
}
|
||||||
@@ -66,7 +66,7 @@ func TestAutoGenerateCommandsChecksLimitBeforeDelete(t *testing.T) {
|
|||||||
|
|
||||||
func TestGatherCommandsForPluginReturnsSortedCommands(t *testing.T) {
|
func TestGatherCommandsForPluginReturnsSortedCommands(t *testing.T) {
|
||||||
plugin := NewPlugin[NoDB]("sorted")
|
plugin := NewPlugin[NoDB]("sorted")
|
||||||
exec := func(ctx *MsgContext, db NoDB) {}
|
exec := func(ctx *MsgContext, db NoDB) error { return nil }
|
||||||
|
|
||||||
plugin.AddCommand(NewCommand(exec, "zeta"))
|
plugin.AddCommand(NewCommand(exec, "zeta"))
|
||||||
plugin.AddCommand(NewCommand(exec, "alpha"))
|
plugin.AddCommand(NewCommand(exec, "alpha"))
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package laniakea
|
package laniakea
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"math/rand/v2"
|
"math/rand/v2"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
@@ -9,9 +8,6 @@ import (
|
|||||||
"git.nix13.pw/scuroneko/laniakea/tgapi"
|
"git.nix13.pw/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.
|
// Interface for generating unique draft IDs.
|
||||||
type draftIdGenerator interface {
|
type draftIdGenerator interface {
|
||||||
// Next returns the next unique draft ID.
|
// Next returns the next unique draft ID.
|
||||||
@@ -221,6 +217,9 @@ func (d *Draft) Flush() error {
|
|||||||
if d.chatID == 0 {
|
if d.chatID == 0 {
|
||||||
return ErrDraftChatIDZero
|
return ErrDraftChatIDZero
|
||||||
}
|
}
|
||||||
|
if err := validateMessageText(d.Message); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
params := tgapi.SendMessageP{
|
params := tgapi.SendMessageP{
|
||||||
ChatID: d.chatID,
|
ChatID: d.chatID,
|
||||||
@@ -245,6 +244,9 @@ func (d *Draft) push(text string) error {
|
|||||||
return ErrDraftChatIDZero
|
return ErrDraftChatIDZero
|
||||||
}
|
}
|
||||||
d.Message += text
|
d.Message += text
|
||||||
|
if err := validateMessageText(d.Message); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
params := tgapi.SendMessageDraftP{
|
params := tgapi.SendMessageDraftP{
|
||||||
ChatID: d.chatID,
|
ChatID: d.chatID,
|
||||||
DraftID: d.ID,
|
DraftID: d.ID,
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package laniakea
|
package laniakea
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.nix13.pw/scuroneko/laniakea/tgapi"
|
"git.nix13.pw/scuroneko/laniakea/tgapi"
|
||||||
@@ -34,3 +36,20 @@ func TestMsgContextNewDraftWorksWithoutLimiter(t *testing.T) {
|
|||||||
t.Fatalf("unexpected chat id: %d", draft.chatID)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
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 = 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 = errors.New("can't find session for this context")
|
||||||
|
ErrSceneNotFound = errors.New("scene not found")
|
||||||
|
ErrSceneStepNotFound = errors.New("scene step not found")
|
||||||
|
ErrSceneCommandNotFound = errors.New("scene command not found")
|
||||||
|
ErrNotInScene = errors.New("not in scene")
|
||||||
|
)
|
||||||
|
|
||||||
|
func validateMessageText(text string) error {
|
||||||
|
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
|
||||||
|
}
|
||||||
+81
-35
@@ -1,6 +1,7 @@
|
|||||||
package laniakea
|
package laniakea
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
@@ -13,36 +14,50 @@ import (
|
|||||||
// ErrInvalidPayloadType is returned when callback payload encoding type is unknown.
|
// ErrInvalidPayloadType is returned when callback payload encoding type is unknown.
|
||||||
var ErrInvalidPayloadType = errors.New("invalid payload type")
|
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() {
|
defer func() {
|
||||||
if r := recover(); r != nil {
|
if r := recover(); r != nil {
|
||||||
bot.logger.Errorln(fmt.Sprintf("panic in handle: %v", r))
|
bot.logger.Errorln(fmt.Sprintf("panic in handle: %v", r))
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
ctx := &MsgContext{
|
ctx, cancel := context.WithCancel(parentCtx)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
msgCtx := &MsgContext{
|
||||||
Update: *u, Api: bot.api,
|
Update: *u, Api: bot.api,
|
||||||
Logger: bot.logger,
|
Logger: bot.logger,
|
||||||
errorTemplate: bot.errorTemplate,
|
errorTemplate: bot.errorTemplate,
|
||||||
l10n: bot.l10n,
|
l10n: bot.l10n,
|
||||||
draftProvider: bot.draftProvider,
|
draftProvider: bot.draftProvider,
|
||||||
|
sceneRuntime: bot,
|
||||||
payloadType: bot.payloadType,
|
payloadType: bot.payloadType,
|
||||||
|
ctx: ctx,
|
||||||
}
|
}
|
||||||
bot.prepareUpdateCtx(u, ctx)
|
bot.prepareUpdateCtx(u, msgCtx)
|
||||||
|
|
||||||
for _, middleware := range bot.middlewares {
|
for _, middleware := range bot.middlewares {
|
||||||
if !middleware.Execute(ctx, bot.dbContext) {
|
if !middleware.Execute(msgCtx, bot.dbContext) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sceneHandled, err := bot.tryHandleScene(msgCtx)
|
||||||
|
if err != nil {
|
||||||
|
bot.logger.Errorln(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if sceneHandled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
switch u.Type {
|
switch u.Type {
|
||||||
case tgapi.UpdateTypeMessage, tgapi.UpdateTypeChannelPost:
|
case tgapi.UpdateTypeMessage, tgapi.UpdateTypeChannelPost:
|
||||||
bot.handleMessage(u, ctx)
|
bot.handleMessage(u, msgCtx)
|
||||||
case tgapi.UpdateTypeCallbackQuery:
|
case tgapi.UpdateTypeCallbackQuery:
|
||||||
bot.handleCallback(u, ctx)
|
bot.handleCallback(u, msgCtx)
|
||||||
default:
|
default:
|
||||||
bot.handleUpdate(u, ctx)
|
bot.handleUpdate(u, msgCtx)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,30 +80,11 @@ func (bot *Bot[T]) handleMessage(update *tgapi.Update, ctx *MsgContext) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
text = strings.TrimSpace(text)
|
prefix, cmd, args := bot.parseCommand(text)
|
||||||
prefix, hasPrefix := bot.checkPrefixes(text)
|
if cmd == "" {
|
||||||
if !hasPrefix {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.Prefix = prefix
|
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, "@") {
|
if strings.Contains(cmd, "@") {
|
||||||
botUsername := bot.username
|
botUsername := bot.username
|
||||||
@@ -156,7 +152,9 @@ func (bot *Bot[T]) handleUpdate(u *tgapi.Update, ctx *MsgContext) {
|
|||||||
if !plugin.executeMiddlewares(pluginCtx, bot.dbContext) {
|
if !plugin.executeMiddlewares(pluginCtx, bot.dbContext) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
handler(pluginCtx, bot.dbContext)
|
if err := handler(pluginCtx, bot.dbContext); err != nil {
|
||||||
|
pluginCtx.error(err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,12 +265,14 @@ func (bot *Bot[T]) prepareUpdateCtx(u *tgapi.Update, ctx *MsgContext) {
|
|||||||
ctx.From = from
|
ctx.From = from
|
||||||
ctx.FromID = from.ID
|
ctx.FromID = from.ID
|
||||||
}
|
}
|
||||||
ctx.Update = *u
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (bot *Bot[T]) checkPrefixes(text string) (string, bool) {
|
func (bot *Bot[T]) checkPrefixes(text string) (string, bool) {
|
||||||
for _, prefix := range bot.prefixes {
|
for _, prefix := range bot.prefixes {
|
||||||
if prefix == "" {
|
if prefix == "" {
|
||||||
|
if bot.logger != nil {
|
||||||
|
bot.logger.Warnln("empty prefix is not allowed")
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if strings.HasPrefix(text, prefix) {
|
if strings.HasPrefix(text, prefix) {
|
||||||
@@ -281,6 +281,23 @@ func (bot *Bot[T]) checkPrefixes(text string) (string, bool) {
|
|||||||
}
|
}
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
|
func (bot *Bot[T]) parseCommand(text string) (prefix, cmd, args string) {
|
||||||
|
if prefix, hasPrefix := bot.checkPrefixes(text); hasPrefix {
|
||||||
|
text = strings.TrimSpace(text[len(prefix):])
|
||||||
|
spaceIndex := strings.Index(text, " ")
|
||||||
|
var cmd string
|
||||||
|
var args string
|
||||||
|
if spaceIndex == -1 {
|
||||||
|
cmd = text
|
||||||
|
args = ""
|
||||||
|
} else {
|
||||||
|
cmd = text[:spaceIndex]
|
||||||
|
args = strings.TrimSpace(text[spaceIndex:])
|
||||||
|
}
|
||||||
|
return prefix, cmd, args
|
||||||
|
}
|
||||||
|
return "", "", ""
|
||||||
|
}
|
||||||
|
|
||||||
func encodeJsonPayload(d CallbackData) (string, error) {
|
func encodeJsonPayload(d CallbackData) (string, error) {
|
||||||
b, err := json.Marshal(d)
|
b, err := json.Marshal(d)
|
||||||
@@ -320,19 +337,48 @@ func decodeBase64Payload(s string) (CallbackData, error) {
|
|||||||
}
|
}
|
||||||
return decodeJsonPayload(string(b))
|
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 {
|
switch payloadType {
|
||||||
case BotPayloadBase64:
|
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:
|
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) {
|
// func (bot *Bot[T]) encodePayload(d CallbackData) (string, error) {
|
||||||
// return encodePayload(bot.payloadType, d)
|
// return encodePayload(bot.payloadType, d)
|
||||||
// }
|
// }
|
||||||
func (bot *Bot[T]) decodePayload(s string) (CallbackData, error) {
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
+96
-9
@@ -1,6 +1,7 @@
|
|||||||
package laniakea
|
package laniakea
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.nix13.pw/scuroneko/laniakea/tgapi"
|
"git.nix13.pw/scuroneko/laniakea/tgapi"
|
||||||
@@ -35,7 +36,7 @@ func TestBotMiddlewareReceivesLogger(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
bot.handle(&tgapi.Update{
|
bot.handle(context.Background(), &tgapi.Update{
|
||||||
UpdateID: 1,
|
UpdateID: 1,
|
||||||
Type: tgapi.UpdateTypePoll,
|
Type: tgapi.UpdateTypePoll,
|
||||||
Poll: &tgapi.Poll{
|
Poll: &tgapi.Poll{
|
||||||
@@ -51,7 +52,7 @@ func TestBotMiddlewareReceivesLogger(t *testing.T) {
|
|||||||
|
|
||||||
func TestAddUpdateHandlerRejectsReservedUpdateTypes(t *testing.T) {
|
func TestAddUpdateHandlerRejectsReservedUpdateTypes(t *testing.T) {
|
||||||
plugin := NewPlugin[NoDB]("test")
|
plugin := NewPlugin[NoDB]("test")
|
||||||
handler := func(ctx *MsgContext, db NoDB) {}
|
handler := func(ctx *MsgContext, db NoDB) error { return nil }
|
||||||
|
|
||||||
for _, updateType := range []tgapi.UpdateType{
|
for _, updateType := range []tgapi.UpdateType{
|
||||||
tgapi.UpdateTypeMessage,
|
tgapi.UpdateTypeMessage,
|
||||||
@@ -110,7 +111,7 @@ func TestHandleUpdateHandlersPopulateFromContext(t *testing.T) {
|
|||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
called := false
|
called := false
|
||||||
plugin := NewPlugin[NoDB]("test").AddUpdateHandler(tt.update.Type, func(ctx *MsgContext, db NoDB) {
|
plugin := NewPlugin[NoDB]("test").AddUpdateHandler(tt.update.Type, func(ctx *MsgContext, db NoDB) error {
|
||||||
called = true
|
called = true
|
||||||
if ctx.Update.UpdateID != tt.update.UpdateID {
|
if ctx.Update.UpdateID != tt.update.UpdateID {
|
||||||
t.Fatalf("unexpected update in context: got %d want %d", ctx.Update.UpdateID, tt.update.UpdateID)
|
t.Fatalf("unexpected update in context: got %d want %d", ctx.Update.UpdateID, tt.update.UpdateID)
|
||||||
@@ -127,6 +128,7 @@ func TestHandleUpdateHandlersPopulateFromContext(t *testing.T) {
|
|||||||
if ctx.Msg != nil {
|
if ctx.Msg != nil {
|
||||||
t.Fatalf("did not expect message context for %s", tt.name)
|
t.Fatalf("did not expect message context for %s", tt.name)
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
bot := &Bot[NoDB]{
|
bot := &Bot[NoDB]{
|
||||||
@@ -134,7 +136,7 @@ func TestHandleUpdateHandlersPopulateFromContext(t *testing.T) {
|
|||||||
plugins: []Plugin[NoDB]{clonePlugin(plugin)},
|
plugins: []Plugin[NoDB]{clonePlugin(plugin)},
|
||||||
}
|
}
|
||||||
|
|
||||||
bot.handle(tt.update)
|
bot.handle(context.Background(), tt.update)
|
||||||
|
|
||||||
if !called {
|
if !called {
|
||||||
t.Fatalf("expected update handler for %s to be called", tt.name)
|
t.Fatalf("expected update handler for %s to be called", tt.name)
|
||||||
@@ -147,7 +149,7 @@ func TestHandleUpdateHandlersReceiveIsolatedContexts(t *testing.T) {
|
|||||||
firstCalled := false
|
firstCalled := false
|
||||||
secondCalled := false
|
secondCalled := false
|
||||||
|
|
||||||
first := NewPlugin[NoDB]("first").AddUpdateHandler(tgapi.UpdateTypeInlineQuery, func(ctx *MsgContext, db NoDB) {
|
first := NewPlugin[NoDB]("first").AddUpdateHandler(tgapi.UpdateTypeInlineQuery, func(ctx *MsgContext, db NoDB) error {
|
||||||
firstCalled = true
|
firstCalled = true
|
||||||
if ctx.FromID != 41 {
|
if ctx.FromID != 41 {
|
||||||
t.Fatalf("unexpected FromID in first handler: got %d want 41", ctx.FromID)
|
t.Fatalf("unexpected FromID in first handler: got %d want 41", ctx.FromID)
|
||||||
@@ -156,8 +158,9 @@ func TestHandleUpdateHandlersReceiveIsolatedContexts(t *testing.T) {
|
|||||||
ctx.FromID = 999
|
ctx.FromID = 999
|
||||||
ctx.Text = "mutated"
|
ctx.Text = "mutated"
|
||||||
ctx.Args = []string{"mutated"}
|
ctx.Args = []string{"mutated"}
|
||||||
|
return nil
|
||||||
})
|
})
|
||||||
second := NewPlugin[NoDB]("second").AddUpdateHandler(tgapi.UpdateTypeInlineQuery, func(ctx *MsgContext, db NoDB) {
|
second := NewPlugin[NoDB]("second").AddUpdateHandler(tgapi.UpdateTypeInlineQuery, func(ctx *MsgContext, db NoDB) error {
|
||||||
secondCalled = true
|
secondCalled = true
|
||||||
if ctx.From == nil {
|
if ctx.From == nil {
|
||||||
t.Fatal("expected ctx.From to remain populated for second handler")
|
t.Fatal("expected ctx.From to remain populated for second handler")
|
||||||
@@ -171,6 +174,7 @@ func TestHandleUpdateHandlersReceiveIsolatedContexts(t *testing.T) {
|
|||||||
if len(ctx.Args) != 0 {
|
if len(ctx.Args) != 0 {
|
||||||
t.Fatalf("unexpected leaked Args in second handler: %v", ctx.Args)
|
t.Fatalf("unexpected leaked Args in second handler: %v", ctx.Args)
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
bot := &Bot[NoDB]{
|
bot := &Bot[NoDB]{
|
||||||
@@ -181,7 +185,7 @@ func TestHandleUpdateHandlersReceiveIsolatedContexts(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
bot.handle(&tgapi.Update{
|
bot.handle(context.Background(), &tgapi.Update{
|
||||||
UpdateID: 3,
|
UpdateID: 3,
|
||||||
Type: tgapi.UpdateTypeInlineQuery,
|
Type: tgapi.UpdateTypeInlineQuery,
|
||||||
InlineQuery: &tgapi.InlineQuery{
|
InlineQuery: &tgapi.InlineQuery{
|
||||||
@@ -199,7 +203,7 @@ func TestHandleUpdateHandlersReceiveIsolatedContexts(t *testing.T) {
|
|||||||
func TestHandleChannelPostCommandWithSenderChat(t *testing.T) {
|
func TestHandleChannelPostCommandWithSenderChat(t *testing.T) {
|
||||||
called := false
|
called := false
|
||||||
plugin := NewPlugin[NoDB]("test")
|
plugin := NewPlugin[NoDB]("test")
|
||||||
plugin.NewCommand(func(ctx *MsgContext, db NoDB) {
|
plugin.NewCommand(func(ctx *MsgContext, db NoDB) error {
|
||||||
called = true
|
called = true
|
||||||
if ctx.Msg == nil {
|
if ctx.Msg == nil {
|
||||||
t.Fatal("expected message context")
|
t.Fatal("expected message context")
|
||||||
@@ -213,6 +217,7 @@ func TestHandleChannelPostCommandWithSenderChat(t *testing.T) {
|
|||||||
if ctx.FromID != 0 {
|
if ctx.FromID != 0 {
|
||||||
t.Fatalf("expected zero FromID for sender_chat updates, got %d", ctx.FromID)
|
t.Fatalf("expected zero FromID for sender_chat updates, got %d", ctx.FromID)
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
}, "ping")
|
}, "ping")
|
||||||
|
|
||||||
bot := &Bot[NoDB]{
|
bot := &Bot[NoDB]{
|
||||||
@@ -221,7 +226,7 @@ func TestHandleChannelPostCommandWithSenderChat(t *testing.T) {
|
|||||||
plugins: []Plugin[NoDB]{clonePlugin(plugin)},
|
plugins: []Plugin[NoDB]{clonePlugin(plugin)},
|
||||||
}
|
}
|
||||||
|
|
||||||
bot.handle(&tgapi.Update{
|
bot.handle(context.Background(), &tgapi.Update{
|
||||||
UpdateID: 10,
|
UpdateID: 10,
|
||||||
Type: tgapi.UpdateTypeChannelPost,
|
Type: tgapi.UpdateTypeChannelPost,
|
||||||
ChannelPost: &tgapi.Message{
|
ChannelPost: &tgapi.Message{
|
||||||
@@ -236,3 +241,85 @@ func TestHandleChannelPostCommandWithSenderChat(t *testing.T) {
|
|||||||
t.Fatal("expected channel post command handler to be called")
|
t.Fatal("expected channel post command handler to be called")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCommandHandlerBindArgsEndToEnd(t *testing.T) {
|
||||||
|
type banInput struct {
|
||||||
|
UserID int
|
||||||
|
Reason string
|
||||||
|
}
|
||||||
|
|
||||||
|
var got banInput
|
||||||
|
plugin := NewPlugin[NoDB]("test")
|
||||||
|
plugin.NewCommand(func(ctx *MsgContext, db NoDB) error {
|
||||||
|
return ctx.BindArgs(&got)
|
||||||
|
}, "ban",
|
||||||
|
NewCommandArg("user_id").SetValueType(CommandValueIntType).SetRequired(),
|
||||||
|
NewCommandArg("reason").SetRequired(),
|
||||||
|
)
|
||||||
|
|
||||||
|
bot := &Bot[NoDB]{
|
||||||
|
logger: slog.CreateLogger(),
|
||||||
|
prefixes: []string{"/"},
|
||||||
|
plugins: []Plugin[NoDB]{clonePlugin(plugin)},
|
||||||
|
}
|
||||||
|
|
||||||
|
bot.handle(context.Background(), &tgapi.Update{
|
||||||
|
UpdateID: 11,
|
||||||
|
Type: tgapi.UpdateTypeMessage,
|
||||||
|
Message: &tgapi.Message{
|
||||||
|
MessageID: 1,
|
||||||
|
Text: "/ban 42 too loud",
|
||||||
|
Chat: &tgapi.Chat{ID: 99, Type: string(tgapi.ChatTypePrivate)},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
want := banInput{UserID: 42, Reason: "too loud"}
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("unexpected bound input: got %#v want %#v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPayloadHandlerBindArgsEndToEnd(t *testing.T) {
|
||||||
|
type payloadInput struct {
|
||||||
|
ID int
|
||||||
|
Note string
|
||||||
|
}
|
||||||
|
|
||||||
|
var got payloadInput
|
||||||
|
plugin := NewPlugin[NoDB]("test")
|
||||||
|
plugin.NewPayload(func(ctx *MsgContext, db NoDB) error {
|
||||||
|
return ctx.BindArgs(&got)
|
||||||
|
}, "approve",
|
||||||
|
NewCommandArg("id").SetValueType(CommandValueIntType).SetRequired(),
|
||||||
|
NewCommandArg("note").SetRequired(),
|
||||||
|
)
|
||||||
|
|
||||||
|
bot := &Bot[NoDB]{
|
||||||
|
logger: slog.CreateLogger(),
|
||||||
|
payloadType: BotPayloadJson,
|
||||||
|
plugins: []Plugin[NoDB]{clonePlugin(plugin)},
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := encodeJsonPayload(CallbackData{
|
||||||
|
Command: "approve",
|
||||||
|
Args: []string{"7", "looks", "good"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("encodeJsonPayload returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
bot.handle(context.Background(), &tgapi.Update{
|
||||||
|
UpdateID: 12,
|
||||||
|
Type: tgapi.UpdateTypeCallbackQuery,
|
||||||
|
CallbackQuery: &tgapi.CallbackQuery{
|
||||||
|
ID: "cb-1",
|
||||||
|
Data: data,
|
||||||
|
From: tgapi.User{ID: 1},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
want := payloadInput{ID: 7, Note: "looks good"}
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("unexpected bound payload input: got %#v want %#v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+10
-2
@@ -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.
|
// 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 {
|
func (in *InlineKeyboard) SetPayloadType(t BotPayloadType) *InlineKeyboard {
|
||||||
in.payloadType = t
|
in.payloadType = t
|
||||||
return in
|
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.
|
// Internal helper that appends a button and auto-flushes a full row.
|
||||||
func (in *InlineKeyboard) append(button tgapi.InlineKeyboardButton) *InlineKeyboard {
|
func (in *InlineKeyboard) append(button tgapi.InlineKeyboardButton) *InlineKeyboard {
|
||||||
if in.CurrentLine.Len() == in.maxRow {
|
if in.CurrentLine.Len() == in.maxRow {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package laniakea
|
package laniakea
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
@@ -42,3 +44,54 @@ func TestInlineKeyboardBuilderPreservesConfiguredButtonFields(t *testing.T) {
|
|||||||
t.Fatalf("unexpected url: %q", button.URL)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+259
-19
@@ -2,7 +2,11 @@ package laniakea
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"reflect"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.nix13.pw/scuroneko/laniakea/tgapi"
|
"git.nix13.pw/scuroneko/laniakea/tgapi"
|
||||||
@@ -35,6 +39,9 @@ type MsgContext struct {
|
|||||||
l10n *L10n
|
l10n *L10n
|
||||||
draftProvider *DraftProvider
|
draftProvider *DraftProvider
|
||||||
payloadType BotPayloadType
|
payloadType BotPayloadType
|
||||||
|
sceneRuntime sceneRuntime
|
||||||
|
|
||||||
|
ctx context.Context
|
||||||
}
|
}
|
||||||
|
|
||||||
// AnswerMessage represents a message sent or edited via MsgContext.
|
// AnswerMessage represents a message sent or edited via MsgContext.
|
||||||
@@ -48,6 +55,10 @@ type AnswerMessage struct {
|
|||||||
|
|
||||||
// Internal helper for text edits with optional keyboard and parse mode.
|
// 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 {
|
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{
|
params := tgapi.EditMessageTextP{
|
||||||
Text: text,
|
Text: text,
|
||||||
ParseMode: parseMode,
|
ParseMode: parseMode,
|
||||||
@@ -59,13 +70,13 @@ func (ctx *MsgContext) edit(messageId int, text string, keyboard *InlineKeyboard
|
|||||||
case ctx.InlineMsgId != "":
|
case ctx.InlineMsgId != "":
|
||||||
params.InlineMessageID = ctx.InlineMsgId
|
params.InlineMessageID = ctx.InlineMsgId
|
||||||
default:
|
default:
|
||||||
ctx.Logger.Errorln("Can't edit message: no valid message target")
|
ctx.Logger.Errorln(ErrEditTargetMissing)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if keyboard != nil {
|
if keyboard != nil {
|
||||||
params.ReplyMarkup = keyboard.Get()
|
params.ReplyMarkup = keyboard.Get()
|
||||||
}
|
}
|
||||||
msg, _, err := ctx.Api.EditMessageText(params)
|
msg, _, err := ctx.Api.EditMessageTextWithContext(ctx.Context(), params)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.Logger.Errorln(err)
|
ctx.Logger.Errorln(err)
|
||||||
return nil
|
return nil
|
||||||
@@ -96,7 +107,7 @@ func (m *AnswerMessage) EditMarkdown(text string) *AnswerMessage {
|
|||||||
// Internal helper for editing callback-linked messages.
|
// Internal helper for editing callback-linked messages.
|
||||||
func (ctx *MsgContext) editCallback(text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage {
|
func (ctx *MsgContext) editCallback(text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage {
|
||||||
if ctx.CallbackMsgId == 0 && ctx.InlineMsgId == "" {
|
if ctx.CallbackMsgId == 0 && ctx.InlineMsgId == "" {
|
||||||
ctx.Logger.Errorln("Can't edit non-callback update message")
|
ctx.Logger.Errorln(ErrCallbackMessageMissing)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return ctx.edit(ctx.CallbackMsgId, text, keyboard, parseMode)
|
return ctx.edit(ctx.CallbackMsgId, text, keyboard, parseMode)
|
||||||
@@ -128,6 +139,10 @@ func (ctx *MsgContext) EditCallbackfMarkdown(format string, keyboard *InlineKeyb
|
|||||||
|
|
||||||
// Internal helper for media-caption edits.
|
// Internal helper for media-caption edits.
|
||||||
func (ctx *MsgContext) editPhotoText(messageId int, text string, kb *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage {
|
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{
|
params := tgapi.EditMessageCaptionP{
|
||||||
Caption: text,
|
Caption: text,
|
||||||
ParseMode: parseMode,
|
ParseMode: parseMode,
|
||||||
@@ -139,14 +154,14 @@ func (ctx *MsgContext) editPhotoText(messageId int, text string, kb *InlineKeybo
|
|||||||
case ctx.InlineMsgId != "":
|
case ctx.InlineMsgId != "":
|
||||||
params.InlineMessageID = ctx.InlineMsgId
|
params.InlineMessageID = ctx.InlineMsgId
|
||||||
default:
|
default:
|
||||||
ctx.Logger.Errorln("Can't edit caption: no valid message target")
|
ctx.Logger.Errorln(ErrEditTargetMissing)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if kb != nil {
|
if kb != nil {
|
||||||
params.ReplyMarkup = kb.Get()
|
params.ReplyMarkup = kb.Get()
|
||||||
}
|
}
|
||||||
|
|
||||||
msg, _, err := ctx.Api.EditMessageCaption(params)
|
msg, _, err := ctx.Api.EditMessageCaptionWithContext(ctx.Context(), params)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.Logger.Errorln(err)
|
ctx.Logger.Errorln(err)
|
||||||
return nil
|
return nil
|
||||||
@@ -187,7 +202,11 @@ func (m *AnswerMessage) EditCaptionKeyboardMarkdown(text string, kb *InlineKeybo
|
|||||||
// Internal helper for message replies with optional keyboard and parse mode.
|
// Internal helper for message replies with optional keyboard and parse mode.
|
||||||
func (ctx *MsgContext) answer(text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage {
|
func (ctx *MsgContext) answer(text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage {
|
||||||
if ctx.Msg == nil {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
params := tgapi.SendMessageP{
|
params := tgapi.SendMessageP{
|
||||||
@@ -205,7 +224,7 @@ func (ctx *MsgContext) answer(text string, keyboard *InlineKeyboard, parseMode t
|
|||||||
params.DirectMessagesTopicID = ctx.Msg.DirectMessageTopic.TopicID
|
params.DirectMessagesTopicID = ctx.Msg.DirectMessageTopic.TopicID
|
||||||
}
|
}
|
||||||
|
|
||||||
msg, err := ctx.Api.SendMessage(params)
|
msg, err := ctx.Api.SendMessageWithContext(ctx.Context(), params)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.Logger.Errorln(err)
|
ctx.Logger.Errorln(err)
|
||||||
return nil
|
return nil
|
||||||
@@ -220,6 +239,14 @@ func (ctx *MsgContext) Answer(text string) *AnswerMessage {
|
|||||||
return ctx.answer(text, nil, tgapi.ParseNone)
|
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.
|
// AnswerMarkdown sends a message using MarkdownV2 formatting.
|
||||||
//
|
//
|
||||||
// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here.
|
// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here.
|
||||||
@@ -232,6 +259,11 @@ func (ctx *MsgContext) Answerf(template string, args ...any) *AnswerMessage {
|
|||||||
return ctx.answer(fmt.Sprintf(template, args...), nil, tgapi.ParseNone)
|
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.
|
// AnswerfMarkdown formats a string using fmt.Sprintf and sends it using MarkdownV2.
|
||||||
//
|
//
|
||||||
// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here.
|
// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here.
|
||||||
@@ -244,6 +276,13 @@ func (ctx *MsgContext) Keyboard(text string, kb *InlineKeyboard) *AnswerMessage
|
|||||||
return ctx.answer(text, kb, tgapi.ParseNone)
|
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.
|
// KeyboardMarkdown sends a message with an inline keyboard using MarkdownV2.
|
||||||
//
|
//
|
||||||
// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here.
|
// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here.
|
||||||
@@ -251,10 +290,53 @@ func (ctx *MsgContext) KeyboardMarkdown(text string, keyboard *InlineKeyboard) *
|
|||||||
return ctx.answer(text, keyboard, tgapi.ParseMDV2)
|
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.
|
// Internal helper for photo replies with optional caption and keyboard.
|
||||||
func (ctx *MsgContext) answerPhoto(photoId, text string, kb *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage {
|
func (ctx *MsgContext) answerPhoto(photoId, text string, kb *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage {
|
||||||
if ctx.Msg == nil {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
params := tgapi.SendPhotoP{
|
params := tgapi.SendPhotoP{
|
||||||
@@ -273,7 +355,7 @@ func (ctx *MsgContext) answerPhoto(photoId, text string, kb *InlineKeyboard, par
|
|||||||
params.DirectMessagesTopicID = int(ctx.Msg.DirectMessageTopic.TopicID)
|
params.DirectMessagesTopicID = int(ctx.Msg.DirectMessageTopic.TopicID)
|
||||||
}
|
}
|
||||||
|
|
||||||
msg, err := ctx.Api.SendPhoto(params)
|
msg, err := ctx.Api.SendPhotoWithContext(ctx.Context(), params)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.Logger.Errorln(err)
|
ctx.Logger.Errorln(err)
|
||||||
return nil
|
return nil
|
||||||
@@ -322,14 +404,14 @@ func (ctx *MsgContext) AnswerPhotofMarkdown(photoId, template string, args ...an
|
|||||||
// Internal helper that deletes a message by ID.
|
// Internal helper that deletes a message by ID.
|
||||||
func (ctx *MsgContext) delete(messageId int) {
|
func (ctx *MsgContext) delete(messageId int) {
|
||||||
if messageId == 0 {
|
if messageId == 0 {
|
||||||
ctx.Logger.Errorln("Can't delete message: message ID zero")
|
ctx.Logger.Errorln(ErrMessageIDZero)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if ctx.Msg == nil {
|
if ctx.Msg == nil {
|
||||||
ctx.Logger.Errorln("Can't delete message: no chat message context")
|
ctx.Logger.Errorln(ErrMessageContextNil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_, err := ctx.Api.DeleteMessage(tgapi.DeleteMessageP{
|
_, err := ctx.Api.DeleteMessageWithContext(ctx.Context(), tgapi.DeleteMessageP{
|
||||||
ChatID: ctx.Msg.Chat.ID,
|
ChatID: ctx.Msg.Chat.ID,
|
||||||
MessageID: messageId,
|
MessageID: messageId,
|
||||||
})
|
})
|
||||||
@@ -344,7 +426,7 @@ func (m *AnswerMessage) Delete() { m.ctx.delete(m.MessageID) }
|
|||||||
// CallbackDelete deletes the message that triggered the callback query.
|
// CallbackDelete deletes the message that triggered the callback query.
|
||||||
func (ctx *MsgContext) CallbackDelete() {
|
func (ctx *MsgContext) CallbackDelete() {
|
||||||
if ctx.CallbackMsgId == 0 {
|
if ctx.CallbackMsgId == 0 {
|
||||||
ctx.Logger.Errorln("Can't delete callback message: no callback message ID")
|
ctx.Logger.Errorln(ErrCallbackMessageMissing)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ctx.delete(ctx.CallbackMsgId)
|
ctx.delete(ctx.CallbackMsgId)
|
||||||
@@ -355,7 +437,7 @@ func (ctx *MsgContext) answerCallbackQuery(url, text string, showAlert bool) {
|
|||||||
if len(ctx.CallbackQueryId) == 0 {
|
if len(ctx.CallbackQueryId) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_, err := ctx.Api.AnswerCallbackQuery(tgapi.AnswerCallbackQueryP{
|
_, err := ctx.Api.AnswerCallbackQueryWithContext(ctx.Context(), tgapi.AnswerCallbackQueryP{
|
||||||
CallbackQueryID: ctx.CallbackQueryId,
|
CallbackQueryID: ctx.CallbackQueryId,
|
||||||
Text: text, ShowAlert: showAlert, URL: url,
|
Text: text, ShowAlert: showAlert, URL: url,
|
||||||
})
|
})
|
||||||
@@ -388,7 +470,7 @@ func (ctx *MsgContext) SendAction(action tgapi.ChatActionType) {
|
|||||||
if ctx.Msg.MessageThreadID > 0 {
|
if ctx.Msg.MessageThreadID > 0 {
|
||||||
params.MessageThreadID = ctx.Msg.MessageThreadID
|
params.MessageThreadID = ctx.Msg.MessageThreadID
|
||||||
}
|
}
|
||||||
_, err := ctx.Api.SendChatAction(params)
|
_, err := ctx.Api.SendChatActionWithContext(ctx.Context(), params)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.Logger.Errorln(err)
|
ctx.Logger.Errorln(err)
|
||||||
}
|
}
|
||||||
@@ -411,20 +493,20 @@ func (ctx *MsgContext) Error(err error) { ctx.error(err) }
|
|||||||
|
|
||||||
func (ctx *MsgContext) newDraft(parseMode tgapi.ParseMode) *Draft {
|
func (ctx *MsgContext) newDraft(parseMode tgapi.ParseMode) *Draft {
|
||||||
if ctx.Msg == nil {
|
if ctx.Msg == nil {
|
||||||
ctx.Logger.Errorln("can't create draft: ctx.Msg is nil")
|
ctx.Logger.Errorln(ErrMessageContextNil)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if ctx.Api == nil {
|
if ctx.Api == nil {
|
||||||
ctx.Logger.Errorln("can't create draft: ctx.Api is nil")
|
ctx.Logger.Errorln(ErrAPIIsNil)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if ctx.draftProvider == nil {
|
if ctx.draftProvider == nil {
|
||||||
ctx.Logger.Errorln("can't create draft: ctx.draftProvider is nil")
|
ctx.Logger.Errorln(ErrDraftProviderNil)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if ctx.Api.Limiter != 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()
|
defer cancel()
|
||||||
if err := ctx.Api.Limiter.Wait(c, ctx.Msg.Chat.ID); err != nil {
|
if err := ctx.Api.Limiter.Wait(c, ctx.Msg.Chat.ID); err != nil {
|
||||||
ctx.Logger.Errorln(err)
|
ctx.Logger.Errorln(err)
|
||||||
@@ -464,3 +546,161 @@ func (ctx *MsgContext) Translate(key string) string {
|
|||||||
func (ctx *MsgContext) NewInlineKeyboard(maxRow int) *InlineKeyboard {
|
func (ctx *MsgContext) NewInlineKeyboard(maxRow int) *InlineKeyboard {
|
||||||
return NewInlineKeyboard(ctx.payloadType, maxRow)
|
return NewInlineKeyboard(ctx.payloadType, maxRow)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func bindPositional(args []string, dst any) error {
|
||||||
|
v := reflect.ValueOf(dst)
|
||||||
|
if v.Kind() != reflect.Pointer || v.IsNil() {
|
||||||
|
return ErrBindArgsTargetNotPointer
|
||||||
|
}
|
||||||
|
|
||||||
|
v = v.Elem()
|
||||||
|
if v.Kind() != reflect.Struct {
|
||||||
|
return ErrBindArgsTargetNotStruct
|
||||||
|
}
|
||||||
|
|
||||||
|
t := v.Type()
|
||||||
|
fields := make([]int, 0, v.NumField())
|
||||||
|
|
||||||
|
for i := 0; i < v.NumField(); i++ {
|
||||||
|
field := v.Field(i)
|
||||||
|
if !field.CanSet() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fields = append(fields, i)
|
||||||
|
}
|
||||||
|
|
||||||
|
argIndex := 0
|
||||||
|
for fieldPos, fieldIndex := range fields {
|
||||||
|
field := v.Field(fieldIndex)
|
||||||
|
fieldType := t.Field(fieldIndex)
|
||||||
|
|
||||||
|
if argIndex >= len(args) {
|
||||||
|
// Leave trailing fields at their zero values when arguments run out.
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
isLastBindableField := fieldPos == len(fields)-1
|
||||||
|
|
||||||
|
raw := args[argIndex]
|
||||||
|
if isLastBindableField && field.Kind() == reflect.String {
|
||||||
|
raw = strings.Join(args[argIndex:], " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
switch field.Kind() {
|
||||||
|
case reflect.String:
|
||||||
|
field.SetString(raw)
|
||||||
|
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||||
|
n, err := strconv.ParseInt(raw, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%w: field %s: %v", ErrBindArgsConversion, fieldType.Name, err)
|
||||||
|
}
|
||||||
|
field.SetInt(n)
|
||||||
|
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
||||||
|
n, err := strconv.ParseUint(raw, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%w: field %s: %v", ErrBindArgsConversion, fieldType.Name, err)
|
||||||
|
}
|
||||||
|
field.SetUint(n)
|
||||||
|
case reflect.Float32, reflect.Float64:
|
||||||
|
f, err := strconv.ParseFloat(raw, 64)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%w: field %s: %v", ErrBindArgsConversion, fieldType.Name, err)
|
||||||
|
}
|
||||||
|
field.SetFloat(f)
|
||||||
|
case reflect.Bool:
|
||||||
|
b, err := strconv.ParseBool(raw)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%w: field %s: %v", ErrBindArgsConversion, fieldType.Name, err)
|
||||||
|
}
|
||||||
|
field.SetBool(b)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("%w: field %s: %s", ErrBindArgsUnsupportedFieldType, fieldType.Name, field.Kind())
|
||||||
|
}
|
||||||
|
|
||||||
|
if isLastBindableField && field.Kind() == reflect.String {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
argIndex++
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BindArgs binds positional command arguments from ctx.Args into dst.
|
||||||
|
//
|
||||||
|
// Exported struct fields are filled in declaration order. When fewer arguments
|
||||||
|
// are provided than fields, the remaining fields keep their zero values. If the
|
||||||
|
// final bindable field is a string, it receives the remaining arguments joined
|
||||||
|
// with spaces.
|
||||||
|
func (ctx *MsgContext) BindArgs(dst any) error {
|
||||||
|
return bindPositional(ctx.Args, dst)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Context returns the request-scoped context associated with the current update.
|
||||||
|
func (ctx *MsgContext) Context() context.Context {
|
||||||
|
if ctx.ctx == nil {
|
||||||
|
return context.Background()
|
||||||
|
}
|
||||||
|
return ctx.ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ctx *MsgContext) EnterScene(name string) error {
|
||||||
|
scene, ok := ctx.sceneRuntime.FindScene(name)
|
||||||
|
if !ok {
|
||||||
|
return ErrSceneNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
key, ok := ctx.sceneRuntime.BuildSceneKey(scene.Scope, ctx)
|
||||||
|
if !ok {
|
||||||
|
return ErrCantFindSession
|
||||||
|
}
|
||||||
|
|
||||||
|
session := SceneSession{
|
||||||
|
Scene: scene.Name,
|
||||||
|
Step: scene.Entry,
|
||||||
|
}
|
||||||
|
|
||||||
|
return ctx.sceneRuntime.SetSession(key, session)
|
||||||
|
}
|
||||||
|
func (ctx *MsgContext) EnterSceneStep(name, step string) error {
|
||||||
|
scene, ok := ctx.sceneRuntime.FindScene(name)
|
||||||
|
if !ok {
|
||||||
|
return ErrSceneNotFound
|
||||||
|
}
|
||||||
|
if _, ok := scene.Steps[step]; !ok {
|
||||||
|
return ErrSceneStepNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
key, ok := ctx.sceneRuntime.BuildSceneKey(scene.Scope, ctx)
|
||||||
|
if !ok {
|
||||||
|
return ErrCantFindSession
|
||||||
|
}
|
||||||
|
|
||||||
|
session := SceneSession{
|
||||||
|
Scene: scene.Name,
|
||||||
|
Step: step,
|
||||||
|
}
|
||||||
|
|
||||||
|
return ctx.sceneRuntime.SetSession(key, session)
|
||||||
|
}
|
||||||
|
func (ctx *MsgContext) ExitScene() error {
|
||||||
|
_, session, err := ctx.sceneRuntime.FindSceneSession(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if session.Scene == "" {
|
||||||
|
return ErrNotInScene
|
||||||
|
}
|
||||||
|
|
||||||
|
scene, ok := ctx.sceneRuntime.FindScene(session.Scene)
|
||||||
|
if !ok {
|
||||||
|
return ErrSceneNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
key, ok := ctx.sceneRuntime.BuildSceneKey(scene.Scope, ctx)
|
||||||
|
if !ok {
|
||||||
|
return ErrCantFindSession
|
||||||
|
}
|
||||||
|
|
||||||
|
return ctx.sceneRuntime.DeleteSession(key)
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ package laniakea
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -62,3 +64,247 @@ func TestAnswerPhotoIncludesDirectMessagesTopicID(t *testing.T) {
|
|||||||
t.Fatalf("unexpected direct_messages_topic_id: %v", got)
|
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(¬Struct); !errors.Is(err, ErrBindArgsTargetNotStruct) {
|
||||||
|
t.Fatalf("expected ErrBindArgsTargetNotStruct for non-struct target, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBindArgsReportsConversionFailures(t *testing.T) {
|
||||||
|
type input struct {
|
||||||
|
ID int
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := &MsgContext{Args: []string{"oops"}}
|
||||||
|
var got input
|
||||||
|
|
||||||
|
err := ctx.BindArgs(&got)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected BindArgs to fail")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, ErrBindArgsConversion) {
|
||||||
|
t.Fatalf("expected ErrBindArgsConversion, got %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "field ID") {
|
||||||
|
t.Fatalf("expected field name in error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBindArgsRejectsUnsupportedFieldTypes(t *testing.T) {
|
||||||
|
type input struct {
|
||||||
|
Tags []string
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := &MsgContext{Args: []string{"tag"}}
|
||||||
|
var got input
|
||||||
|
|
||||||
|
err := ctx.BindArgs(&got)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected BindArgs to fail")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, ErrBindArgsUnsupportedFieldType) {
|
||||||
|
t.Fatalf("expected ErrBindArgsUnsupportedFieldType, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnswerRejectsEmptyMessage(t *testing.T) {
|
||||||
|
ctx := &MsgContext{
|
||||||
|
Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: string(tgapi.ChatTypePrivate)}},
|
||||||
|
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: string(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: string(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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+22
-3
@@ -81,7 +81,8 @@ func (c CommandArg) SetRequired() CommandArg {
|
|||||||
|
|
||||||
// CommandExecutor is the function type that executes a command.
|
// CommandExecutor is the function type that executes a command.
|
||||||
// It receives the message context and a database context (generic).
|
// It receives the message context and a database context (generic).
|
||||||
type CommandExecutor[T DbContext] func(ctx *MsgContext, dbContext T)
|
// Returning a non-nil error routes it through the bot's error handler.
|
||||||
|
type CommandExecutor[T DbContext] func(ctx *MsgContext, dbContext T) error
|
||||||
|
|
||||||
// Command represents a bot command with arguments, description, and executor.
|
// Command represents a bot command with arguments, description, and executor.
|
||||||
// Can be registered in a Plugin and optionally skipped from auto-generation.
|
// Can be registered in a Plugin and optionally skipped from auto-generation.
|
||||||
@@ -160,6 +161,7 @@ type Plugin[T DbContext] struct {
|
|||||||
name string // Name of the plugin (e.g., "admin", "user")
|
name string // Name of the plugin (e.g., "admin", "user")
|
||||||
commands map[string]*Command[T] // Registered commands (triggered by message)
|
commands map[string]*Command[T] // Registered commands (triggered by message)
|
||||||
payloads map[string]*Command[T] // Registered payloads (triggered by callback data)
|
payloads map[string]*Command[T] // Registered payloads (triggered by callback data)
|
||||||
|
scenes map[string]*Scene[T] // Optional scenes for multi-step interactions
|
||||||
middlewares extypes.Slice[Middleware[T]] // Shared middlewares for all commands/payloads
|
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
|
skipAutoCmd bool // If true, all commands in this plugin are excluded from auto-help
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
@@ -176,6 +178,7 @@ func NewPlugin[T DbContext](name string) *Plugin[T] {
|
|||||||
commands: make(map[string]*Command[T]),
|
commands: make(map[string]*Command[T]),
|
||||||
payloads: make(map[string]*Command[T]),
|
payloads: make(map[string]*Command[T]),
|
||||||
middlewares: make(extypes.Slice[Middleware[T]], 0),
|
middlewares: make(extypes.Slice[Middleware[T]], 0),
|
||||||
|
scenes: make(map[string]*Scene[T]),
|
||||||
skipAutoCmd: false,
|
skipAutoCmd: false,
|
||||||
logger: nil,
|
logger: nil,
|
||||||
handlers: make(map[tgapi.UpdateType]CommandExecutor[T]),
|
handlers: make(map[tgapi.UpdateType]CommandExecutor[T]),
|
||||||
@@ -212,6 +215,18 @@ func (p *Plugin[T]) NewPayload(exec CommandExecutor[T], command string, args ...
|
|||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p *Plugin[T]) AddScene(scene *Scene[T]) *Plugin[T] {
|
||||||
|
scene.PluginName = p.name
|
||||||
|
scene.setPluginName(p.name)
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
func (p *Plugin[T]) NewScene(name string) *Scene[T] {
|
||||||
|
scene := NewScene[T](name)
|
||||||
|
scene.setPluginName(p.name)
|
||||||
|
p.AddScene(scene)
|
||||||
|
return scene
|
||||||
|
}
|
||||||
|
|
||||||
// AddUpdateHandler registers a handler for a non-command update type.
|
// AddUpdateHandler registers a handler for a non-command update type.
|
||||||
// Message, channel post, and callback query updates stay on the command/payload flow.
|
// 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] {
|
func (p *Plugin[T]) AddUpdateHandler(t tgapi.UpdateType, handler CommandExecutor[T]) *Plugin[T] {
|
||||||
@@ -309,7 +324,9 @@ func (p *Plugin[T]) executeCmd(cmd string, ctx *MsgContext, db T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Execute command
|
// Execute command
|
||||||
command.exec(ctx, db)
|
if err := command.exec(ctx, db); err != nil {
|
||||||
|
ctx.error(err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Internal helper that validates and executes a payload handler.
|
// Internal helper that validates and executes a payload handler.
|
||||||
@@ -333,7 +350,9 @@ func (p *Plugin[T]) executePayload(payload string, ctx *MsgContext, db T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Execute payload
|
// Execute payload
|
||||||
command.exec(ctx, db)
|
if err := command.exec(ctx, db); err != nil {
|
||||||
|
ctx.error(err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Internal helper that runs plugin middlewares in order.
|
// Internal helper that runs plugin middlewares in order.
|
||||||
|
|||||||
+3
-3
@@ -6,7 +6,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestValidateArgsRequiresFullMatch(t *testing.T) {
|
func TestValidateArgsRequiresFullMatch(t *testing.T) {
|
||||||
intCmd := NewCommand[NoDB](func(ctx *MsgContext, db NoDB) {}, "int", NewCommandArg("n").SetValueType(CommandValueIntType).SetRequired())
|
intCmd := NewCommand[NoDB](func(ctx *MsgContext, db NoDB) error { return nil }, "int", NewCommandArg("n").SetValueType(CommandValueIntType).SetRequired())
|
||||||
if err := intCmd.validateArgs([]string{"123"}); err != nil {
|
if err := intCmd.validateArgs([]string{"123"}); err != nil {
|
||||||
t.Fatalf("expected valid integer argument, got %v", err)
|
t.Fatalf("expected valid integer argument, got %v", err)
|
||||||
}
|
}
|
||||||
@@ -14,7 +14,7 @@ func TestValidateArgsRequiresFullMatch(t *testing.T) {
|
|||||||
t.Fatalf("expected ErrCmdArgRegexpMismatch for partial int match, got %v", err)
|
t.Fatalf("expected ErrCmdArgRegexpMismatch for partial int match, got %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
boolCmd := NewCommand[NoDB](func(ctx *MsgContext, db NoDB) {}, "bool", NewCommandArg("flag").SetValueType(CommandValueBoolType).SetRequired())
|
boolCmd := NewCommand[NoDB](func(ctx *MsgContext, db NoDB) error { return nil }, "bool", NewCommandArg("flag").SetValueType(CommandValueBoolType).SetRequired())
|
||||||
if err := boolCmd.validateArgs([]string{"false"}); err != nil {
|
if err := boolCmd.validateArgs([]string{"false"}); err != nil {
|
||||||
t.Fatalf("expected valid bool argument, got %v", err)
|
t.Fatalf("expected valid bool argument, got %v", err)
|
||||||
}
|
}
|
||||||
@@ -25,7 +25,7 @@ func TestValidateArgsRequiresFullMatch(t *testing.T) {
|
|||||||
|
|
||||||
func TestValidateArgsEnforcesRequiredArgIndex(t *testing.T) {
|
func TestValidateArgsEnforcesRequiredArgIndex(t *testing.T) {
|
||||||
cmd := NewCommand[NoDB](
|
cmd := NewCommand[NoDB](
|
||||||
func(ctx *MsgContext, db NoDB) {},
|
func(ctx *MsgContext, db NoDB) error { return nil },
|
||||||
"mixed",
|
"mixed",
|
||||||
NewCommandArg("optional"),
|
NewCommandArg("optional"),
|
||||||
NewCommandArg("required").SetRequired(),
|
NewCommandArg("required").SetRequired(),
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
package laniakea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SceneHandler[T any] func(ctx *SceneContext, db T) (SceneResult, error)
|
||||||
|
type Scene[T any] struct {
|
||||||
|
Name string
|
||||||
|
Scope SceneScope
|
||||||
|
Entry string // starting step
|
||||||
|
PluginName string
|
||||||
|
|
||||||
|
steps map[string]SceneHandler[T]
|
||||||
|
commands map[string]SceneHandler[T]
|
||||||
|
message SceneHandler[T]
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewScene[T any](name string) *Scene[T] {
|
||||||
|
return &Scene[T]{
|
||||||
|
Name: name,
|
||||||
|
Scope: SceneScopeUserChat,
|
||||||
|
Entry: "",
|
||||||
|
steps: make(map[string]SceneHandler[T]),
|
||||||
|
commands: make(map[string]SceneHandler[T]),
|
||||||
|
message: nil,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func (s *Scene[T]) SetScope(scope SceneScope) *Scene[T] {
|
||||||
|
s.Scope = scope
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
func (s *Scene[T]) SetEntry(step string) *Scene[T] {
|
||||||
|
s.Entry = step
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
func (s *Scene[T]) setPluginName(name string) *Scene[T] {
|
||||||
|
s.PluginName = name
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Scene[T]) OnStep(step string, handler SceneHandler[T]) *Scene[T] {
|
||||||
|
s.steps[step] = handler
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
func (s *Scene[T]) OnCommand(cmd string, handler SceneHandler[T]) *Scene[T] {
|
||||||
|
s.commands[cmd] = handler
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
func (s *Scene[T]) OnMessage(handler SceneHandler[T]) *Scene[T] {
|
||||||
|
s.message = handler
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Scene[T]) executeCommand(cmd string, ctx *SceneContext, db T) (SceneResult, bool, error) {
|
||||||
|
handler, ok := s.commands[cmd]
|
||||||
|
if !ok {
|
||||||
|
return SceneResult{}, false, nil
|
||||||
|
}
|
||||||
|
result, err := handler(ctx, db)
|
||||||
|
return result, true, err
|
||||||
|
}
|
||||||
|
func (s *Scene[T]) executeStep(step string, ctx *SceneContext, db T) (SceneResult, bool, error) {
|
||||||
|
handler, ok := s.steps[step]
|
||||||
|
if !ok {
|
||||||
|
return SceneResult{}, false, nil
|
||||||
|
}
|
||||||
|
result, err := handler(ctx, db)
|
||||||
|
return result, true, err
|
||||||
|
}
|
||||||
|
func (s *Scene[T]) executeMessage(ctx *SceneContext, db T) (SceneResult, bool, error) {
|
||||||
|
if s.message == nil {
|
||||||
|
return SceneResult{}, false, nil
|
||||||
|
}
|
||||||
|
result, err := s.message(ctx, db)
|
||||||
|
return result, true, err
|
||||||
|
}
|
||||||
|
|
||||||
|
type SceneSession struct {
|
||||||
|
Scene string
|
||||||
|
Step string
|
||||||
|
Data []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetData sets the session data. It is thread-safe and can be used to store any arbitrary data as a byte slice.
|
||||||
|
func (s *SceneSession) SetData(data []byte) {
|
||||||
|
s.Data = data
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetData retrieves the session data. It is thread-safe and returns the data as a byte slice.
|
||||||
|
func (s *SceneSession) GetData() []byte {
|
||||||
|
return s.Data
|
||||||
|
}
|
||||||
|
func (s *SceneSession) HasData() bool {
|
||||||
|
return len(s.Data) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearData clears the session data. It is thread-safe and sets the data to nil.
|
||||||
|
func (s *SceneSession) ClearData() {
|
||||||
|
s.Data = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BindData binds the session data to the provided struct.
|
||||||
|
func (s *SceneSession) BindData(v any) error {
|
||||||
|
if len(s.Data) == 0 {
|
||||||
|
return nil // No data to bind, return nil error
|
||||||
|
}
|
||||||
|
return json.Unmarshal(s.Data, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveData saves the provided struct as JSON in the session data.
|
||||||
|
func (s *SceneSession) SaveData(v any) error {
|
||||||
|
data, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.Data = data
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type SessionStore interface {
|
||||||
|
Get(key string) (SceneSession, error)
|
||||||
|
Set(key string, session SceneSession) error
|
||||||
|
Delete(key string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type MemorySessionStore struct {
|
||||||
|
store map[string]SceneSession
|
||||||
|
mu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMemorySessionStore() *MemorySessionStore {
|
||||||
|
return &MemorySessionStore{
|
||||||
|
store: make(map[string]SceneSession),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func (s *MemorySessionStore) Get(key string) (SceneSession, error) {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
if session, ok := s.store[key]; ok {
|
||||||
|
return session, nil
|
||||||
|
}
|
||||||
|
return SceneSession{}, nil
|
||||||
|
}
|
||||||
|
func (s *MemorySessionStore) Set(key string, session SceneSession) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.store[key] = session
|
||||||
|
s.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (s *MemorySessionStore) Delete(key string) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
delete(s.store, key)
|
||||||
|
s.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type SceneResult struct {
|
||||||
|
Action SceneAction
|
||||||
|
Next string
|
||||||
|
}
|
||||||
|
|
||||||
|
type SceneAction int
|
||||||
|
|
||||||
|
const (
|
||||||
|
SceneActionStay SceneAction = iota
|
||||||
|
SceneActionNext
|
||||||
|
SceneActionExit
|
||||||
|
SceneActionPass
|
||||||
|
)
|
||||||
|
|
||||||
|
type SceneScope int
|
||||||
|
|
||||||
|
const (
|
||||||
|
SceneScopeUser SceneScope = iota
|
||||||
|
SceneScopeChat
|
||||||
|
SceneScopeUserChat
|
||||||
|
)
|
||||||
|
|
||||||
|
type sceneRuntime interface {
|
||||||
|
FindScene(name string) (*sceneMeta, bool)
|
||||||
|
GetSession(key string) (SceneSession, error)
|
||||||
|
SetSession(key string, session SceneSession) error
|
||||||
|
DeleteSession(key string) error
|
||||||
|
BuildSceneKey(scope SceneScope, ctx *MsgContext) (string, bool)
|
||||||
|
FindSceneSession(ctx *MsgContext) (string, SceneSession, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type sceneMeta struct {
|
||||||
|
Name string
|
||||||
|
Scope SceneScope
|
||||||
|
Entry string
|
||||||
|
Steps map[string]struct{}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package laniakea
|
||||||
|
|
||||||
|
type SceneContext struct {
|
||||||
|
*MsgContext
|
||||||
|
sess SceneSession
|
||||||
|
key string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ctx *SceneContext) Next(step string) SceneResult {
|
||||||
|
return SceneResult{
|
||||||
|
Action: SceneActionNext,
|
||||||
|
Next: step,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func (ctx *SceneContext) Stay() SceneResult {
|
||||||
|
return SceneResult{Action: SceneActionStay}
|
||||||
|
}
|
||||||
|
func (ctx *SceneContext) Exit() SceneResult {
|
||||||
|
return SceneResult{Action: SceneActionExit}
|
||||||
|
}
|
||||||
|
func (ctx *SceneContext) Pass() SceneResult {
|
||||||
|
return SceneResult{Action: SceneActionPass}
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
package laniakea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (bot *Bot[T]) tryHandleScene(ctx *MsgContext) (bool, error) {
|
||||||
|
key, session, err := bot.FindSceneSession(ctx)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, ErrCantFindSession) || errors.Is(err, ErrMessageNil) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if session.Scene == "" {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, plugin := range bot.plugins {
|
||||||
|
scene, ok := plugin.scenes[session.Scene]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if scene.PluginName != "" && scene.PluginName != plugin.name {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sceneCtx := &SceneContext{
|
||||||
|
MsgContext: ctx,
|
||||||
|
sess: session,
|
||||||
|
key: key,
|
||||||
|
}
|
||||||
|
return bot.executeScene(scene, sceneCtx)
|
||||||
|
}
|
||||||
|
return false, ErrSceneNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func (bot *Bot[T]) executeScene(scene *Scene[T], ctx *SceneContext) (bool, error) {
|
||||||
|
if ctx.MsgContext == nil || ctx.sess.Scene == "" {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
text := ctx.Msg.Text
|
||||||
|
if text == "" {
|
||||||
|
text = ctx.Msg.Caption
|
||||||
|
}
|
||||||
|
text = strings.TrimSpace(text)
|
||||||
|
prefix, cmd, args := bot.parseCommand(text)
|
||||||
|
if cmd != "" {
|
||||||
|
ctx.Prefix = prefix
|
||||||
|
ctx.Text = args
|
||||||
|
ctx.Args = strings.Fields(args)
|
||||||
|
|
||||||
|
res, matched, err := scene.executeCommand(cmd, ctx, bot.dbContext)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if matched {
|
||||||
|
return bot.applySceneResult(scene, ctx, res)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.Text = text
|
||||||
|
ctx.Args = nil
|
||||||
|
ctx.Prefix = ""
|
||||||
|
if ctx.sess.Step != "" {
|
||||||
|
res, matched, err := scene.executeStep(ctx.sess.Step, ctx, bot.dbContext)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if matched {
|
||||||
|
return bot.applySceneResult(scene, ctx, res)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res, matched, err := scene.executeMessage(ctx, bot.dbContext)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if matched {
|
||||||
|
return bot.applySceneResult(scene, ctx, res)
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
func (bot *Bot[T]) applySceneResult(scene *Scene[T], ctx *SceneContext, result SceneResult) (bool, error) {
|
||||||
|
switch result.Action {
|
||||||
|
case SceneActionStay:
|
||||||
|
if err := bot.sessionStore.Set(ctx.key, ctx.sess); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
|
||||||
|
case SceneActionNext:
|
||||||
|
if result.Next == "" {
|
||||||
|
return false, ErrSceneStepNotFound
|
||||||
|
}
|
||||||
|
if _, ok := scene.steps[result.Next]; !ok {
|
||||||
|
return false, ErrSceneStepNotFound
|
||||||
|
}
|
||||||
|
ctx.sess.Step = result.Next
|
||||||
|
if err := bot.sessionStore.Set(ctx.key, ctx.sess); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
|
||||||
|
case SceneActionExit:
|
||||||
|
if err := bot.sessionStore.Delete(ctx.key); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
|
||||||
|
case SceneActionPass:
|
||||||
|
return false, nil
|
||||||
|
|
||||||
|
default:
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func buildSceneKey(scope SceneScope, ctx *MsgContext) (string, bool) {
|
||||||
|
switch scope {
|
||||||
|
case SceneScopeUserChat:
|
||||||
|
return fmt.Sprintf("user_id:%d:chat_id:%d", ctx.FromID, ctx.Msg.Chat.ID), true
|
||||||
|
case SceneScopeChat:
|
||||||
|
return fmt.Sprintf("chat_id:%d", ctx.Msg.Chat.ID), true
|
||||||
|
case SceneScopeUser:
|
||||||
|
return fmt.Sprintf("user_id:%d", ctx.FromID), true
|
||||||
|
default:
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
+1
-1
@@ -224,7 +224,7 @@ type ChatBoostSource struct {
|
|||||||
// ChatBoost represents a boost added to a chat.
|
// ChatBoost represents a boost added to a chat.
|
||||||
// See https://core.telegram.org/bots/api#chatboost
|
// See https://core.telegram.org/bots/api#chatboost
|
||||||
type ChatBoost struct {
|
type ChatBoost struct {
|
||||||
BoostID int `json:"boost_id"`
|
BoostID string `json:"boost_id"`
|
||||||
AddDate int `json:"add_date"`
|
AddDate int `json:"add_date"`
|
||||||
ExpirationDate int `json:"expiration_date"`
|
ExpirationDate int `json:"expiration_date"`
|
||||||
Source ChatBoostSource `json:"source"`
|
Source ChatBoostSource `json:"source"`
|
||||||
|
|||||||
+23
-1
@@ -37,9 +37,28 @@ func TestUpdateUnmarshalSetsType(t *testing.T) {
|
|||||||
}`,
|
}`,
|
||||||
want: UpdateTypeCallbackQuery,
|
want: UpdateTypeCallbackQuery,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "chat boost",
|
||||||
|
body: `{
|
||||||
|
"update_id": 3,
|
||||||
|
"chat_boost": {
|
||||||
|
"chat": {"id": -1001, "type": "supergroup", "title": "Boosted"},
|
||||||
|
"boost": {
|
||||||
|
"boost_id": "boost-1",
|
||||||
|
"add_date": 1735689600,
|
||||||
|
"expiration_date": 1738291600,
|
||||||
|
"source": {
|
||||||
|
"source": "premium",
|
||||||
|
"user": {"id": 1, "is_bot": false, "first_name": "Test"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
want: UpdateTypeChatBoost,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "unknown",
|
name: "unknown",
|
||||||
body: `{"update_id":3}`,
|
body: `{"update_id":4}`,
|
||||||
want: UpdateTypeUnknown,
|
want: UpdateTypeUnknown,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -53,6 +72,9 @@ func TestUpdateUnmarshalSetsType(t *testing.T) {
|
|||||||
if update.Type != tt.want {
|
if update.Type != tt.want {
|
||||||
t.Fatalf("unexpected update type: got %q want %q", update.Type, tt.want)
|
t.Fatalf("unexpected update type: got %q want %q", update.Type, tt.want)
|
||||||
}
|
}
|
||||||
|
if tt.want == UpdateTypeChatBoost && update.ChatBoost.Boost.BoostID != "boost-1" {
|
||||||
|
t.Fatalf("unexpected boost id: got %q want %q", update.ChatBoost.Boost.BoostID, "boost-1")
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -2,7 +2,7 @@ package utils
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
// VersionString is the module version string.
|
// VersionString is the module version string.
|
||||||
VersionString = "1.0.0-rc.10"
|
VersionString = "1.0.0-rc.12"
|
||||||
// VersionMajor is the module major version.
|
// VersionMajor is the module major version.
|
||||||
VersionMajor = 1
|
VersionMajor = 1
|
||||||
// VersionMinor is the module minor version.
|
// VersionMinor is the module minor version.
|
||||||
@@ -10,5 +10,5 @@ const (
|
|||||||
// VersionPatch is the module patch version.
|
// VersionPatch is the module patch version.
|
||||||
VersionPatch = 0
|
VersionPatch = 0
|
||||||
// VersionBeta is the prerelease counter for the current version.
|
// VersionBeta is the prerelease counter for the current version.
|
||||||
VersionBeta = 10
|
VersionBeta = 12
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user