wip: add long message helpers and payload type controls
- add centralized message validation errors - switch command handlers to return error - add explicit long plain-text reply helpers - support strict payload type policy with debug logging - document versioning and changelog workflow
This commit is contained in:
@@ -76,6 +76,30 @@ 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, update `CHANGELOG.md`.
|
||||||
|
- 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.
|
||||||
|
|
||||||
## Output format
|
## Output format
|
||||||
For repo-wide review tasks, structure the result as:
|
For repo-wide review tasks, structure the result as:
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,28 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
- `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.
|
||||||
|
- 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
|
## v1.0.0-rc.11
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -52,10 +52,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 +82,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 +109,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 +130,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 +142,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 +228,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.
|
||||||
|
|||||||
+11
-6
@@ -53,10 +53,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 +83,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 +110,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 +131,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 +142,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 +225,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.
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ type Bot[T DbContext] struct {
|
|||||||
errorTemplate string
|
errorTemplate string
|
||||||
username string
|
username string
|
||||||
payloadType BotPayloadType
|
payloadType BotPayloadType
|
||||||
|
strictPayloadType bool
|
||||||
maxWorkers int
|
maxWorkers int
|
||||||
|
|
||||||
logger *slog.Logger // Main bot logger (JSON stdout + optional file)
|
logger *slog.Logger // Main bot logger (JSON stdout + optional file)
|
||||||
@@ -159,6 +160,7 @@ func NewBot[T any](opts *BotOpts) (*Bot[T], error) {
|
|||||||
updateOffset: 0,
|
updateOffset: 0,
|
||||||
errorTemplate: "%s",
|
errorTemplate: "%s",
|
||||||
payloadType: BotPayloadBase64,
|
payloadType: BotPayloadBase64,
|
||||||
|
strictPayloadType: opts.StrictPayloadType,
|
||||||
maxWorkers: workers,
|
maxWorkers: workers,
|
||||||
updateQueue: updateQueue,
|
updateQueue: updateQueue,
|
||||||
api: api,
|
api: api,
|
||||||
@@ -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] {
|
||||||
|
|||||||
+13
@@ -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.
|
||||||
@@ -118,6 +123,7 @@ func LoadOptsFromEnv() *BotOpts {
|
|||||||
|
|
||||||
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
|
||||||
|
}
|
||||||
+38
-7
@@ -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)
|
||||||
case BotPayloadJson:
|
if err == nil {
|
||||||
return decodeJsonPayload(s)
|
return data, BotPayloadBase64, nil
|
||||||
}
|
}
|
||||||
return CallbackData{}, ErrInvalidPayloadType
|
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:
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
||||||
|
}
|
||||||
+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.11"
|
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 = 11
|
VersionBeta = 12
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user