REPOSITORY / ScuroNeko/Laniakea
Compare commits
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68e7529f16
|
||
|
|
0ee0917af5
|
||
|
|
8618397bc1
|
||
|
|
945b8240e6
|
||
|
|
5d3199dc21
|
@@ -1,2 +1,5 @@
|
||||
.idea/
|
||||
.wiki/
|
||||
.vscode/
|
||||
test/
|
||||
.codex/
|
||||
|
||||
@@ -22,6 +22,12 @@ Review the codebase with focus on:
|
||||
- When feasible, make small, high-confidence improvements directly.
|
||||
- When uncertain, state confidence level and evidence.
|
||||
|
||||
## Documentation languages
|
||||
- When creating or expanding project documentation, generate and maintain both English and Russian versions in the same turn whenever reasonably possible.
|
||||
- For wiki pages, prefer paired pages such as `Page.md` and `Page-RU.md`.
|
||||
- Keep English and Russian pages aligned in structure, major examples, and user-facing guidance.
|
||||
- If only one language can be updated safely in the current turn, explicitly say which language is lagging and why.
|
||||
|
||||
## Go review expectations
|
||||
Check for:
|
||||
- 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.
|
||||
|
||||
## 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
|
||||
For repo-wide review tasks, structure the result as:
|
||||
|
||||
|
||||
+36
-1
@@ -1,6 +1,41 @@
|
||||
# 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
|
||||
- `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)
|
||||
|
||||
[Wiki](https://git.nix13.pw/ScuroNeko/Laniakea/wiki)
|
||||
|
||||
---
|
||||
|
||||
## ✨ Features
|
||||
@@ -52,10 +54,11 @@ import (
|
||||
// It receives two parameters:
|
||||
// - ctx: the message context (contains info about the message, sender, chat, etc.)
|
||||
// - db: your custom database context (here we use NoDB, a placeholder for no database)
|
||||
func echo(ctx *laniakea.MsgContext, db laniakea.NoDB) {
|
||||
func echo(ctx *laniakea.MsgContext, db laniakea.NoDB) error {
|
||||
// Answer the user with the text they sent, without any command prefix.
|
||||
// ctx.Text contains the user's message with the command part stripped off.
|
||||
ctx.Answer(ctx.Text) // User input WITHOUT command
|
||||
return nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
@@ -81,8 +84,9 @@ func main() {
|
||||
|
||||
// 5. Add another command using an anonymous function (closure).
|
||||
// This command simply replies "Pong" when the user sends "/ping".
|
||||
p.AddCommand(p.NewCommand(func(ctx *laniakea.MsgContext, db laniakea.NoDB) {
|
||||
p.AddCommand(p.NewCommand(func(ctx *laniakea.MsgContext, db laniakea.NoDB) error {
|
||||
ctx.Answer("Pong")
|
||||
return nil
|
||||
}, "ping"))
|
||||
|
||||
// 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.
|
||||
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.
|
||||
4. `AddCommand`: Registers a command. The first argument is the handler function (func(*MsgContext, T)), the second is the command name (without the slash).
|
||||
5. **Handler Functions**: Receive *MsgContext (message details, methods like Answer) and your custom database context T.
|
||||
4. `AddCommand`: Registers a command. The first argument is the handler function (`func(*MsgContext, T) error`), the second is the command name (without the slash).
|
||||
5. **Handler Functions**: Receive *MsgContext (message details, methods like Answer) and your custom database context T, and return an error for centralized error handling.
|
||||
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.
|
||||
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).
|
||||
```go
|
||||
func myHandler(ctx *laniakea.MsgContext, db *MyDB) {
|
||||
func myHandler(ctx *laniakea.MsgContext, db *MyDB) error {
|
||||
// Access command arguments via ctx.Args ([]string)
|
||||
// Reply to the user: ctx.Answer("some text")
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
@@ -139,8 +144,10 @@ func myHandler(ctx *laniakea.MsgContext, db *MyDB) {
|
||||
Provides access to the incoming message and useful reply methods:
|
||||
|
||||
- `Answer(text string) *AnswerMessage`: Sends a message with parse_mode none.
|
||||
- `AnswerLong(text string) []*AnswerMessage`: Splits long plain text into multiple messages.
|
||||
- `AnswerMarkdown(text string) *AnswerMessage`: Sends a message formatted with MarkdownV2 (you handle escaping).
|
||||
- `Keyboard(text string, keyboard *InlineKeyboard) *AnswerMessage`: Sends a message with parse_mode none and inline keyboard.
|
||||
- `KeyboardLong(text string, keyboard *InlineKeyboard) []*AnswerMessage`: Splits long plain text into multiple messages and attaches the keyboard to the final chunk.
|
||||
- `KeyboardMarkdown(text string, keyboard *InlineKeyboard) *AnswerMessage`: Sends a message formatted with MarkdownV2 (you handle escaping) and inline keyboard.
|
||||
- `AnswerPhoto(photoId, text string) *AnswerMessage`: Sends a message with photo with parse_mode none.
|
||||
- `AnswerPhotoMarkdown(photoId, text string) *AnswerMessage`: Sends a photo with MarkdownV2 caption (you handle escaping).
|
||||
@@ -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.
|
||||
|
||||
## ⚙️ Advanced Configuration
|
||||
- **Inline Keyboards**: Build keyboards using `laniakea.NewInlineKeyboardJson`, `laniakea.NewInlineKeyboardBase64`, or `laniakea.NewInlineKeyboard`.
|
||||
- **Inline Keyboards**: Build keyboards using `laniakea.NewInlineKeyboardJson`, `laniakea.NewInlineKeyboardBase64`, or `laniakea.NewInlineKeyboard`. `Bot.SetPayloadType(...)` defines the default payload format, and `InlineKeyboard.SetPayloadType(...)` overrides it for one keyboard.
|
||||
- **Rate Limiting**: Pass a configured utils.RateLimiter via BotOpts to handle Telegram's rate limits gracefully.
|
||||
- **Localization**: `L10n` is safe for concurrent use once attached to the bot.
|
||||
- **Custom Update Handlers**: Use `plugin.AddUpdateHandler(...)` for Telegram update types that are not part of the command/payload flow.
|
||||
@@ -242,6 +249,8 @@ This project is licensed under the GNU General Public License v3.0 — see the [
|
||||
## 📚 Learn More
|
||||
[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)
|
||||
|
||||
✅ Built with ❤️ by scuroneko
|
||||
|
||||
+15
-6
@@ -10,6 +10,8 @@
|
||||
|
||||
[English](README.md)
|
||||
|
||||
[Wiki](https://git.nix13.pw/ScuroNeko/Laniakea/wiki)
|
||||
|
||||
---
|
||||
|
||||
## ✨ Возможности
|
||||
@@ -53,10 +55,11 @@ import (
|
||||
// Она получает два параметра:
|
||||
// - ctx: контекст сообщения (содержит информацию о сообщении, отправителе, чате и т.д.)
|
||||
// - db: ваш пользовательский контекст базы данных (здесь мы используем NoDB — заглушку)
|
||||
func echo(ctx *laniakea.MsgContext, db laniakea.NoDB) {
|
||||
func echo(ctx *laniakea.MsgContext, db laniakea.NoDB) error {
|
||||
// Отвечаем пользователю текстом, который он прислал, без префикса команды.
|
||||
// ctx.Text содержит сообщение пользователя, из которого удалена часть с командой.
|
||||
ctx.Answer(ctx.Text) // Ввод пользователя БЕЗ команды
|
||||
return nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
@@ -82,8 +85,9 @@ func main() {
|
||||
|
||||
// 5. Добавляем ещё одну команду, используя анонимную функцию (замыкание).
|
||||
// Эта команда просто отвечает "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")
|
||||
return nil
|
||||
}, "ping"))
|
||||
|
||||
// 6. Настраиваем бота: задаём шаблон ошибки и добавляем плагин.
|
||||
@@ -108,8 +112,8 @@ func main() {
|
||||
1. `BotOpts`: Содержит конфигурацию, например, токен API.
|
||||
2. `NewBot[T]`: Создаёт экземпляр бота. Параметр типа T позволяет передать пользовательский контекст базы данных (например, *sql.DB), который будет доступен во всех обработчиках. Используйте laniakea.NoDB, если он не нужен.
|
||||
3. `NewPlugin`: Создаёт логическую группу для команд и Middleware.
|
||||
4. `AddCommand`: Регистрирует команду. Первый аргумент — функция-обработчик (func(*MsgContext, T)), второй — имя команды (без слеша).
|
||||
5. **Функции-обработчики**: Получают *MsgContext (детали сообщения, методы типа Answer) и ваш контекст базы данных T.
|
||||
4. `AddCommand`: Регистрирует команду. Первый аргумент — функция-обработчик (`func(*MsgContext, T) error`), второй — имя команды (без слеша).
|
||||
5. **Функции-обработчики**: Получают *MsgContext (детали сообщения, методы типа Answer) и ваш контекст базы данных T, а ошибку возвращают для централизованной обработки.
|
||||
6. `ErrorTemplate`: Устанавливает шаблон для сообщений об ошибках. Плейсхолдер %s заменяется на текст ошибки.
|
||||
7. `AutoGenerateCommands`: Регистрирует команды из плагинов в Telegram для поддерживаемых scope.
|
||||
8. `Run()`: Запускает цикл опроса обновлений бота и возвращает ошибку, если старт или polling завершился неуспешно.
|
||||
@@ -129,9 +133,10 @@ bot.AddPlugins(plugin)
|
||||
Команда — это функция, которая обрабатывает конкретную команду бота (например, /start).
|
||||
|
||||
```go
|
||||
func myHandler(ctx *laniakea.MsgContext, db *MyDB) {
|
||||
func myHandler(ctx *laniakea.MsgContext, db *MyDB) error {
|
||||
// Доступ к аргументам команды через ctx.Args ([]string)
|
||||
// Ответ пользователю: ctx.Answer("какой-то текст")
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
@@ -139,8 +144,10 @@ func myHandler(ctx *laniakea.MsgContext, db *MyDB) {
|
||||
Предоставляет доступ к входящему сообщению и полезные методы для ответа:
|
||||
|
||||
- `Answer(text string)`: Отправляет сообщение с parse_mode none.
|
||||
- `AnswerLong(text string) []*AnswerMessage`: Разбивает длинный plain text на несколько сообщений.
|
||||
- `AnswerMarkdown(text string)`: Отправляет сообщение, отформатированное MarkdownV2 (экранирование на вашей стороне).
|
||||
- `Keyboard(text string, keyboard *InlineKeyboard) *AnswerMessage`: Отправляет сообщение с parse_mode none и Inline клавиатурой.
|
||||
- `KeyboardLong(text string, keyboard *InlineKeyboard) []*AnswerMessage`: Разбивает длинный plain text на несколько сообщений и вешает клавиатуру на последний chunk.
|
||||
- `KeyboardMarkdown(text string, keyboard *InlineKeyboard) *AnswerMessage`: Отправляет сообщение, отформатированное MarkdownV2 (экранирование на вашей стороне), и Inline клавиатурой.
|
||||
- `AnswerPhoto(photoId, text string) *AnswerMessage`: Отправляет фотографию с подписью и parse_mode none.
|
||||
- `AnswerPhotoMarkdown(photoId, text string) *AnswerMessage`: Отправляет фотографию с подписью, отформатированной MarkdownV2 (экранирование на вашей стороне).
|
||||
@@ -220,7 +227,7 @@ func adminOnlyMiddleware(ctx *laniakea.MsgContext, db *MyDB) bool {
|
||||
- Middleware может изменять MsgContext (например, добавлять пользовательские поля) перед запуском команды.
|
||||
|
||||
## ⚙️ Расширенная настройка
|
||||
- **Инлайн-клавиатуры**: Создавайте клавиатуры с помощью `laniakea.NewInlineKeyboardJson`, `laniakea.NewInlineKeyboardBase64` или `laniakea.NewInlineKeyboard`.
|
||||
- **Инлайн-клавиатуры**: Создавайте клавиатуры с помощью `laniakea.NewInlineKeyboardJson`, `laniakea.NewInlineKeyboardBase64` или `laniakea.NewInlineKeyboard`. `Bot.SetPayloadType(...)` задаёт payload format по умолчанию, а `InlineKeyboard.SetPayloadType(...)` переопределяет его для конкретной клавиатуры.
|
||||
- **Ограничение запросов**: Передайте настроенный `utils.RateLimiter` через `BotOpts` для корректной обработки лимитов Telegram.
|
||||
- **Локализация**: `L10n` безопасен для конкурентного использования после подключения к боту.
|
||||
- **Пользовательские update handlers**: Используйте `plugin.AddUpdateHandler(...)` для Telegram update types вне command/payload flow.
|
||||
@@ -238,6 +245,8 @@ func adminOnlyMiddleware(ctx *laniakea.MsgContext, db *MyDB) bool {
|
||||
## 📚 Дополнительная информация
|
||||
[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)
|
||||
|
||||
✅ Создано с ❤️ 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.
|
||||
// A Bot is single-use: after Run or RunWithContext returns, create a new Bot for the next session.
|
||||
type Bot[T DbContext] struct {
|
||||
token string
|
||||
debug bool
|
||||
errorTemplate string
|
||||
username string
|
||||
payloadType BotPayloadType
|
||||
maxWorkers int
|
||||
token string
|
||||
debug bool
|
||||
errorTemplate string
|
||||
username string
|
||||
payloadType BotPayloadType
|
||||
strictPayloadType bool
|
||||
maxWorkers int
|
||||
|
||||
logger *slog.Logger // Main bot logger (JSON stdout + optional file)
|
||||
RequestLogger *slog.Logger // Optional request-level API logging
|
||||
@@ -156,22 +157,23 @@ func NewBot[T any](opts *BotOpts) (*Bot[T], error) {
|
||||
}
|
||||
|
||||
bot := &Bot[T]{
|
||||
updateOffset: 0,
|
||||
errorTemplate: "%s",
|
||||
payloadType: BotPayloadBase64,
|
||||
maxWorkers: workers,
|
||||
updateQueue: updateQueue,
|
||||
api: api,
|
||||
uploader: uploader,
|
||||
debug: opts.Debug,
|
||||
prefixes: prefixes,
|
||||
token: opts.Token,
|
||||
plugins: make([]Plugin[T], 0),
|
||||
updateTypes: append([]tgapi.UpdateType{}, opts.UpdateTypes...),
|
||||
runners: make([]Runner[T], 0),
|
||||
extraLoggers: make([]*slog.Logger, 0),
|
||||
l10n: &L10n{},
|
||||
draftProvider: NewRandomDraftProvider(api),
|
||||
updateOffset: 0,
|
||||
errorTemplate: "%s",
|
||||
payloadType: BotPayloadBase64,
|
||||
strictPayloadType: opts.StrictPayloadType,
|
||||
maxWorkers: workers,
|
||||
updateQueue: updateQueue,
|
||||
api: api,
|
||||
uploader: uploader,
|
||||
debug: opts.Debug,
|
||||
prefixes: prefixes,
|
||||
token: opts.Token,
|
||||
plugins: make([]Plugin[T], 0),
|
||||
updateTypes: append([]tgapi.UpdateType{}, opts.UpdateTypes...),
|
||||
runners: make([]Runner[T], 0),
|
||||
extraLoggers: make([]*slog.Logger, 0),
|
||||
l10n: &L10n{},
|
||||
draftProvider: NewRandomDraftProvider(api),
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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":[...]}`.
|
||||
// Base64 stores the same JSON encoded as a Base64URL string.
|
||||
// InlineKeyboard.SetPayloadType may override this value for an individual keyboard.
|
||||
func (bot *Bot[T]) SetPayloadType(t BotPayloadType) *Bot[T] {
|
||||
bot.payloadType = t
|
||||
return bot
|
||||
}
|
||||
|
||||
// GetPayloadType returns the bot's default callback payload encoding type.
|
||||
func (bot *Bot[T]) GetPayloadType() BotPayloadType { return bot.payloadType }
|
||||
|
||||
// SetStrictPayloadType enables or disables strict callback payload decoding.
|
||||
// When enabled, callback payloads must match the bot's default payload type.
|
||||
func (bot *Bot[T]) SetStrictPayloadType(strict bool) *Bot[T] {
|
||||
bot.strictPayloadType = strict
|
||||
return bot
|
||||
}
|
||||
|
||||
// AddUpdateType adds one or more update types to the list.
|
||||
// Does not overwrite existing types.
|
||||
func (bot *Bot[T]) AddUpdateType(t ...tgapi.UpdateType) *Bot[T] {
|
||||
|
||||
+15
-2
@@ -56,6 +56,10 @@ type BotOpts struct {
|
||||
// Use this to prioritize responsiveness over reliability.
|
||||
DropRLOverflow bool
|
||||
|
||||
// StrictPayloadType disables callback payload fallback decoding.
|
||||
// When enabled, the bot accepts only the configured default payload type.
|
||||
StrictPayloadType bool
|
||||
|
||||
// MaxWorkers is the maximum number of update handlers that may run concurrently.
|
||||
MaxWorkers int
|
||||
}
|
||||
@@ -75,6 +79,7 @@ type BotOpts struct {
|
||||
// - API_URL: custom API endpoint
|
||||
// - RATE_LIMIT: max requests per second (default: 30)
|
||||
// - DROP_RL_OVERFLOW: "true" to drop updates on rate limit overflow
|
||||
// - STRICT_PAYLOAD_TYPE: "true" to reject callback payloads encoded in a different format
|
||||
// - MAX_WORKERS: maximum number of concurrent update handlers (default: 32)
|
||||
//
|
||||
// Returns a populated BotOpts.
|
||||
@@ -116,8 +121,9 @@ func LoadOptsFromEnv() *BotOpts {
|
||||
UseTestServer: os.Getenv("USE_TEST_SERVER") == "true",
|
||||
APIUrl: os.Getenv("API_URL"),
|
||||
|
||||
RateLimit: rateLimit,
|
||||
DropRLOverflow: os.Getenv("DROP_RL_OVERFLOW") == "true",
|
||||
RateLimit: rateLimit,
|
||||
DropRLOverflow: os.Getenv("DROP_RL_OVERFLOW") == "true",
|
||||
StrictPayloadType: os.Getenv("STRICT_PAYLOAD_TYPE") == "true",
|
||||
|
||||
MaxWorkers: maxWorkers,
|
||||
}
|
||||
@@ -208,6 +214,13 @@ func (opts *BotOpts) SetDropRLOverflow(drop bool) *BotOpts {
|
||||
return opts
|
||||
}
|
||||
|
||||
// SetStrictPayloadType enables or disables strict callback payload decoding.
|
||||
// When enabled, the bot accepts only the configured default payload type.
|
||||
func (opts *BotOpts) SetStrictPayloadType(strict bool) *BotOpts {
|
||||
opts.StrictPayloadType = strict
|
||||
return opts
|
||||
}
|
||||
|
||||
// SetMaxWorkers sets the maximum number of concurrent update handlers.
|
||||
// Must be called before NewBot, as the value is captured during bot creation.
|
||||
//
|
||||
|
||||
@@ -45,3 +45,12 @@ func TestLoadPrefixesFromEnvDropsEmptyValues(t *testing.T) {
|
||||
t.Fatalf("unexpected prefixes: got %v want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadOptsFromEnvReadsStrictPayloadType(t *testing.T) {
|
||||
t.Setenv("STRICT_PAYLOAD_TYPE", "true")
|
||||
|
||||
opts := LoadOptsFromEnv()
|
||||
if !opts.StrictPayloadType {
|
||||
t.Fatal("expected StrictPayloadType to be enabled")
|
||||
}
|
||||
}
|
||||
|
||||
+18
-2
@@ -27,13 +27,13 @@ func TestAddPluginsSnapshotsConfiguration(t *testing.T) {
|
||||
bot := &Bot[NoDB]{logger: slog.CreateLogger()}
|
||||
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 }))
|
||||
|
||||
bot.AddPlugins(plugin)
|
||||
|
||||
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 }))
|
||||
|
||||
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) {
|
||||
bot := &Bot[NoDB]{logger: slog.CreateLogger()}
|
||||
plugin := NewPlugin[NoDB]("demo")
|
||||
|
||||
@@ -44,7 +44,7 @@ func TestAutoGenerateCommandsChecksLimitBeforeDelete(t *testing.T) {
|
||||
}()
|
||||
|
||||
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++ {
|
||||
plugin.AddCommand(NewCommand(exec, "cmd"+strconv.Itoa(i)))
|
||||
}
|
||||
@@ -66,7 +66,7 @@ func TestAutoGenerateCommandsChecksLimitBeforeDelete(t *testing.T) {
|
||||
|
||||
func TestGatherCommandsForPluginReturnsSortedCommands(t *testing.T) {
|
||||
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, "alpha"))
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package laniakea
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math/rand/v2"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
@@ -9,9 +8,6 @@ import (
|
||||
"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.
|
||||
type draftIdGenerator interface {
|
||||
// Next returns the next unique draft ID.
|
||||
@@ -221,6 +217,9 @@ func (d *Draft) Flush() error {
|
||||
if d.chatID == 0 {
|
||||
return ErrDraftChatIDZero
|
||||
}
|
||||
if err := validateMessageText(d.Message); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
params := tgapi.SendMessageP{
|
||||
ChatID: d.chatID,
|
||||
@@ -245,6 +244,9 @@ func (d *Draft) push(text string) error {
|
||||
return ErrDraftChatIDZero
|
||||
}
|
||||
d.Message += text
|
||||
if err := validateMessageText(d.Message); err != nil {
|
||||
return err
|
||||
}
|
||||
params := tgapi.SendMessageDraftP{
|
||||
ChatID: d.chatID,
|
||||
DraftID: d.ID,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package laniakea
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.nix13.pw/scuroneko/laniakea/tgapi"
|
||||
@@ -34,3 +36,20 @@ func TestMsgContextNewDraftWorksWithoutLimiter(t *testing.T) {
|
||||
t.Fatalf("unexpected chat id: %d", draft.chatID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDraftFlushRejectsLongMessage(t *testing.T) {
|
||||
draft := NewRandomDraftProvider(&tgapi.API{}).NewDraft(tgapi.ParseNone).SetChat(42, 0)
|
||||
draft.Message = strings.Repeat("a", maxMessageTextLen+1)
|
||||
|
||||
if err := draft.Flush(); !errors.Is(err, ErrMessageTooLong) {
|
||||
t.Fatalf("expected ErrMessageTooLong, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDraftPushRejectsLongMessage(t *testing.T) {
|
||||
draft := NewRandomDraftProvider(&tgapi.API{}).NewDraft(tgapi.ParseNone).SetChat(42, 0)
|
||||
|
||||
if err := draft.Push(strings.Repeat("a", maxMessageTextLen+1)); !errors.Is(err, ErrMessageTooLong) {
|
||||
t.Fatalf("expected ErrMessageTooLong, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
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))
|
||||
}
|
||||
func decodePayload(payloadType BotPayloadType, s string) (CallbackData, error) {
|
||||
func decodePayload(payloadType BotPayloadType, s string, strict bool) (CallbackData, BotPayloadType, error) {
|
||||
switch payloadType {
|
||||
case BotPayloadBase64:
|
||||
return decodeBase64Payload(s)
|
||||
data, err := decodeBase64Payload(s)
|
||||
if err == nil {
|
||||
return data, BotPayloadBase64, nil
|
||||
}
|
||||
if strict {
|
||||
return CallbackData{}, "", fmt.Errorf("%w: expected %s", ErrPayloadTypeMismatch, BotPayloadBase64)
|
||||
}
|
||||
data, err = decodeJsonPayload(s)
|
||||
if err != nil {
|
||||
return CallbackData{}, "", err
|
||||
}
|
||||
return data, BotPayloadJson, nil
|
||||
case BotPayloadJson:
|
||||
return decodeJsonPayload(s)
|
||||
data, err := decodeJsonPayload(s)
|
||||
if err == nil {
|
||||
return data, BotPayloadJson, nil
|
||||
}
|
||||
if strict {
|
||||
return CallbackData{}, "", fmt.Errorf("%w: expected %s", ErrPayloadTypeMismatch, BotPayloadJson)
|
||||
}
|
||||
data, err = decodeBase64Payload(s)
|
||||
if err != nil {
|
||||
return CallbackData{}, "", err
|
||||
}
|
||||
return data, BotPayloadBase64, nil
|
||||
}
|
||||
return CallbackData{}, ErrInvalidPayloadType
|
||||
return CallbackData{}, "", ErrInvalidPayloadType
|
||||
}
|
||||
|
||||
// func (bot *Bot[T]) encodePayload(d CallbackData) (string, error) {
|
||||
// return encodePayload(bot.payloadType, d)
|
||||
// }
|
||||
func (bot *Bot[T]) decodePayload(s string) (CallbackData, error) {
|
||||
return decodePayload(bot.payloadType, s)
|
||||
data, decodedType, err := decodePayload(bot.payloadType, s, bot.strictPayloadType)
|
||||
if err != nil {
|
||||
return CallbackData{}, err
|
||||
}
|
||||
if decodedType == BotPayloadBase64 && bot.debug && bot.logger != nil {
|
||||
bot.logger.Debugf("decoded callback payload base64->json: raw=%q json=%s", s, data.ToJson())
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
+9
-5
@@ -51,7 +51,7 @@ func TestBotMiddlewareReceivesLogger(t *testing.T) {
|
||||
|
||||
func TestAddUpdateHandlerRejectsReservedUpdateTypes(t *testing.T) {
|
||||
plugin := NewPlugin[NoDB]("test")
|
||||
handler := func(ctx *MsgContext, db NoDB) {}
|
||||
handler := func(ctx *MsgContext, db NoDB) error { return nil }
|
||||
|
||||
for _, updateType := range []tgapi.UpdateType{
|
||||
tgapi.UpdateTypeMessage,
|
||||
@@ -110,7 +110,7 @@ func TestHandleUpdateHandlersPopulateFromContext(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
called := false
|
||||
plugin := NewPlugin[NoDB]("test").AddUpdateHandler(tt.update.Type, func(ctx *MsgContext, db NoDB) {
|
||||
plugin := NewPlugin[NoDB]("test").AddUpdateHandler(tt.update.Type, func(ctx *MsgContext, db NoDB) error {
|
||||
called = true
|
||||
if ctx.Update.UpdateID != tt.update.UpdateID {
|
||||
t.Fatalf("unexpected update in context: got %d want %d", ctx.Update.UpdateID, tt.update.UpdateID)
|
||||
@@ -127,6 +127,7 @@ func TestHandleUpdateHandlersPopulateFromContext(t *testing.T) {
|
||||
if ctx.Msg != nil {
|
||||
t.Fatalf("did not expect message context for %s", tt.name)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
bot := &Bot[NoDB]{
|
||||
@@ -147,7 +148,7 @@ func TestHandleUpdateHandlersReceiveIsolatedContexts(t *testing.T) {
|
||||
firstCalled := 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
|
||||
if ctx.FromID != 41 {
|
||||
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.Text = "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
|
||||
if ctx.From == nil {
|
||||
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 {
|
||||
t.Fatalf("unexpected leaked Args in second handler: %v", ctx.Args)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
bot := &Bot[NoDB]{
|
||||
@@ -199,7 +202,7 @@ func TestHandleUpdateHandlersReceiveIsolatedContexts(t *testing.T) {
|
||||
func TestHandleChannelPostCommandWithSenderChat(t *testing.T) {
|
||||
called := false
|
||||
plugin := NewPlugin[NoDB]("test")
|
||||
plugin.NewCommand(func(ctx *MsgContext, db NoDB) {
|
||||
plugin.NewCommand(func(ctx *MsgContext, db NoDB) error {
|
||||
called = true
|
||||
if ctx.Msg == nil {
|
||||
t.Fatal("expected message context")
|
||||
@@ -213,6 +216,7 @@ func TestHandleChannelPostCommandWithSenderChat(t *testing.T) {
|
||||
if ctx.FromID != 0 {
|
||||
t.Fatalf("expected zero FromID for sender_chat updates, got %d", ctx.FromID)
|
||||
}
|
||||
return nil
|
||||
}, "ping")
|
||||
|
||||
bot := &Bot[NoDB]{
|
||||
|
||||
+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.
|
||||
// It should be one of BotPayloadJson or BotPayloadBase64.
|
||||
// It overrides the bot's default payload type for this keyboard only.
|
||||
func (in *InlineKeyboard) SetPayloadType(t BotPayloadType) *InlineKeyboard {
|
||||
in.payloadType = t
|
||||
return in
|
||||
}
|
||||
|
||||
// GetPayloadType returns the keyboard-local callback payload encoding type.
|
||||
func (in *InlineKeyboard) GetPayloadType() BotPayloadType { return in.payloadType }
|
||||
|
||||
func (in *InlineKeyboard) SetMaxRow(maxRow int) *InlineKeyboard {
|
||||
in.maxRow = maxRow
|
||||
return in
|
||||
}
|
||||
|
||||
// Internal helper that appends a button and auto-flushes a full row.
|
||||
func (in *InlineKeyboard) append(button tgapi.InlineKeyboardButton) *InlineKeyboard {
|
||||
if in.CurrentLine.Len() == in.maxRow {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package laniakea
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -42,3 +44,54 @@ func TestInlineKeyboardBuilderPreservesConfiguredButtonFields(t *testing.T) {
|
||||
t.Fatalf("unexpected url: %q", button.URL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInlineKeyboardGetPayloadTypeReturnsLocalOverride(t *testing.T) {
|
||||
kb := NewInlineKeyboardJson(2)
|
||||
if got := kb.GetPayloadType(); got != BotPayloadJson {
|
||||
t.Fatalf("unexpected initial payload type: %q", got)
|
||||
}
|
||||
kb.SetPayloadType(BotPayloadBase64)
|
||||
if got := kb.GetPayloadType(); got != BotPayloadBase64 {
|
||||
t.Fatalf("unexpected updated payload type: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodePayloadAcceptsBase64KeyboardPayloadWhenBotPrefersJSON(t *testing.T) {
|
||||
kb := NewInlineKeyboardBase64(1).
|
||||
AddCallbackButton("A", "cmd", 1, "two")
|
||||
|
||||
got, _, err := decodePayload(BotPayloadJson, kb.Get().InlineKeyboard[0][0].CallbackData, false)
|
||||
if err != nil {
|
||||
t.Fatalf("decodePayload returned error: %v", err)
|
||||
}
|
||||
|
||||
want := CallbackData{Command: "cmd", Args: []string{"1", "two"}}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("unexpected payload: got %#v want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodePayloadAcceptsJSONKeyboardPayloadWhenBotPrefersBase64(t *testing.T) {
|
||||
kb := NewInlineKeyboardJson(1).
|
||||
AddCallbackButton("A", "cmd", 1, "two")
|
||||
|
||||
got, _, err := decodePayload(BotPayloadBase64, kb.Get().InlineKeyboard[0][0].CallbackData, false)
|
||||
if err != nil {
|
||||
t.Fatalf("decodePayload returned error: %v", err)
|
||||
}
|
||||
|
||||
want := CallbackData{Command: "cmd", Args: []string{"1", "two"}}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("unexpected payload: got %#v want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodePayloadStrictRejectsMismatchedType(t *testing.T) {
|
||||
kb := NewInlineKeyboardBase64(1).
|
||||
AddCallbackButton("A", "cmd", 1)
|
||||
|
||||
_, _, err := decodePayload(BotPayloadJson, kb.Get().InlineKeyboard[0][0].CallbackData, true)
|
||||
if !errors.Is(err, ErrPayloadTypeMismatch) {
|
||||
t.Fatalf("expected ErrPayloadTypeMismatch, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
+87
-11
@@ -2,6 +2,7 @@ package laniakea
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
@@ -48,6 +49,10 @@ type AnswerMessage struct {
|
||||
|
||||
// Internal helper for text edits with optional keyboard and parse mode.
|
||||
func (ctx *MsgContext) edit(messageId int, text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage {
|
||||
if err := validateMessageText(text); err != nil {
|
||||
ctx.Logger.Errorln(err)
|
||||
return nil
|
||||
}
|
||||
params := tgapi.EditMessageTextP{
|
||||
Text: text,
|
||||
ParseMode: parseMode,
|
||||
@@ -59,7 +64,7 @@ func (ctx *MsgContext) edit(messageId int, text string, keyboard *InlineKeyboard
|
||||
case ctx.InlineMsgId != "":
|
||||
params.InlineMessageID = ctx.InlineMsgId
|
||||
default:
|
||||
ctx.Logger.Errorln("Can't edit message: no valid message target")
|
||||
ctx.Logger.Errorln(ErrEditTargetMissing)
|
||||
return nil
|
||||
}
|
||||
if keyboard != nil {
|
||||
@@ -96,7 +101,7 @@ func (m *AnswerMessage) EditMarkdown(text string) *AnswerMessage {
|
||||
// Internal helper for editing callback-linked messages.
|
||||
func (ctx *MsgContext) editCallback(text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage {
|
||||
if ctx.CallbackMsgId == 0 && ctx.InlineMsgId == "" {
|
||||
ctx.Logger.Errorln("Can't edit non-callback update message")
|
||||
ctx.Logger.Errorln(ErrCallbackMessageMissing)
|
||||
return nil
|
||||
}
|
||||
return ctx.edit(ctx.CallbackMsgId, text, keyboard, parseMode)
|
||||
@@ -128,6 +133,10 @@ func (ctx *MsgContext) EditCallbackfMarkdown(format string, keyboard *InlineKeyb
|
||||
|
||||
// Internal helper for media-caption edits.
|
||||
func (ctx *MsgContext) editPhotoText(messageId int, text string, kb *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage {
|
||||
if err := validateCaptionText(text); err != nil {
|
||||
ctx.Logger.Errorln(err)
|
||||
return nil
|
||||
}
|
||||
params := tgapi.EditMessageCaptionP{
|
||||
Caption: text,
|
||||
ParseMode: parseMode,
|
||||
@@ -139,7 +148,7 @@ func (ctx *MsgContext) editPhotoText(messageId int, text string, kb *InlineKeybo
|
||||
case ctx.InlineMsgId != "":
|
||||
params.InlineMessageID = ctx.InlineMsgId
|
||||
default:
|
||||
ctx.Logger.Errorln("Can't edit caption: no valid message target")
|
||||
ctx.Logger.Errorln(ErrEditTargetMissing)
|
||||
return nil
|
||||
}
|
||||
if kb != nil {
|
||||
@@ -187,7 +196,11 @@ func (m *AnswerMessage) EditCaptionKeyboardMarkdown(text string, kb *InlineKeybo
|
||||
// Internal helper for message replies with optional keyboard and parse mode.
|
||||
func (ctx *MsgContext) answer(text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage {
|
||||
if ctx.Msg == nil {
|
||||
ctx.Logger.Errorln("Can't answer message without a message")
|
||||
ctx.Logger.Errorln(ErrMessageContextNil)
|
||||
return nil
|
||||
}
|
||||
if err := validateMessageText(text); err != nil {
|
||||
ctx.Logger.Errorln(err)
|
||||
return nil
|
||||
}
|
||||
params := tgapi.SendMessageP{
|
||||
@@ -220,6 +233,14 @@ func (ctx *MsgContext) Answer(text string) *AnswerMessage {
|
||||
return ctx.answer(text, nil, tgapi.ParseNone)
|
||||
}
|
||||
|
||||
// AnswerLong sends one or more plain-text messages if text exceeds Telegram's limit.
|
||||
//
|
||||
// The text is split into Telegram-safe chunks. Returned messages preserve send
|
||||
// order. If a chunk fails to send, already-sent messages are returned.
|
||||
func (ctx *MsgContext) AnswerLong(text string) []*AnswerMessage {
|
||||
return ctx.answerLong(text, nil, tgapi.ParseNone)
|
||||
}
|
||||
|
||||
// AnswerMarkdown sends a message using MarkdownV2 formatting.
|
||||
//
|
||||
// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here.
|
||||
@@ -232,6 +253,11 @@ func (ctx *MsgContext) Answerf(template string, args ...any) *AnswerMessage {
|
||||
return ctx.answer(fmt.Sprintf(template, args...), nil, tgapi.ParseNone)
|
||||
}
|
||||
|
||||
// AnswerLongf formats a string using fmt.Sprintf and sends it as one or more plain-text messages.
|
||||
func (ctx *MsgContext) AnswerLongf(template string, args ...any) []*AnswerMessage {
|
||||
return ctx.answerLong(fmt.Sprintf(template, args...), nil, tgapi.ParseNone)
|
||||
}
|
||||
|
||||
// AnswerfMarkdown formats a string using fmt.Sprintf and sends it using MarkdownV2.
|
||||
//
|
||||
// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here.
|
||||
@@ -244,6 +270,13 @@ func (ctx *MsgContext) Keyboard(text string, kb *InlineKeyboard) *AnswerMessage
|
||||
return ctx.answer(text, kb, tgapi.ParseNone)
|
||||
}
|
||||
|
||||
// KeyboardLong sends long plain text split across multiple messages.
|
||||
//
|
||||
// The inline keyboard is attached only to the final chunk.
|
||||
func (ctx *MsgContext) KeyboardLong(text string, kb *InlineKeyboard) []*AnswerMessage {
|
||||
return ctx.answerLong(text, kb, tgapi.ParseNone)
|
||||
}
|
||||
|
||||
// KeyboardMarkdown sends a message with an inline keyboard using MarkdownV2.
|
||||
//
|
||||
// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here.
|
||||
@@ -251,10 +284,53 @@ func (ctx *MsgContext) KeyboardMarkdown(text string, keyboard *InlineKeyboard) *
|
||||
return ctx.answer(text, keyboard, tgapi.ParseMDV2)
|
||||
}
|
||||
|
||||
func (ctx *MsgContext) answerLong(text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) []*AnswerMessage {
|
||||
if parseMode != tgapi.ParseNone {
|
||||
ctx.Logger.Errorln(ErrMessageSplitImpossible)
|
||||
return nil
|
||||
}
|
||||
if ctx.Msg == nil {
|
||||
ctx.Logger.Errorln(ErrMessageContextNil)
|
||||
return nil
|
||||
}
|
||||
if err := validateMessageText(text); err == nil {
|
||||
msg := ctx.answer(text, keyboard, parseMode)
|
||||
if msg == nil {
|
||||
return nil
|
||||
}
|
||||
return []*AnswerMessage{msg}
|
||||
} else if !errors.Is(err, ErrMessageTooLong) {
|
||||
ctx.Logger.Errorln(err)
|
||||
return nil
|
||||
}
|
||||
|
||||
parts := SplitMessageText(text)
|
||||
messages := make([]*AnswerMessage, 0, len(parts))
|
||||
for i, part := range parts {
|
||||
partKeyboard := (*InlineKeyboard)(nil)
|
||||
if i == len(parts)-1 {
|
||||
partKeyboard = keyboard
|
||||
}
|
||||
msg := ctx.answer(part, partKeyboard, parseMode)
|
||||
if msg == nil {
|
||||
break
|
||||
}
|
||||
messages = append(messages, msg)
|
||||
}
|
||||
if len(messages) == 0 {
|
||||
return nil
|
||||
}
|
||||
return messages
|
||||
}
|
||||
|
||||
// Internal helper for photo replies with optional caption and keyboard.
|
||||
func (ctx *MsgContext) answerPhoto(photoId, text string, kb *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage {
|
||||
if ctx.Msg == nil {
|
||||
ctx.Logger.Errorln("Can't answer message without a message")
|
||||
ctx.Logger.Errorln(ErrMessageContextNil)
|
||||
return nil
|
||||
}
|
||||
if err := validateCaptionText(text); err != nil {
|
||||
ctx.Logger.Errorln(err)
|
||||
return nil
|
||||
}
|
||||
params := tgapi.SendPhotoP{
|
||||
@@ -322,11 +398,11 @@ func (ctx *MsgContext) AnswerPhotofMarkdown(photoId, template string, args ...an
|
||||
// Internal helper that deletes a message by ID.
|
||||
func (ctx *MsgContext) delete(messageId int) {
|
||||
if messageId == 0 {
|
||||
ctx.Logger.Errorln("Can't delete message: message ID zero")
|
||||
ctx.Logger.Errorln(ErrMessageIDZero)
|
||||
return
|
||||
}
|
||||
if ctx.Msg == nil {
|
||||
ctx.Logger.Errorln("Can't delete message: no chat message context")
|
||||
ctx.Logger.Errorln(ErrMessageContextNil)
|
||||
return
|
||||
}
|
||||
_, err := ctx.Api.DeleteMessage(tgapi.DeleteMessageP{
|
||||
@@ -344,7 +420,7 @@ func (m *AnswerMessage) Delete() { m.ctx.delete(m.MessageID) }
|
||||
// CallbackDelete deletes the message that triggered the callback query.
|
||||
func (ctx *MsgContext) CallbackDelete() {
|
||||
if ctx.CallbackMsgId == 0 {
|
||||
ctx.Logger.Errorln("Can't delete callback message: no callback message ID")
|
||||
ctx.Logger.Errorln(ErrCallbackMessageMissing)
|
||||
return
|
||||
}
|
||||
ctx.delete(ctx.CallbackMsgId)
|
||||
@@ -411,15 +487,15 @@ func (ctx *MsgContext) Error(err error) { ctx.error(err) }
|
||||
|
||||
func (ctx *MsgContext) newDraft(parseMode tgapi.ParseMode) *Draft {
|
||||
if ctx.Msg == nil {
|
||||
ctx.Logger.Errorln("can't create draft: ctx.Msg is nil")
|
||||
ctx.Logger.Errorln(ErrMessageContextNil)
|
||||
return nil
|
||||
}
|
||||
if ctx.Api == nil {
|
||||
ctx.Logger.Errorln("can't create draft: ctx.Api is nil")
|
||||
ctx.Logger.Errorln(ErrAPIIsNil)
|
||||
return nil
|
||||
}
|
||||
if ctx.draftProvider == nil {
|
||||
ctx.Logger.Errorln("can't create draft: ctx.draftProvider is nil")
|
||||
ctx.Logger.Errorln(ErrDraftProviderNil)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,10 @@ package laniakea
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -62,3 +64,146 @@ func TestAnswerPhotoIncludesDirectMessagesTopicID(t *testing.T) {
|
||||
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.
|
||||
// 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.
|
||||
// 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
|
||||
command.exec(ctx, db)
|
||||
if err := command.exec(ctx, db); err != nil {
|
||||
ctx.error(err)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
command.exec(ctx, db)
|
||||
if err := command.exec(ctx, db); err != nil {
|
||||
ctx.error(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Internal helper that runs plugin middlewares in order.
|
||||
|
||||
+3
-3
@@ -6,7 +6,7 @@ import (
|
||||
)
|
||||
|
||||
func TestValidateArgsRequiresFullMatch(t *testing.T) {
|
||||
intCmd := NewCommand[NoDB](func(ctx *MsgContext, db NoDB) {}, "int", NewCommandArg("n").SetValueType(CommandValueIntType).SetRequired())
|
||||
intCmd := NewCommand[NoDB](func(ctx *MsgContext, db NoDB) error { return nil }, "int", NewCommandArg("n").SetValueType(CommandValueIntType).SetRequired())
|
||||
if err := intCmd.validateArgs([]string{"123"}); err != nil {
|
||||
t.Fatalf("expected valid integer argument, got %v", err)
|
||||
}
|
||||
@@ -14,7 +14,7 @@ func TestValidateArgsRequiresFullMatch(t *testing.T) {
|
||||
t.Fatalf("expected ErrCmdArgRegexpMismatch for partial int match, got %v", err)
|
||||
}
|
||||
|
||||
boolCmd := NewCommand[NoDB](func(ctx *MsgContext, db NoDB) {}, "bool", NewCommandArg("flag").SetValueType(CommandValueBoolType).SetRequired())
|
||||
boolCmd := NewCommand[NoDB](func(ctx *MsgContext, db NoDB) error { return nil }, "bool", NewCommandArg("flag").SetValueType(CommandValueBoolType).SetRequired())
|
||||
if err := boolCmd.validateArgs([]string{"false"}); err != nil {
|
||||
t.Fatalf("expected valid bool argument, got %v", err)
|
||||
}
|
||||
@@ -25,7 +25,7 @@ func TestValidateArgsRequiresFullMatch(t *testing.T) {
|
||||
|
||||
func TestValidateArgsEnforcesRequiredArgIndex(t *testing.T) {
|
||||
cmd := NewCommand[NoDB](
|
||||
func(ctx *MsgContext, db NoDB) {},
|
||||
func(ctx *MsgContext, db NoDB) error { return nil },
|
||||
"mixed",
|
||||
NewCommandArg("optional"),
|
||||
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.
|
||||
// See https://core.telegram.org/bots/api#chatboost
|
||||
type ChatBoost struct {
|
||||
BoostID int `json:"boost_id"`
|
||||
BoostID string `json:"boost_id"`
|
||||
AddDate int `json:"add_date"`
|
||||
ExpirationDate int `json:"expiration_date"`
|
||||
Source ChatBoostSource `json:"source"`
|
||||
|
||||
+23
-1
@@ -37,9 +37,28 @@ func TestUpdateUnmarshalSetsType(t *testing.T) {
|
||||
}`,
|
||||
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",
|
||||
body: `{"update_id":3}`,
|
||||
body: `{"update_id":4}`,
|
||||
want: UpdateTypeUnknown,
|
||||
},
|
||||
}
|
||||
@@ -53,6 +72,9 @@ func TestUpdateUnmarshalSetsType(t *testing.T) {
|
||||
if 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 (
|
||||
// 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 = 1
|
||||
// VersionMinor is the module minor version.
|
||||
@@ -10,5 +10,5 @@ const (
|
||||
// VersionPatch is the module patch version.
|
||||
VersionPatch = 0
|
||||
// VersionBeta is the prerelease counter for the current version.
|
||||
VersionBeta = 10
|
||||
VersionBeta = 12
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user