REPOSITORY / ScuroNeko/Laniakea
Compare commits
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68e7529f16
|
||
|
|
0ee0917af5
|
||
|
|
8618397bc1
|
||
|
|
945b8240e6
|
||
|
|
5d3199dc21
|
@@ -1,2 +1,5 @@
|
|||||||
.idea/
|
.idea/
|
||||||
|
.wiki/
|
||||||
|
.vscode/
|
||||||
test/
|
test/
|
||||||
|
.codex/
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ 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.
|
||||||
|
|
||||||
## 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 +82,46 @@ 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.
|
||||||
|
|
||||||
|
## 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:
|
||||||
|
|
||||||
|
|||||||
+36
-1
@@ -1,6 +1,41 @@
|
|||||||
# 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(...)`.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- `CommandExecutor` now returns `error`, and command, payload, and non-command update handlers now use centralized bot error handling for returned errors.
|
||||||
|
- README and README_RU examples now use the new handler signature and document the long-message helpers.
|
||||||
|
- README and README_RU now link to the project wiki, and the wiki now includes a page-priority tracker while content is being filled in.
|
||||||
|
- `AGENTS.md` now requires every change to be recorded in `CHANGELOG.md`, enforces version alignment with `utils/version.go`, and blocks breaking changes without a major-version bump.
|
||||||
|
- `AGENTS.md` now also defines a short commit-message format: one summary line plus up to three high-signal detail lines.
|
||||||
|
- `AGENTS.md` now explicitly requires each commit-message detail line to be placed on its own new line.
|
||||||
|
- `AGENTS.md` now also requires commit messages to be emitted as a plain multiline block instead of collapsed prose or list formatting.
|
||||||
|
- `AGENTS.md` now requires new or expanded project documentation to be maintained in both English and Russian whenever reasonably possible.
|
||||||
|
- `AGENTS.md` now requires all agent-created commits to be GPG-signed and to fail fast instead of falling back to unsigned commits when signing cannot be completed.
|
||||||
|
- Added `TODO.md` to track missing framework-level concepts, with detailed notes for scenes, typed handler input, and request-scoped cancellation.
|
||||||
|
- Payload-type comments and docs now distinguish between the bot's default payload type and keyboard-local overrides.
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
## 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,136 @@
|
|||||||
|
# TODO
|
||||||
|
|
||||||
|
This file tracks framework-level backlog items that are about missing concepts in the library itself, not just missing documentation.
|
||||||
|
|
||||||
|
## High-Priority Core Concepts
|
||||||
|
|
||||||
|
### 1. Conversation / Scene Model
|
||||||
|
|
||||||
|
Current state:
|
||||||
|
- The framework is strong at handling a single update through commands, payloads, middleware, and update handlers.
|
||||||
|
- It already has useful lower-level building blocks such as `MsgContext`, drafts, payload routing, plugins, and update handlers.
|
||||||
|
- It does not yet provide a first-class concept for long-lived user interaction flows.
|
||||||
|
|
||||||
|
Why this matters:
|
||||||
|
- Many Telegram bots quickly move beyond isolated commands and need stateful multi-step flows.
|
||||||
|
- Real bots often need concepts like "wait for the user's next message", "user is currently on step 3 of 5", or "button press moves the user to the next scene state".
|
||||||
|
- Without a scene model, library users end up building their own mini-framework on top of Laniakea.
|
||||||
|
|
||||||
|
What is missing:
|
||||||
|
- A way to route updates to an active scene before normal command routing.
|
||||||
|
- A way to persist conversation state per user or per chat.
|
||||||
|
- A way to describe steps and transitions without hand-rolling state machines around middleware and storage.
|
||||||
|
- A way to enter, continue, cancel, and complete a conversation flow explicitly.
|
||||||
|
- A way to support modal chat flows where the user is "inside" a scene and ordinary text is treated as scene input until an explicit escape command exits the mode.
|
||||||
|
|
||||||
|
Possible API direction:
|
||||||
|
- `Scene`, `Step`, and `SessionStore` concepts.
|
||||||
|
- `bot.AddScene(...)` or a dedicated scene registry.
|
||||||
|
- `ctx.Scene()`, `ctx.NextStep(...)`, `ctx.ExitScene()`, or similar state-transition helpers.
|
||||||
|
- Routing rule: active scene first, then normal command/payload flow if no scene claims the update.
|
||||||
|
- Storage-backed per-user or per-chat state with a clean interface for custom persistence.
|
||||||
|
- Scene-local escape and passthrough commands, so flows like `/startrp` can put a user into a dedicated chat mode where most messages go straight to the scene, while commands like `/exit` or a small whitelist still retain special meaning.
|
||||||
|
|
||||||
|
Important design constraints:
|
||||||
|
- This should be additive and optional.
|
||||||
|
- It should not replace plugins, commands, or handlers as the normal framework entry points.
|
||||||
|
- It should work with existing middleware and `MsgContext` instead of introducing a second incompatible execution model.
|
||||||
|
|
||||||
|
Practical target:
|
||||||
|
- Make stateful bot flows a first-class, framework-supported pattern instead of a userland convention.
|
||||||
|
- Cover both step-based forms and mode-based chat flows without forcing users to build custom routing layers around active sessions.
|
||||||
|
|
||||||
|
### 2. Typed Handler Input Model
|
||||||
|
|
||||||
|
Current state:
|
||||||
|
- Commands and payloads currently expose parsed text through `ctx.Text` and `ctx.Args`.
|
||||||
|
- `CommandArg` provides basic argument validation and shape checks.
|
||||||
|
- Handlers still do most non-trivial parsing manually.
|
||||||
|
|
||||||
|
Why this matters:
|
||||||
|
- As bots grow, handlers often start with repetitive `ctx.Args` parsing boilerplate.
|
||||||
|
- Validation logic tends to spread across handlers instead of living in one predictable binding layer.
|
||||||
|
- The current model is simple and honest, but it does not help enough once commands become more structured.
|
||||||
|
|
||||||
|
What is missing:
|
||||||
|
- A first-class way to bind command or payload arguments into a typed Go value.
|
||||||
|
- A framework-level pattern for conversion errors and validation errors beyond raw string handling.
|
||||||
|
- A low-friction way to move from positional arguments to a structured input object.
|
||||||
|
|
||||||
|
Possible API direction:
|
||||||
|
- A lightweight binding API such as `ctx.BindArgs(&input)`.
|
||||||
|
- Or explicit typed command registration such as `NewCommandTyped(...)`.
|
||||||
|
- Positional mapping into structs, optional fields, basic conversion support, and integration with current validation flow.
|
||||||
|
- Unified binding and validation failures routed through the current centralized error path.
|
||||||
|
|
||||||
|
Example of the kind of user code this should enable:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type BanInput struct {
|
||||||
|
UserID int
|
||||||
|
Reason string
|
||||||
|
}
|
||||||
|
|
||||||
|
func ban(ctx *laniakea.MsgContext, db *App) error {
|
||||||
|
var input BanInput
|
||||||
|
if err := ctx.BindArgs(&input); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return db.Ban(input.UserID, input.Reason)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Important design constraints:
|
||||||
|
- Avoid a reflection-heavy, magical subsystem.
|
||||||
|
- Keep the current `ctx.Args` model as the minimal baseline.
|
||||||
|
- Treat typed binding as an ergonomic layer on top of the current command model, not a replacement for it.
|
||||||
|
|
||||||
|
Practical target:
|
||||||
|
- Remove repetitive parsing boilerplate while preserving the framework's explicit, Go-like feel.
|
||||||
|
|
||||||
|
### 3. Request Context / Cancellation Model
|
||||||
|
|
||||||
|
Current state:
|
||||||
|
- `RunWithContext(...)` controls bot runtime lifecycle and graceful shutdown.
|
||||||
|
- `tgapi` already supports context-aware methods.
|
||||||
|
- Regular handlers do not receive a first-class request-scoped `context.Context`.
|
||||||
|
|
||||||
|
Why this matters:
|
||||||
|
- Handler business logic often needs cancellation-aware database calls, HTTP calls, or downstream service calls.
|
||||||
|
- The framework already has a good runtime cancellation story, but it does not flow naturally into user code inside handlers.
|
||||||
|
- In modern Go APIs, `context.Context` is a standard part of operational correctness.
|
||||||
|
|
||||||
|
What is missing:
|
||||||
|
- A clean request-scoped context that follows each update through handler execution.
|
||||||
|
- A standard way for application code to stop work when the bot is shutting down or the update processing context is canceled.
|
||||||
|
- A direct bridge between bot lifecycle control and service-layer cancellation.
|
||||||
|
|
||||||
|
Possible API direction:
|
||||||
|
- Prefer a non-breaking approach by exposing context through `MsgContext`, for example `ctx.Context()`.
|
||||||
|
- Build the context from the update-processing lifecycle so it is meaningful during graceful shutdown.
|
||||||
|
- Make it natural to pass that context into database methods, HTTP clients, and `tgapi.WithContext(...)` calls.
|
||||||
|
|
||||||
|
Why this should probably not be a signature change:
|
||||||
|
- Changing handler signatures to accept `context.Context` directly would be a public breaking change.
|
||||||
|
- A `MsgContext` accessor would preserve compatibility while still giving handlers an idiomatic Go cancellation path.
|
||||||
|
|
||||||
|
Practical target:
|
||||||
|
- Let handler code participate naturally in cancellation and graceful shutdown without forcing users to invent their own context plumbing.
|
||||||
|
|
||||||
|
## Secondary Backlog
|
||||||
|
|
||||||
|
- Webhook runtime model: the library has a solid polling model, but no first-class webhook execution model at the framework level.
|
||||||
|
- Service layer and dependency graph model: `DatabaseContext(T)` is intentionally minimal, but there is no stronger framework concept for application services or scoped dependencies.
|
||||||
|
- User-facing vs internal error model: the framework has a unified error flow, but it does not yet distinguish well between user-visible, internal-only, retryable, or silent errors.
|
||||||
|
- Authorization and policy model: middleware can implement auth and permissions, but there is no explicit framework concept for access policies, roles, or capability checks.
|
||||||
|
- Observability model: logging is strong, but metrics, tracing, and structured framework hooks are still missing as first-class concepts.
|
||||||
|
- Plugin composition contract: plugins are a good grouping unit, but there is no explicit model for plugin dependencies, shared capabilities, or composition contracts.
|
||||||
|
- Update schema contract: update handling exists, but there is no formal framework-level concept describing which `MsgContext` fields are guaranteed in which update kinds.
|
||||||
|
- Configuration freeze model: the framework already has real commit points like `AddPlugins(...)`, but this is still more of an implementation truth than an explicit top-level concept.
|
||||||
|
|
||||||
|
## Suggested Priority
|
||||||
|
|
||||||
|
1. Request context / cancellation model
|
||||||
|
2. Conversation / scene model
|
||||||
|
3. Typed handler input model
|
||||||
@@ -77,12 +77,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
|
||||||
@@ -156,22 +157,23 @@ 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),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add API and Uploader loggers to extraLoggers for unified output
|
// Add API and Uploader loggers to extraLoggers for unified output
|
||||||
@@ -356,14 +358,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] {
|
||||||
|
|||||||
+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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+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,59 @@
|
|||||||
|
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")
|
||||||
|
// 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")
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
+37
-6
@@ -156,7 +156,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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -320,19 +322,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
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-5
@@ -51,7 +51,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 +110,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 +127,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]{
|
||||||
@@ -147,7 +148,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 +157,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 +173,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]{
|
||||||
@@ -199,7 +202,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 +216,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]{
|
||||||
|
|||||||
+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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+87
-11
@@ -2,6 +2,7 @@ package laniakea
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -48,6 +49,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,7 +64,7 @@ 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 {
|
||||||
@@ -96,7 +101,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 +133,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,7 +148,7 @@ 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 {
|
||||||
@@ -187,7 +196,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{
|
||||||
@@ -220,6 +233,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 +253,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 +270,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 +284,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{
|
||||||
@@ -322,11 +398,11 @@ 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.DeleteMessage(tgapi.DeleteMessageP{
|
||||||
@@ -344,7 +420,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)
|
||||||
@@ -411,15 +487,15 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,146 @@ func TestAnswerPhotoIncludesDirectMessagesTopicID(t *testing.T) {
|
|||||||
t.Fatalf("unexpected direct_messages_topic_id: %v", got)
|
t.Fatalf("unexpected direct_messages_topic_id: %v", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+8
-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.
|
||||||
@@ -309,7 +310,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 +336,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,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