(draft): telegram markdown v2 string builder
Golang lint / lint (push) Successful in 4m51s

This commit is contained in:
2026-04-30 13:56:36 +03:00
parent 269ccec007
commit 071fc2375e
34 changed files with 1227 additions and 385 deletions
+13 -1
View File
@@ -2,15 +2,27 @@
## v1.0.0 ## v1.0.0
### Breaking Changes
- Renamed final public APIs to idiomatic names before the stable release: `RunWebhookWithContext(...)`, `RunWebhook(...)`, `CloseWebhook()`, `BotWebhookOpts`, `NewBotWebhookOpts()`, `SetWebhookLogger(...)`, and `GetWebhookLogger()`.
- Renamed plugin builder helpers from `NewCommand(...)`, `NewPayload(...)`, and `NewScene(...)` to `Command(...)`, `Payload(...)`, and `Scene(...)`; `NewCommand(...)` and `NewPayload(...)` now take the command string before the executor.
- Renamed command argument value constants to `CommandValueString`, `CommandValueInt`, `CommandValueBool`, and `CommandValueAny`; `NewCommandArg(...)` now defaults to unvalidated `CommandValueAny`.
- Renamed runner builders from `Onetime(...)` and `Timeout(...)` to `Once(...)` and `Every(...)`.
- Renamed remaining public acronym/casing outliers including `AnswerCallback...`, `ParseMarkdownV2`, `ParseMarkdown`, `GetChatMemberCount`, `DropRateLimitOverflow`, `SetDropRateLimitOverflow`, and inline keyboard builder APIs.
### Added ### Added
- Added `MsgContext.IsCallback()` and `MsgContext.HasPhoto()` helpers for callback-aware handler code. - Added `MsgContext.IsCallback()` and `MsgContext.HasPhoto()` helpers for callback-aware handler code.
- Added `MsgContext.UpsertKeyboard(...)` and `MsgContext.UpsertKeyboardMarkdown(...)` helpers that edit callback messages, replace photo callback messages with a fresh chat message, and send a new chat message outside callback flow. - Added `MsgContext.UpsertKeyboard(...)` and `MsgContext.UpsertKeyboardMarkdown(...)` helpers that edit callback messages, replace photo callback messages with a fresh chat message, and send a new chat message outside callback flow.
- Added `CommandGroup`, `NewCommandGroup(...)`, `Plugin.CommandGroup(...)`, and `Plugin.AddCommandGroup(...)` helpers for registering prefixed command groups with shared middleware. - Added `CommandGroup`, `NewCommandGroup(...)`, `Plugin.CommandGroup(...)`, and `Plugin.AddCommandGroup(...)` helpers for registering prefixed command groups with shared middleware.
- Added the `tgmd` package with Telegram Markdown formatting helpers and a message entity builder.
### Changed ### Changed
- Version metadata now reports the stable `v1.0.0` release instead of `v1.0.0-rc.16`. - Version metadata now reports the stable `v1.0.0` release instead of `v1.0.0-rc.16`.
- Bot-level middleware blocks now emit a final `UpdateHandledEvent` with `Handled=false`, keeping observer update lifecycles balanced. - Bot-level middleware blocks now emit a final `UpdateHandledEvent` with `Handled=false`, keeping observer update lifecycles balanced.
- `BotOpts`, `tgapi.APIOpts`, and logger utility godoc now document `LOG_FORMAT`, `LogFormat`, and logger formatting options consistently. - `BotOpts`, `tgapi.APIOpts`, logger utilities, README, and wiki pages now document the final stable API names and configuration options consistently.
### Fixed
- Fixed webhook startup so empty-secret warnings are logged only after the webhook logger is initialized.
- Fixed webhook startup so a logger configured through `SetWebhookLogger(...)` is preserved.
### Tests ### Tests
- Added regression coverage proving bot-level middleware blocks still complete the observer update lifecycle. - Added regression coverage proving bot-level middleware blocks still complete the observer update lifecycle.
+18 -18
View File
@@ -23,7 +23,7 @@ A lightweight, easy-to-use, and performant Telegram Bot API wrapper for Go. It s
* **Built-in Rate Limiting:** Protect your bot from hitting Telegram API limits (supports `retry_after` handling). * **Built-in Rate Limiting:** Protect your bot from hitting Telegram API limits (supports `retry_after` handling).
* **Context-Aware:** Pass custom application data or state contexts to your handlers. * **Context-Aware:** Pass custom application data or state contexts to your handlers.
* **Configurable API:** Mix `Set...` and `Add...` helpers to configure bots clearly (for example, `bot.SetErrorTemplate(...).AddPlugins(...)`). * **Configurable API:** Mix `Set...` and `Add...` helpers to configure bots clearly (for example, `bot.SetErrorTemplate(...).AddPlugins(...)`).
* **Polling and Webhook Runtime:** Run bots through long polling with `Run()` / `RunWithContext(...)` or through a bot-owned webhook server with `RunWebHookWithContext(...)`. * **Polling and Webhook Runtime:** Run bots through long polling with `Run()` / `RunWithContext(...)` or through a bot-owned webhook server with `RunWebhookWithContext(...)`.
--- ---
@@ -80,15 +80,15 @@ func main() {
p := laniakea.NewPlugin[laniakea.NoData]("ping") p := laniakea.NewPlugin[laniakea.NoData]("ping")
// 4. Add a command to the plugin. // 4. Add a command to the plugin.
// p.NewCommand(echo, "echo") creates a command that triggers the 'echo' function on the "/echo" command. // p.Command("echo", echo) creates a command that triggers the 'echo' function on the "/echo" command.
p.AddCommand(p.NewCommand(echo, "echo")) p.Command("echo", echo)
// 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, data laniakea.NoData) error { p.Command("ping", func(ctx *laniakea.MsgContext, data laniakea.NoData) error {
ctx.Answer("Pong") ctx.Answer("Pong")
return nil return nil
}, "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.
// SetErrorTemplate sets a format string for errors (where %s will be replaced by the actual error). // SetErrorTemplate sets a format string for errors (where %s will be replaced by the actual error).
@@ -112,13 +112,13 @@ 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 custom shared application data (for example, *sql.DB or a service container) that will be available in all handlers. Use laniakea.NoData if you don't need it. 2. `NewBot[T]`: Creates a bot instance. The type parameter T allows you to pass custom shared application data (for example, *sql.DB or a service container) that will be available in all handlers. Use laniakea.NoData if you don't need it.
3. `NewPlugin`: Creates a logical group for commands and middlewares. 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) error`), the second is the command name (without the slash). 4. `Command`: Creates and registers a command. The first argument is the command name without the slash, the second is the handler function (`func(*MsgContext, T) error`).
5. **Handler Functions**: Receive *MsgContext (message details, methods like Answer) and your custom application data T, and return an error for centralized error handling. 5. **Handler Functions**: Receive *MsgContext (message details, methods like Answer) and your custom application data T, and return an error for centralized error handling.
6. `SetErrorTemplate`: Sets a template for error messages. The %s placeholder is replaced by the actual error. 6. `SetErrorTemplate`: Sets a template for error messages. The %s placeholder is replaced by the actual error.
7. `AutoGenerateCommands`: Registers plugin-defined commands with Telegram across the supported scopes. 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.
9. `RunWebHookWithContext(...)`: Starts the bot-owned webhook runtime when Telegram should deliver updates over HTTP instead of long polling. 9. `RunWebhookWithContext(...)`: Starts the bot-owned webhook runtime when Telegram should deliver updates over HTTP instead of long polling.
10. A `Bot` instance is single-use. After `Run()`, `RunWithContext()`, or `RunWebHookWithContext()` returns, create a new bot instance for the next session. 10. A `Bot` instance is single-use. After `Run()`, `RunWithContext()`, or `RunWebhookWithContext()` returns, create a new bot instance for the next session.
## File-Based Config ## File-Based Config
@@ -151,7 +151,7 @@ See the full guide in the wiki: [Bot Options and Configuration](https://git.scur
## Webhook Runtime ## Webhook Runtime
Laniakea also supports a bot-owned webhook runtime through `RunWebHookWithContext(...)` and `RunWebHook(...)`. Laniakea also supports a bot-owned webhook runtime through `RunWebhookWithContext(...)` and `RunWebhook(...)`.
Use it when: Use it when:
- Telegram should push updates to your HTTP endpoint instead of your bot polling for them. - Telegram should push updates to your HTTP endpoint instead of your bot polling for them.
@@ -159,11 +159,11 @@ Use it when:
- You want Laniakea to register the webhook and own the local HTTP server. - You want Laniakea to register the webhook and own the local HTTP server.
Production notes: Production notes:
- Set `BotWebHookOpts.SecretToken` for request authentication. - Set `BotWebhookOpts.SecretToken` for request authentication.
- `BotWebHookOpts.SecretToken` is required when `BotWebHookOpts.UseStatusPath` is enabled. - `BotWebhookOpts.SecretToken` is required when `BotWebhookOpts.UseStatusPath` is enabled.
- Keep `BotWebHookOpts.Path` specific instead of serving webhook traffic on `/`. - Keep `BotWebhookOpts.Path` specific instead of serving webhook traffic on `/`.
- If you switch an existing deployment from webhook mode to long polling, delete the webhook first with `CloseWebHook()` or `tgapi.DeleteWebhook(...)`. Telegram keeps webhook delivery active until it is removed. - If you switch an existing deployment from webhook mode to long polling, delete the webhook first with `CloseWebhook()` or `tgapi.DeleteWebhook(...)`. Telegram keeps webhook delivery active until it is removed.
- Use `RunWebHookWithContext(...)` with a cancelable context, then call `Close()` after runtime shutdown. - Use `RunWebhookWithContext(...)` with a cancelable context, then call `Close()` after runtime shutdown.
See the full guide in the wiki: [Webhook Runtime](https://git.scuroneko.dev/ScuroNeko/Laniakea/wiki/Webhook-Runtime) See the full guide in the wiki: [Webhook Runtime](https://git.scuroneko.dev/ScuroNeko/Laniakea/wiki/Webhook-Runtime)
@@ -173,7 +173,7 @@ See the full guide in the wiki: [Webhook Runtime](https://git.scuroneko.dev/Scur
Plugins are the main way to organize code. A plugin can have multiple commands and middlewares. Plugins are the main way to organize code. A plugin can have multiple commands and middlewares.
```go ```go
plugin := laniakea.NewPlugin[*MyDB]("admin") plugin := laniakea.NewPlugin[*MyDB]("admin")
plugin.AddCommand(plugin.NewCommand(banUser, "ban")) plugin.Command("ban", banUser)
bot.AddPlugins(plugin) bot.AddPlugins(plugin)
``` ```
@@ -238,7 +238,7 @@ Scenes model multi-step conversations inside a plugin. Each active scene is stor
```go ```go
plugin := laniakea.NewPlugin[MyDB]("signup") plugin := laniakea.NewPlugin[MyDB]("signup")
plugin.NewScene("signup"). plugin.Scene("signup").
SetScope(laniakea.SceneScopeUserChat). SetScope(laniakea.SceneScopeUserChat).
SetEntry("ask_name"). SetEntry("ask_name").
OnStep("ask_name", func(ctx *laniakea.SceneContext, db MyDB) (laniakea.SceneResult, error) { OnStep("ask_name", func(ctx *laniakea.SceneContext, db MyDB) (laniakea.SceneResult, error) {
@@ -288,7 +288,7 @@ Use `AddMiddleware` on a plugin to add one or more shared middleware functions.
plugin := laniakea.NewPlugin[*MyDB]("admin") plugin := laniakea.NewPlugin[*MyDB]("admin")
plugin.AddMiddleware(laniakea.NewMiddleware("logging", loggingMiddleware)) plugin.AddMiddleware(laniakea.NewMiddleware("logging", loggingMiddleware))
plugin.AddMiddleware(laniakea.NewMiddleware("admin-only", adminOnlyMiddleware)) plugin.AddMiddleware(laniakea.NewMiddleware("admin-only", adminOnlyMiddleware))
plugin.AddCommand(plugin.NewCommand(banUser, "ban")) plugin.Command("ban", banUser)
``` ```
### Example Middlewares ### Example Middlewares
@@ -320,7 +320,7 @@ func adminOnlyMiddleware(ctx *laniakea.MsgContext, db *MyDB) bool {
- **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.
- **Lifecycle**: `RunWithContext(...)` and `RunWebHookWithContext(...)` do not call `Close()` for you. Shut the bot down explicitly, and create a fresh `Bot` for the next run. - **Lifecycle**: `RunWithContext(...)` and `RunWebhookWithContext(...)` do not call `Close()` for you. Shut the bot down explicitly, and create a fresh `Bot` for the next run.
## Telegram Update Handling ## Telegram Update Handling
- Commands and payloads are handled through plugins. - Commands and payloads are handled through plugins.
+18 -18
View File
@@ -24,7 +24,7 @@
* **Встроенный ограничитель запросов (Rate Limiter):** Защитите бота от превышения лимитов Telegram API (с обработкой `retry_after`). * **Встроенный ограничитель запросов (Rate Limiter):** Защитите бота от превышения лимитов Telegram API (с обработкой `retry_after`).
* **Контекст данных:** Передавайте общие данные приложения или state в обработчики. * **Контекст данных:** Передавайте общие данные приложения или state в обработчики.
* **Настраиваемый API:** Комбинируйте `Set...` и `Add...` helper-методы для понятной конфигурации, например `bot.SetErrorTemplate(...).AddPlugins(...)`. * **Настраиваемый API:** Комбинируйте `Set...` и `Add...` helper-методы для понятной конфигурации, например `bot.SetErrorTemplate(...).AddPlugins(...)`.
* **Polling и Webhook Runtime:** Запускайте бота через long polling с `Run()` / `RunWithContext(...)` или через webhook server, которым владеет сам бот, с `RunWebHookWithContext(...)`. * **Polling и Webhook Runtime:** Запускайте бота через long polling с `Run()` / `RunWithContext(...)` или через webhook server, которым владеет сам бот, с `RunWebhookWithContext(...)`.
--- ---
@@ -81,15 +81,15 @@ func main() {
p := laniakea.NewPlugin[laniakea.NoData]("ping") p := laniakea.NewPlugin[laniakea.NoData]("ping")
// 4. Добавляем команду в плагин. // 4. Добавляем команду в плагин.
// p.NewCommand(echo, "echo") создаёт команду, которая вызывает функцию 'echo' по команде "/echo". // p.Command("echo", echo) создаёт команду, которая вызывает функцию 'echo' по команде "/echo".
p.AddCommand(p.NewCommand(echo, "echo")) p.Command("echo", echo)
// 5. Добавляем ещё одну команду, используя анонимную функцию (замыкание). // 5. Добавляем ещё одну команду, используя анонимную функцию (замыкание).
// Эта команда просто отвечает "Pong", когда пользователь отправляет "/ping". // Эта команда просто отвечает "Pong", когда пользователь отправляет "/ping".
p.AddCommand(p.NewCommand(func(ctx *laniakea.MsgContext, data laniakea.NoData) error { p.Command("ping", func(ctx *laniakea.MsgContext, data laniakea.NoData) error {
ctx.Answer("Pong") ctx.Answer("Pong")
return nil return nil
}, "ping")) })
// 6. Настраиваем бота: задаём шаблон ошибки и добавляем плагин. // 6. Настраиваем бота: задаём шаблон ошибки и добавляем плагин.
// SetErrorTemplate устанавливает формат для сообщений об ошибках (где %s будет заменён на текст ошибки). // SetErrorTemplate устанавливает формат для сообщений об ошибках (где %s будет заменён на текст ошибки).
@@ -113,13 +113,13 @@ func main() {
1. `BotOpts`: Содержит конфигурацию, например, токен API. 1. `BotOpts`: Содержит конфигурацию, например, токен API.
2. `NewBot[T]`: Создаёт экземпляр бота. Параметр типа T позволяет передать общие данные приложения (например, *sql.DB или контейнер сервисов), которые будут доступны во всех обработчиках. Используйте laniakea.NoData, если они не нужны. 2. `NewBot[T]`: Создаёт экземпляр бота. Параметр типа T позволяет передать общие данные приложения (например, *sql.DB или контейнер сервисов), которые будут доступны во всех обработчиках. Используйте laniakea.NoData, если они не нужны.
3. `NewPlugin`: Создаёт логическую группу для команд и Middleware. 3. `NewPlugin`: Создаёт логическую группу для команд и Middleware.
4. `AddCommand`: Регистрирует команду. Первый аргумент — функция-обработчик (`func(*MsgContext, T) error`), второй — имя команды (без слеша). 4. `Command`: Создаёт и регистрирует команду. Первый аргумент — имя команды без слеша, второй — функция-обработчик (`func(*MsgContext, T) error`).
5. **Функции-обработчики**: Получают *MsgContext (детали сообщения, методы типа Answer) и ваши данные приложения типа T, а ошибку возвращают для централизованной обработки. 5. **Функции-обработчики**: Получают *MsgContext (детали сообщения, методы типа Answer) и ваши данные приложения типа T, а ошибку возвращают для централизованной обработки.
6. `SetErrorTemplate`: Устанавливает шаблон для сообщений об ошибках. Плейсхолдер %s заменяется на текст ошибки. 6. `SetErrorTemplate`: Устанавливает шаблон для сообщений об ошибках. Плейсхолдер %s заменяется на текст ошибки.
7. `AutoGenerateCommands`: Регистрирует команды из плагинов в Telegram для поддерживаемых scope. 7. `AutoGenerateCommands`: Регистрирует команды из плагинов в Telegram для поддерживаемых scope.
8. `Run()`: Запускает цикл опроса обновлений бота и возвращает ошибку, если старт или polling завершился неуспешно. 8. `Run()`: Запускает цикл опроса обновлений бота и возвращает ошибку, если старт или polling завершился неуспешно.
9. `RunWebHookWithContext(...)`: Запускает bot-owned webhook runtime, когда Telegram должен доставлять update по HTTP вместо long polling. 9. `RunWebhookWithContext(...)`: Запускает bot-owned webhook runtime, когда Telegram должен доставлять update по HTTP вместо long polling.
10. Экземпляр `Bot` одноразовый. После завершения `Run()`, `RunWithContext()` или `RunWebHookWithContext()` для следующего запуска создавайте новый бот. 10. Экземпляр `Bot` одноразовый. После завершения `Run()`, `RunWithContext()` или `RunWebhookWithContext()` для следующего запуска создавайте новый бот.
## Конфиг из файла ## Конфиг из файла
@@ -152,7 +152,7 @@ if err != nil {
## Webhook Runtime ## Webhook Runtime
Laniakea также поддерживает bot-owned webhook runtime через `RunWebHookWithContext(...)` и `RunWebHook(...)`. Laniakea также поддерживает bot-owned webhook runtime через `RunWebhookWithContext(...)` и `RunWebhook(...)`.
Используй его, когда: Используй его, когда:
- Telegram должен сам отправлять update на твой HTTP endpoint вместо polling. - Telegram должен сам отправлять update на твой HTTP endpoint вместо polling.
@@ -160,11 +160,11 @@ Laniakea также поддерживает bot-owned webhook runtime чере
- Ты хочешь, чтобы Laniakea сама регистрировала webhook и владела локальным HTTP server. - Ты хочешь, чтобы Laniakea сама регистрировала webhook и владела локальным HTTP server.
Практические замечания: Практические замечания:
- Задавай `BotWebHookOpts.SecretToken` для аутентификации запросов. - Задавай `BotWebhookOpts.SecretToken` для аутентификации запросов.
- Непустой `BotWebHookOpts.SecretToken` обязателен, если включён `BotWebHookOpts.UseStatusPath`. - Непустой `BotWebhookOpts.SecretToken` обязателен, если включён `BotWebhookOpts.UseStatusPath`.
- Используй явный `BotWebHookOpts.Path`, а не `/`. - Используй явный `BotWebhookOpts.Path`, а не `/`.
- Если ты переводишь уже существующий deployment с webhook-режима на long polling, сначала удали webhook через `CloseWebHook()` или `tgapi.DeleteWebhook(...)`. Пока webhook не удалён, Telegram продолжает доставку через него. - Если ты переводишь уже существующий deployment с webhook-режима на long polling, сначала удали webhook через `CloseWebhook()` или `tgapi.DeleteWebhook(...)`. Пока webhook не удалён, Telegram продолжает доставку через него.
- Запускай `RunWebHookWithContext(...)` с cancelable context и после остановки runtime всё равно вызывай `Close()`. - Запускай `RunWebhookWithContext(...)` с cancelable context и после остановки runtime всё равно вызывай `Close()`.
Полное руководство есть в wiki: [Webhook Runtime](https://git.scuroneko.dev/ScuroNeko/Laniakea/wiki/Webhook-Runtime-RU) Полное руководство есть в wiki: [Webhook Runtime](https://git.scuroneko.dev/ScuroNeko/Laniakea/wiki/Webhook-Runtime-RU)
@@ -174,7 +174,7 @@ Laniakea также поддерживает bot-owned webhook runtime чере
```go ```go
plugin := laniakea.NewPlugin[*MyDB]("admin") plugin := laniakea.NewPlugin[*MyDB]("admin")
plugin.AddCommand(plugin.NewCommand(banUser, "ban")) plugin.Command("ban", banUser)
bot.AddPlugins(plugin) bot.AddPlugins(plugin)
``` ```
@@ -226,7 +226,7 @@ bot.SetAppData(db)
```go ```go
plugin := laniakea.NewPlugin[MyDB]("signup") plugin := laniakea.NewPlugin[MyDB]("signup")
plugin.NewScene("signup"). plugin.Scene("signup").
SetScope(laniakea.SceneScopeUserChat). SetScope(laniakea.SceneScopeUserChat).
SetEntry("ask_name"). SetEntry("ask_name").
OnStep("ask_name", func(ctx *laniakea.SceneContext, db MyDB) (laniakea.SceneResult, error) { OnStep("ask_name", func(ctx *laniakea.SceneContext, db MyDB) (laniakea.SceneResult, error) {
@@ -285,7 +285,7 @@ func(ctx *MsgContext, db T) bool
plugin := laniakea.NewPlugin[*MyDB]("admin") plugin := laniakea.NewPlugin[*MyDB]("admin")
plugin.AddMiddleware(laniakea.NewMiddleware("logging", loggingMiddleware)) plugin.AddMiddleware(laniakea.NewMiddleware("logging", loggingMiddleware))
plugin.AddMiddleware(laniakea.NewMiddleware("admin-only", adminOnlyMiddleware)) plugin.AddMiddleware(laniakea.NewMiddleware("admin-only", adminOnlyMiddleware))
plugin.AddCommand(plugin.NewCommand(banUser, "ban")) plugin.Command("ban", banUser)
``` ```
### Примеры middleware ### Примеры middleware
@@ -317,7 +317,7 @@ func adminOnlyMiddleware(ctx *laniakea.MsgContext, db *MyDB) bool {
- **Ограничение запросов**: Передайте настроенный `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.
- **Жизненный цикл**: `RunWithContext(...)` и `RunWebHookWithContext(...)` не вызывают `Close()` автоматически. Завершайте бот явно и создавайте новый `Bot` для следующего запуска. - **Жизненный цикл**: `RunWithContext(...)` и `RunWebhookWithContext(...)` не вызывают `Close()` автоматически. Завершайте бот явно и создавайте новый `Bot` для следующего запуска.
## Обработка Telegram Updates ## Обработка Telegram Updates
- Команды и payload-ы обрабатываются через плагины. - Команды и payload-ы обрабатываются через плагины.
+23 -19
View File
@@ -59,7 +59,7 @@ var (
ErrNoPrefixes = errors.New("no prefixes defined") ErrNoPrefixes = errors.New("no prefixes defined")
// ErrNoPlugins reports that the bot was started without any registered plugins. // ErrNoPlugins reports that the bot was started without any registered plugins.
ErrNoPlugins = errors.New("no plugins defined") ErrNoPlugins = errors.New("no plugins defined")
// ErrBotAlreadyRun reports that Run, RunWithContext, or RunWebHookWithContext was called more than once. // ErrBotAlreadyRun reports that Run, RunWithContext, or RunWebhookWithContext was called more than once.
ErrBotAlreadyRun = errors.New("bot can only be run once") ErrBotAlreadyRun = errors.New("bot can only be run once")
// ErrTokenRequired reports that BotOpts.Token was empty. // ErrTokenRequired reports that BotOpts.Token was empty.
@@ -78,8 +78,8 @@ var (
// - Localization and draft message support // - Localization and draft message support
// //
// Runtime accessors are safe for concurrent use. Configure the bot before Run, // Runtime accessors are safe for concurrent use. Configure the bot before Run,
// RunWithContext, or RunWebHookWithContext. // RunWithContext, or RunWebhookWithContext.
// A Bot is single-use: after Run, RunWithContext, or RunWebHookWithContext returns, // A Bot is single-use: after Run, RunWithContext, or RunWebhookWithContext returns,
// create a new Bot for the next session. // create a new Bot for the next session.
type Bot[T AppData] struct { type Bot[T AppData] struct {
token string token string
@@ -95,7 +95,7 @@ type Bot[T AppData] struct {
logger *sneklog.Logger // Main bot logger (JSON stdout + optional file) logger *sneklog.Logger // Main bot logger (JSON stdout + optional file)
requestLogger *sneklog.Logger // Optional request-level API logging requestLogger *sneklog.Logger // Optional request-level API logging
useReqLogger bool useReqLogger bool
webHookLogger *sneklog.Logger // Webhook logger. Available only after Bot.RunWebHookWithContext. webhookLogger *sneklog.Logger // Webhook logger. Available only after Bot.RunWebhookWithContext.
extraLoggers extypes.Slice[*sneklog.Logger] // API, Uploader, and custom loggers extraLoggers extypes.Slice[*sneklog.Logger] // API, Uploader, and custom loggers
plugins []Plugin[T] // Command/event handlers plugins []Plugin[T] // Command/event handlers
@@ -164,7 +164,7 @@ func NewBot[T any](opts *BotOpts) (*Bot[T], error) {
SetAPIURL(opts.APIURL). SetAPIURL(opts.APIURL).
UseTestServer(opts.UseTestServer). UseTestServer(opts.UseTestServer).
SetLimiter(limiter). SetLimiter(limiter).
SetLimiterDrop(opts.DropRLOverflow). SetDropRateLimitOverflow(opts.DropRateLimitOverflow).
SetLogFormat(opts.LogFormat). SetLogFormat(opts.LogFormat).
SetLogFormatter(opts.LogFormatter) SetLogFormatter(opts.LogFormatter)
api := tgapi.NewAPI(apiOpts) api := tgapi.NewAPI(apiOpts)
@@ -256,12 +256,16 @@ func (bot *Bot[T]) SetRequestLogger(l *sneklog.Logger) *Bot[T] {
return bot return bot
} }
// SetWebHookLogger replaces the webhook logger. // SetWebhookLogger replaces the webhook logger.
func (bot *Bot[T]) SetWebHookLogger(l *sneklog.Logger) *Bot[T] { func (bot *Bot[T]) SetWebhookLogger(l *sneklog.Logger) *Bot[T] {
bot.webHookLogger = l bot.webhookLogger = l
return bot return bot
} }
func (bot *Bot[T]) GetAPI() *tgapi.API { return bot.api }
func (bot *Bot[T]) GetUploader() *tgapi.Uploader { return bot.uploader }
// Close gracefully shuts down bot-owned resources. // Close gracefully shuts down bot-owned resources.
// //
// Close shuts down, in order: // Close shuts down, in order:
@@ -272,7 +276,7 @@ func (bot *Bot[T]) SetWebHookLogger(l *sneklog.Logger) *Bot[T] {
// - RequestLogger (if enabled) // - RequestLogger (if enabled)
// - Main logger // - Main logger
// //
// RunWithContext and RunWebHookWithContext do not call Close automatically. // RunWithContext and RunWebhookWithContext do not call Close automatically.
// The caller is responsible for invoking Close after runtime returns to release // The caller is responsible for invoking Close after runtime returns to release
// these resources. // these resources.
// //
@@ -294,11 +298,11 @@ func (bot *Bot[T]) Close() error {
e = append(e, err) e = append(e, err)
} }
} }
if bot.webHookLogger != nil { if bot.webhookLogger != nil {
if err := bot.webHookLogger.Close(); err != nil { if err := bot.webhookLogger.Close(); err != nil {
logCloseErr(err) logCloseErr(err)
} }
bot.webHookLogger = nil bot.webhookLogger = nil
} }
if bot.uploader != nil { if bot.uploader != nil {
if err := bot.uploader.Close(); err != nil { if err := bot.uploader.Close(); err != nil {
@@ -354,8 +358,8 @@ func (bot *Bot[T]) GetLogger() *sneklog.Logger { return bot.logger }
// GetRequestLogger returns the request-level logger, if configured. // GetRequestLogger returns the request-level logger, if configured.
func (bot *Bot[T]) GetRequestLogger() *sneklog.Logger { return bot.requestLogger } func (bot *Bot[T]) GetRequestLogger() *sneklog.Logger { return bot.requestLogger }
// GetWebHookLogger returns the webhook logger, if configured. // GetWebhookLogger returns the webhook logger, if configured.
func (bot *Bot[T]) GetWebHookLogger() *sneklog.Logger { return bot.webHookLogger } func (bot *Bot[T]) GetWebhookLogger() *sneklog.Logger { return bot.webhookLogger }
// GetLoggerLevel returns the effective log level derived from the bot's debug // GetLoggerLevel returns the effective log level derived from the bot's debug
// flag. // flag.
@@ -387,7 +391,7 @@ func (bot *Bot[T]) L10n(lang, key string) string {
// - Waits for registered runners to exit // - Waits for registered runners to exit
// //
// If you are switching an existing deployment from webhook delivery to polling, // If you are switching an existing deployment from webhook delivery to polling,
// delete the current webhook first with CloseWebHook or tgapi.DeleteWebhook. // delete the current webhook first with CloseWebhook or tgapi.DeleteWebhook.
// Telegram keeps webhook delivery active until the webhook is removed. // Telegram keeps webhook delivery active until the webhook is removed.
// //
// RunWithContext does not close API, uploader, or logger resources on return. // RunWithContext does not close API, uploader, or logger resources on return.
@@ -414,13 +418,13 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) error {
} }
bot.requestLogger = nil bot.requestLogger = nil
} }
if bot.webHookLogger != nil { if bot.webhookLogger != nil {
bot.logger.Warnln("Bot#webHookLogger present. You shouldn't set this, if ran in Long Polling mode!") bot.logger.Warnln("Bot#webhookLogger present. You shouldn't set this, if ran in Long Polling mode!")
err := bot.webHookLogger.Close() err := bot.webhookLogger.Close()
if err != nil { if err != nil {
bot.logger.Errorln(err) bot.logger.Errorln(err)
} }
bot.webHookLogger = nil bot.webhookLogger = nil
} }
bot.ExecRunners(ctx) bot.ExecRunners(ctx)
+8 -8
View File
@@ -54,9 +54,9 @@ type BotOpts struct {
// Telegram allows up to 30 req/s for most bots. Defaults to 30. // Telegram allows up to 30 req/s for most bots. Defaults to 30.
RateLimit int RateLimit int
// DropRLOverflow drops incoming updates when rate limit is exceeded instead of queuing. // DropRateLimitOverflow drops incoming updates when rate limit is exceeded instead of queuing.
// Use this to prioritize responsiveness over reliability. // Use this to prioritize responsiveness over reliability.
DropRLOverflow bool DropRateLimitOverflow bool
// StrictPayloadType disables callback payload fallback decoding. // StrictPayloadType disables callback payload fallback decoding.
// When enabled, the bot accepts only the configured default payload type. // When enabled, the bot accepts only the configured default payload type.
@@ -135,9 +135,9 @@ func LoadOptsFromEnv() *BotOpts {
UseTestServer: os.Getenv("USE_TEST_SERVER") == "true", UseTestServer: os.Getenv("USE_TEST_SERVER") == "true",
APIURL: os.Getenv("API_URL"), APIURL: os.Getenv("API_URL"),
RateLimit: rateLimit, RateLimit: rateLimit,
DropRLOverflow: os.Getenv("DROP_RL_OVERFLOW") == "true", DropRateLimitOverflow: os.Getenv("DROP_RL_OVERFLOW") == "true",
StrictPayloadType: os.Getenv("STRICT_PAYLOAD_TYPE") == "true", StrictPayloadType: os.Getenv("STRICT_PAYLOAD_TYPE") == "true",
MaxWorkers: maxWorkers, MaxWorkers: maxWorkers,
FileConfigVersion: 0, FileConfigVersion: 0,
@@ -223,10 +223,10 @@ func (opts *BotOpts) SetRateLimit(limit int) *BotOpts {
return opts return opts
} }
// SetDropRLOverflow drops incoming updates when rate limit is exceeded instead of queuing. // SetDropRateLimitOverflow drops incoming updates when rate limit is exceeded instead of queuing.
// Use this to prioritize responsiveness over reliability. Default is false. // Use this to prioritize responsiveness over reliability. Default is false.
func (opts *BotOpts) SetDropRLOverflow(drop bool) *BotOpts { func (opts *BotOpts) SetDropRateLimitOverflow(drop bool) *BotOpts {
opts.DropRLOverflow = drop opts.DropRateLimitOverflow = drop
return opts return opts
} }
+8 -5
View File
@@ -70,10 +70,10 @@ func (codec BotOptsFileJSONCodec) FromBytes(data []byte) (*BotOpts, error) {
WriteToFile: fileOpts.Logger.WriteToFile, WriteToFile: fileOpts.Logger.WriteToFile,
LogFormat: fileOpts.Logger.LogFormat, LogFormat: fileOpts.Logger.LogFormat,
UseTestServer: fileOpts.API.UseTestServer, UseTestServer: fileOpts.API.UseTestServer,
APIURL: fileOpts.API.APIURL, APIURL: fileOpts.API.APIURL,
RateLimit: fileOpts.API.RateLimit, RateLimit: fileOpts.API.RateLimit,
DropRLOverflow: fileOpts.API.DropRLOverflow, DropRateLimitOverflow: fileOpts.API.DropRLOverflow,
StrictPayloadType: fileOpts.StrictPayloadType, StrictPayloadType: fileOpts.StrictPayloadType,
MaxWorkers: fileOpts.MaxWorkers, MaxWorkers: fileOpts.MaxWorkers,
@@ -102,7 +102,7 @@ func (codec BotOptsFileJSONCodec) ToBytes(opts *BotOpts) ([]byte, error) {
UseTestServer: opts.UseTestServer, UseTestServer: opts.UseTestServer,
APIURL: opts.APIURL, APIURL: opts.APIURL,
RateLimit: opts.RateLimit, RateLimit: opts.RateLimit,
DropRLOverflow: opts.DropRLOverflow, DropRLOverflow: opts.DropRateLimitOverflow,
}, },
StrictPayloadType: opts.StrictPayloadType, StrictPayloadType: opts.StrictPayloadType,
MaxWorkers: opts.MaxWorkers, MaxWorkers: opts.MaxWorkers,
@@ -114,9 +114,12 @@ func (codec BotOptsFileJSONCodec) ToBytes(opts *BotOpts) ([]byte, error) {
return data, nil return data, nil
} }
// Load reads BotOpts from a JSON config file.
func (codec BotOptsFileJSONCodec) Load(filename string) (*BotOpts, error) { func (codec BotOptsFileJSONCodec) Load(filename string) (*BotOpts, error) {
return LoadBotOptsFile(codec, filename) return LoadBotOptsFile(codec, filename)
} }
// Save writes BotOpts to a JSON config file.
func (codec BotOptsFileJSONCodec) Save(filename string, opts *BotOpts) error { func (codec BotOptsFileJSONCodec) Save(filename string, opts *BotOpts) error {
return SaveBotOptsFile(codec, filename, opts) return SaveBotOptsFile(codec, filename, opts)
} }
+15 -15
View File
@@ -13,21 +13,21 @@ import (
func TestBotOptsFileJSONCodecRoundTrip(t *testing.T) { func TestBotOptsFileJSONCodecRoundTrip(t *testing.T) {
codec := BotOptsFileJSONCodec{} codec := BotOptsFileJSONCodec{}
want := &BotOpts{ want := &BotOpts{
Token: "TOKEN", Token: "TOKEN",
UpdateTypes: []tgapi.UpdateType{tgapi.UpdateTypeMessage, tgapi.UpdateTypeCallbackQuery}, UpdateTypes: []tgapi.UpdateType{tgapi.UpdateTypeMessage, tgapi.UpdateTypeCallbackQuery},
Debug: true, Debug: true,
ErrorTemplate: "Error: %s", ErrorTemplate: "Error: %s",
Prefixes: []string{"/", "!"}, Prefixes: []string{"/", "!"},
LoggerBasePath: "/tmp/logs", LoggerBasePath: "/tmp/logs",
UseRequestLogger: true, UseRequestLogger: true,
WriteToFile: true, WriteToFile: true,
UseTestServer: true, UseTestServer: true,
APIURL: "https://api.example.invalid", APIURL: "https://api.example.invalid",
RateLimit: 42, RateLimit: 42,
DropRLOverflow: true, DropRateLimitOverflow: true,
StrictPayloadType: true, StrictPayloadType: true,
MaxWorkers: 64, MaxWorkers: 64,
FileConfigVersion: ConfigVersion, FileConfigVersion: ConfigVersion,
} }
data, err := codec.ToBytes(want) data, err := codec.ToBytes(want)
+1 -1
View File
@@ -95,7 +95,7 @@ func (bot *Bot[T]) UsePolicy(name string, policy Policy[T]) *Bot[T] {
// - Scheduled tasks (e.g., daily announcements) // - Scheduled tasks (e.g., daily announcements)
// //
// Runners start from the bot runtime entry points, immediately after // Runners start from the bot runtime entry points, immediately after
// RunWithContext or RunWebHookWithContext begins. // RunWithContext or RunWebhookWithContext begins.
// //
// Example: // Example:
// //
+3 -3
View File
@@ -63,13 +63,13 @@ func TestAddPluginsSnapshotsConfiguration(t *testing.T) {
bot := &Bot[NoData]{logger: sneklog.NewLogger()} bot := &Bot[NoData]{logger: sneklog.NewLogger()}
plugin := NewPlugin[NoData]("demo") plugin := NewPlugin[NoData]("demo")
cmd := plugin.NewCommand(func(ctx *MsgContext, db NoData) error { return nil }, "start") cmd := plugin.Command("start", func(ctx *MsgContext, db NoData) error { return nil })
plugin.AddMiddleware(NewMiddleware("base", func(ctx *MsgContext, db NoData) bool { return true })) plugin.AddMiddleware(NewMiddleware("base", func(ctx *MsgContext, db NoData) 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 NoData) error { return nil }, "late") plugin.Command("late", func(ctx *MsgContext, db NoData) error { return nil })
plugin.AddMiddleware(NewMiddleware("late", func(ctx *MsgContext, db NoData) bool { return true })) plugin.AddMiddleware(NewMiddleware("late", func(ctx *MsgContext, db NoData) bool { return true }))
registered := bot.plugins[0] registered := bot.plugins[0]
@@ -446,7 +446,7 @@ func TestCloseDoesNotDeleteWebhook(t *testing.T) {
bot := &Bot[NoData]{ bot := &Bot[NoData]{
logger: sneklog.NewLogger(), logger: sneklog.NewLogger(),
webHookLogger: sneklog.NewLogger(), webhookLogger: sneklog.NewLogger(),
api: api, api: api,
uploader: uploader, uploader: uploader,
} }
+64 -62
View File
@@ -15,8 +15,8 @@ import (
"git.scuroneko.dev/scuroneko/laniakea/utils" "git.scuroneko.dev/scuroneko/laniakea/utils"
) )
// BotWebHookOpts configures Telegram webhook registration and the local HTTP server. // BotWebhookOpts configures Telegram webhook registration and the local HTTP server.
type BotWebHookOpts struct { type BotWebhookOpts struct {
Path string Path string
LocalPort int LocalPort int
UseStatusPath bool UseStatusPath bool
@@ -30,9 +30,9 @@ type BotWebHookOpts struct {
SecretToken string SecretToken string
} }
// NewBotWebHookOpts returns webhook options with the default path, local port, and max connections. // NewBotWebhookOpts returns webhook options with the default path, local port, and max connections.
func NewBotWebHookOpts() *BotWebHookOpts { func NewBotWebhookOpts() *BotWebhookOpts {
return &BotWebHookOpts{ return &BotWebhookOpts{
Path: "/", Path: "/",
LocalPort: 8080, LocalPort: 8080,
MaxConnections: 40, MaxConnections: 40,
@@ -40,38 +40,38 @@ func NewBotWebHookOpts() *BotWebHookOpts {
} }
// SetPath sets the local HTTP path that receives Telegram webhook requests. // SetPath sets the local HTTP path that receives Telegram webhook requests.
func (opts *BotWebHookOpts) SetPath(path string) *BotWebHookOpts { func (opts *BotWebhookOpts) SetPath(path string) *BotWebhookOpts {
opts.Path = path opts.Path = path
return opts return opts
} }
// SetLocalPort sets the local HTTP port used by the webhook server. // SetLocalPort sets the local HTTP port used by the webhook server.
func (opts *BotWebHookOpts) SetLocalPort(port int) *BotWebHookOpts { func (opts *BotWebhookOpts) SetLocalPort(port int) *BotWebhookOpts {
opts.LocalPort = port opts.LocalPort = port
return opts return opts
} }
// SetUseStatusPath enables or disables the optional /status endpoint. // SetUseStatusPath enables or disables the optional /status endpoint.
// A non-empty SecretToken is required when this endpoint is enabled. // A non-empty SecretToken is required when this endpoint is enabled.
func (opts *BotWebHookOpts) SetUseStatusPath(use bool) *BotWebHookOpts { func (opts *BotWebhookOpts) SetUseStatusPath(use bool) *BotWebhookOpts {
opts.UseStatusPath = use opts.UseStatusPath = use
return opts return opts
} }
// SetURL sets the public base URL Telegram should call for incoming updates. // SetURL sets the public base URL Telegram should call for incoming updates.
func (opts *BotWebHookOpts) SetURL(url string) *BotWebHookOpts { func (opts *BotWebhookOpts) SetURL(url string) *BotWebhookOpts {
opts.URL = url opts.URL = url
return opts return opts
} }
// SetCertificate sets the self-signed webhook certificate bytes to upload. // SetCertificate sets the self-signed webhook certificate bytes to upload.
func (opts *BotWebHookOpts) SetCertificate(certificate []byte) *BotWebHookOpts { func (opts *BotWebhookOpts) SetCertificate(certificate []byte) *BotWebhookOpts {
opts.Certificate = certificate opts.Certificate = certificate
return opts return opts
} }
// MustLoadCertificate loads a webhook certificate from disk and panics on failure. // MustLoadCertificate loads a webhook certificate from disk and panics on failure.
func (opts *BotWebHookOpts) MustLoadCertificate(filename string) *BotWebHookOpts { func (opts *BotWebhookOpts) MustLoadCertificate(filename string) *BotWebhookOpts {
f, err := os.Open(filename) f, err := os.Open(filename)
if err != nil { if err != nil {
panic(err) panic(err)
@@ -87,37 +87,37 @@ func (opts *BotWebHookOpts) MustLoadCertificate(filename string) *BotWebHookOpts
} }
// SetIPAddress sets the fixed IP address Telegram should use for webhook delivery. // SetIPAddress sets the fixed IP address Telegram should use for webhook delivery.
func (opts *BotWebHookOpts) SetIPAddress(ip string) *BotWebHookOpts { func (opts *BotWebhookOpts) SetIPAddress(ip string) *BotWebhookOpts {
opts.IPAddress = ip opts.IPAddress = ip
return opts return opts
} }
// SetMaxConnections sets Telegram's maximum number of simultaneous webhook connections. // SetMaxConnections sets Telegram's maximum number of simultaneous webhook connections.
func (opts *BotWebHookOpts) SetMaxConnections(max int8) *BotWebHookOpts { func (opts *BotWebhookOpts) SetMaxConnections(max int8) *BotWebhookOpts {
opts.MaxConnections = max opts.MaxConnections = max
return opts return opts
} }
// SetAllowedUpdates sets the Telegram update types that should be delivered to the webhook. // SetAllowedUpdates sets the Telegram update types that should be delivered to the webhook.
func (opts *BotWebHookOpts) SetAllowedUpdates(updates ...tgapi.UpdateType) *BotWebHookOpts { func (opts *BotWebhookOpts) SetAllowedUpdates(updates ...tgapi.UpdateType) *BotWebhookOpts {
opts.AllowedUpdates = append([]tgapi.UpdateType(nil), updates...) opts.AllowedUpdates = append([]tgapi.UpdateType(nil), updates...)
return opts return opts
} }
// SetDropPendingUpdates configures whether Telegram should drop pending updates while setting the webhook. // SetDropPendingUpdates configures whether Telegram should drop pending updates while setting the webhook.
func (opts *BotWebHookOpts) SetDropPendingUpdates(drop bool) *BotWebHookOpts { func (opts *BotWebhookOpts) SetDropPendingUpdates(drop bool) *BotWebhookOpts {
opts.DropPendingUpdates = drop opts.DropPendingUpdates = drop
return opts return opts
} }
// SetSecretToken sets the secret token expected in Telegram webhook requests. // SetSecretToken sets the secret token expected in Telegram webhook requests.
// The same token is also required to access /status when that endpoint is enabled. // The same token is also required to access /status when that endpoint is enabled.
func (opts *BotWebHookOpts) SetSecretToken(secretToken string) *BotWebHookOpts { func (opts *BotWebhookOpts) SetSecretToken(secretToken string) *BotWebhookOpts {
opts.SecretToken = secretToken opts.SecretToken = secretToken
return opts return opts
} }
// RunWebHookWithContext registers the webhook and serves incoming updates until ctx is canceled. // RunWebhookWithContext registers the webhook and serves incoming updates until ctx is canceled.
// //
// The bot uses the same update queue, worker pool, runner startup, and single-use lifecycle // The bot uses the same update queue, worker pool, runner startup, and single-use lifecycle
// guarantees as RunWithContext. When opts.AllowedUpdates is empty, the bot-level update types // guarantees as RunWithContext. When opts.AllowedUpdates is empty, the bot-level update types
@@ -126,9 +126,9 @@ func (opts *BotWebHookOpts) SetSecretToken(secretToken string) *BotWebHookOpts {
// //
// When two TLS files are provided, the method serves HTTPS using the existing key-then-cert // When two TLS files are provided, the method serves HTTPS using the existing key-then-cert
// argument order. // argument order.
func (bot *Bot[T]) RunWebHookWithContext(ctx context.Context, opts *BotWebHookOpts, tlsFiles ...string) error { func (bot *Bot[T]) RunWebhookWithContext(ctx context.Context, opts *BotWebhookOpts, tlsFiles ...string) error {
if opts == nil { if opts == nil {
return errors.New("nil BotWebHookOpts") return errors.New("nil BotWebhookOpts")
} }
if len(bot.prefixes) == 0 { if len(bot.prefixes) == 0 {
return ErrNoPrefixes return ErrNoPrefixes
@@ -137,44 +137,42 @@ func (bot *Bot[T]) RunWebHookWithContext(ctx context.Context, opts *BotWebHookOp
return ErrNoPlugins return ErrNoPlugins
} }
if opts.URL == "" { if opts.URL == "" {
return errors.New("empty BotWebHookOpts.URL") return errors.New("empty BotWebhookOpts.URL")
} }
if opts.MaxConnections > 100 || opts.MaxConnections <= 0 { if opts.MaxConnections > 100 || opts.MaxConnections <= 0 {
return errors.New("BotWebHookOpts.MaxConnections must between 1 and 100") return errors.New("BotWebhookOpts.MaxConnections must between 1 and 100")
} }
if err := validateWebhookPath(opts.Path, opts.UseStatusPath); err != nil { if err := validateWebhookPath(opts.Path, opts.UseStatusPath); err != nil {
return err return err
} }
if opts.UseStatusPath && opts.SecretToken == "" { if opts.UseStatusPath && opts.SecretToken == "" {
return errors.New("BotWebHookOpts.SecretToken required when status path is enabled") return errors.New("BotWebhookOpts.SecretToken required when status path is enabled")
} }
if err := validateWebhookTLSFiles(tlsFiles); err != nil { if err := validateWebhookTLSFiles(tlsFiles); err != nil {
return err return err
} }
bot.webHookLogger = utils.CreateLogger("WEBHOOK", bot.GetLoggerLevel(), bot.logFormat, bot.logFormatter)
bot.addTokenReplacer(bot.webHookLogger)
if opts.SecretToken == "" {
bot.webHookLogger.Warnln("Bot webhook secret token empty. It's VERY recommended to set secret.")
}
if opts.Certificate != nil && bot.uploader == nil { if opts.Certificate != nil && bot.uploader == nil {
return errors.New("bot uploader nil, but certificate set") return errors.New("bot uploader nil, but certificate set")
} }
return bot.runWebhookRuntime(ctx, func(runCtx context.Context) error { return bot.runWebhookRuntime(ctx, func(runCtx context.Context) error {
if opts.SecretToken == "" {
bot.webhookLogger.Warnln("Bot webhook secret token empty. It's VERY recommended to set secret.")
}
i, err := bot.api.GetWebhookInfoWithContext(runCtx) i, err := bot.api.GetWebhookInfoWithContext(runCtx)
if err != nil { if err != nil {
return err return err
} }
if i.URL == "" { if i.URL == "" {
bot.webHookLogger.Warnln("API returned webhook info with empty URL. There may be a long-poll") bot.webhookLogger.Warnln("API returned webhook info with empty URL. There may be a long-poll")
} else { } else {
_, err = bot.api.DeleteWebhookWithContext(runCtx, tgapi.DeleteWebhook{}) _, err = bot.api.DeleteWebhookWithContext(runCtx, tgapi.DeleteWebhook{})
if err != nil { if err != nil {
return err return err
} }
bot.webHookLogger.Infof("Bot webhook deleted: %s", i.URL) bot.webhookLogger.Infof("Bot webhook deleted: %s", i.URL)
} }
allowedUpdates := bot.webhookAllowedUpdates(opts) allowedUpdates := bot.webhookAllowedUpdates(opts)
@@ -207,48 +205,48 @@ func (bot *Bot[T]) RunWebHookWithContext(ctx context.Context, opts *BotWebHookOp
} }
if len(tlsFiles) == 2 { if len(tlsFiles) == 2 {
return bot.runWebHookTLS(runCtx, opts, tlsFiles[0], tlsFiles[1]) return bot.runWebhookTLS(runCtx, opts, tlsFiles[0], tlsFiles[1])
} }
return bot.runWebHook(runCtx, opts) return bot.runWebhook(runCtx, opts)
}) })
} }
// RunWebHook starts the webhook runtime with a background context. // RunWebhook starts the webhook runtime with a background context.
// //
// It is shorthand for RunWebHookWithContext(context.Background(), opts, tlsFiles...). // It is shorthand for RunWebhookWithContext(context.Background(), opts, tlsFiles...).
func (bot *Bot[T]) RunWebHook(opts *BotWebHookOpts, tlsFiles ...string) error { func (bot *Bot[T]) RunWebhook(opts *BotWebhookOpts, tlsFiles ...string) error {
return bot.RunWebHookWithContext(context.Background(), opts, tlsFiles...) return bot.RunWebhookWithContext(context.Background(), opts, tlsFiles...)
} }
// CloseWebHook removes the current Telegram webhook registration. // CloseWebhook removes the current Telegram webhook registration.
// //
// It is separate from Close, which only releases local resources. // It is separate from Close, which only releases local resources.
// Call it before switching a deployment from webhook delivery to polling. // Call it before switching a deployment from webhook delivery to polling.
func (bot *Bot[T]) CloseWebHook() error { func (bot *Bot[T]) CloseWebhook() error {
var e []error var e []error
if bot.api == nil { if bot.api == nil {
e = append(e, errors.New("bot api nil")) e = append(e, errors.New("bot api nil"))
} else { } else {
if _, err := bot.api.DeleteWebhook(tgapi.DeleteWebhook{}); err != nil { if _, err := bot.api.DeleteWebhook(tgapi.DeleteWebhook{}); err != nil {
if bot.webHookLogger != nil { if bot.webhookLogger != nil {
bot.webHookLogger.Errorf("Failed to close webhook: %s", err.Error()) bot.webhookLogger.Errorf("Failed to close webhook: %s", err.Error())
} else if bot.logger != nil { } else if bot.logger != nil {
bot.logger.Errorf("Failed to close webhook: %s", err.Error()) bot.logger.Errorf("Failed to close webhook: %s", err.Error())
} }
e = append(e, err) e = append(e, err)
} }
} }
if bot.webHookLogger != nil { if bot.webhookLogger != nil {
if err := bot.webHookLogger.Close(); err != nil { if err := bot.webhookLogger.Close(); err != nil {
e = append(e, err) e = append(e, err)
} }
bot.webHookLogger = nil bot.webhookLogger = nil
} }
return errors.Join(e...) return errors.Join(e...)
} }
func (bot *Bot[T]) webhookAllowedUpdates(opts *BotWebHookOpts) []tgapi.UpdateType { func (bot *Bot[T]) webhookAllowedUpdates(opts *BotWebhookOpts) []tgapi.UpdateType {
if len(opts.AllowedUpdates) > 0 { if len(opts.AllowedUpdates) > 0 {
return append([]tgapi.UpdateType(nil), opts.AllowedUpdates...) return append([]tgapi.UpdateType(nil), opts.AllowedUpdates...)
} }
@@ -264,6 +262,10 @@ func (bot *Bot[T]) runWebhookRuntime(ctx context.Context, run func(context.Conte
runCtx, cancel := context.WithCancel(ctx) runCtx, cancel := context.WithCancel(ctx)
defer cancel() defer cancel()
if bot.webhookLogger == nil {
bot.webhookLogger = utils.CreateLogger("WEBHOOK", bot.GetLoggerLevel(), bot.logFormat, bot.logFormatter)
}
bot.addTokenReplacer(bot.webhookLogger)
bot.ExecRunners(runCtx) bot.ExecRunners(runCtx)
workersDone := make(chan struct{}) workersDone := make(chan struct{})
@@ -315,12 +317,12 @@ func updateHandler[T any](ctx context.Context, bot *Bot[T], secret string) http.
var up tgapi.Update var up tgapi.Update
if err := json.Unmarshal(data, &up); err != nil { if err := json.Unmarshal(data, &up); err != nil {
w.WriteHeader(http.StatusBadRequest) w.WriteHeader(http.StatusBadRequest)
bot.webHookLogger.Errorln(err) bot.webhookLogger.Errorln(err)
return return
} }
bot.webHookLogger.Debugf("UPDATE id=%d type=%s size=%d from=%s", up.UpdateID, up.Type, len(data), r.RemoteAddr) bot.webhookLogger.Debugf("UPDATE id=%d type=%s size=%d from=%s", up.UpdateID, up.Type, len(data), r.RemoteAddr)
if err := bot.enqueueUpdate(ctx, up); err != nil { if err := bot.enqueueUpdate(ctx, up); err != nil {
bot.webHookLogger.Errorln(err) bot.webhookLogger.Errorln(err)
w.WriteHeader(http.StatusServiceUnavailable) w.WriteHeader(http.StatusServiceUnavailable)
return return
} }
@@ -328,7 +330,7 @@ func updateHandler[T any](ctx context.Context, bot *Bot[T], secret string) http.
} }
} }
func statusHandler[T any](bot *Bot[T], opts *BotWebHookOpts) http.HandlerFunc { func statusHandler[T any](bot *Bot[T], opts *BotWebhookOpts) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
auth := "" auth := ""
if r.Header.Get("Authorization") != "" { if r.Header.Get("Authorization") != "" {
@@ -343,24 +345,24 @@ func statusHandler[T any](bot *Bot[T], opts *BotWebHookOpts) http.HandlerFunc {
i, err := bot.api.GetWebhookInfoWithContext(r.Context()) i, err := bot.api.GetWebhookInfoWithContext(r.Context())
if err != nil { if err != nil {
bot.webHookLogger.Errorln(err) bot.webhookLogger.Errorln(err)
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
return return
} }
data, err := json.MarshalIndent(i, "", " ") data, err := json.MarshalIndent(i, "", " ")
if err != nil { if err != nil {
bot.webHookLogger.Errorln(err) bot.webhookLogger.Errorln(err)
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
return return
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
if _, err := fmt.Fprint(w, string(data)); err != nil { if _, err := fmt.Fprint(w, string(data)); err != nil {
bot.webHookLogger.Errorln(err) bot.webhookLogger.Errorln(err)
} }
} }
} }
func (bot *Bot[T]) newWebHookMux(ctx context.Context, opts *BotWebHookOpts) *http.ServeMux { func (bot *Bot[T]) newWebhookMux(ctx context.Context, opts *BotWebhookOpts) *http.ServeMux {
r := http.NewServeMux() r := http.NewServeMux()
if opts.UseStatusPath { if opts.UseStatusPath {
r.HandleFunc("/status", statusHandler(bot, opts)) r.HandleFunc("/status", statusHandler(bot, opts))
@@ -368,10 +370,10 @@ func (bot *Bot[T]) newWebHookMux(ctx context.Context, opts *BotWebHookOpts) *htt
r.HandleFunc(opts.Path, updateHandler(ctx, bot, opts.SecretToken)) r.HandleFunc(opts.Path, updateHandler(ctx, bot, opts.SecretToken))
return r return r
} }
func (bot *Bot[T]) runWebHook(ctx context.Context, opts *BotWebHookOpts) error { func (bot *Bot[T]) runWebhook(ctx context.Context, opts *BotWebhookOpts) error {
srv := &http.Server{ srv := &http.Server{
Addr: fmt.Sprintf(":%d", opts.LocalPort), Addr: fmt.Sprintf(":%d", opts.LocalPort),
Handler: bot.newWebHookMux(ctx, opts), Handler: bot.newWebhookMux(ctx, opts),
} }
errCh := make(chan error, 1) errCh := make(chan error, 1)
@@ -384,7 +386,7 @@ func (bot *Bot[T]) runWebHook(ctx context.Context, opts *BotWebHookOpts) error {
errCh <- nil errCh <- nil
}() }()
bot.webHookLogger.Infoln(fmt.Sprintf("Bot WebHook started at %s; waiting for updates at %s", srv.Addr, opts.URL)) bot.webhookLogger.Infoln(fmt.Sprintf("Bot Webhook started at %s; waiting for updates at %s", srv.Addr, opts.URL))
select { select {
case <-ctx.Done(): case <-ctx.Done():
@@ -401,10 +403,10 @@ func (bot *Bot[T]) runWebHook(ctx context.Context, opts *BotWebHookOpts) error {
return err return err
} }
} }
func (bot *Bot[T]) runWebHookTLS(ctx context.Context, opts *BotWebHookOpts, key, cert string) error { func (bot *Bot[T]) runWebhookTLS(ctx context.Context, opts *BotWebhookOpts, key, cert string) error {
srv := &http.Server{ srv := &http.Server{
Addr: fmt.Sprintf(":%d", opts.LocalPort), Addr: fmt.Sprintf(":%d", opts.LocalPort),
Handler: bot.newWebHookMux(ctx, opts), Handler: bot.newWebhookMux(ctx, opts),
} }
errCh := make(chan error, 1) errCh := make(chan error, 1)
@@ -417,7 +419,7 @@ func (bot *Bot[T]) runWebHookTLS(ctx context.Context, opts *BotWebHookOpts, key,
errCh <- nil errCh <- nil
}() }()
bot.webHookLogger.Infoln(fmt.Sprintf("Bot webhook started with TLS(%s, %s) at %s; waiting for updates at %s", key, cert, srv.Addr, opts.URL)) bot.webhookLogger.Infoln(fmt.Sprintf("Bot webhook started with TLS(%s, %s) at %s; waiting for updates at %s", key, cert, srv.Addr, opts.URL))
select { select {
case <-ctx.Done(): case <-ctx.Done():
@@ -436,16 +438,16 @@ func (bot *Bot[T]) runWebHookTLS(ctx context.Context, opts *BotWebHookOpts, key,
} }
func validateWebhookPath(path string, useStatusPath bool) error { func validateWebhookPath(path string, useStatusPath bool) error {
if path == "" { if path == "" {
return errors.New("empty BotWebHookOpts.Path") return errors.New("empty BotWebhookOpts.Path")
} }
if !strings.HasPrefix(path, "/") { if !strings.HasPrefix(path, "/") {
return errors.New("BotWebHookOpts.Path must start with '/'") return errors.New("BotWebhookOpts.Path must start with '/'")
} }
if strings.Contains(path, "?") || strings.Contains(path, "#") { if strings.Contains(path, "?") || strings.Contains(path, "#") {
return errors.New("BotWebHookOpts.Path must not contain query or fragment") return errors.New("BotWebhookOpts.Path must not contain query or fragment")
} }
if useStatusPath && path == "/status" { if useStatusPath && path == "/status" {
return errors.New("BotWebHookOpts.Path must not be '/status' when status path is enabled") return errors.New("BotWebhookOpts.Path must not be '/status' when status path is enabled")
} }
return nil return nil
} }
+42 -19
View File
@@ -36,10 +36,10 @@ func TestEnqueueUpdateCopiesValue(t *testing.T) {
func TestUpdateHandlerEnqueuesUpdate(t *testing.T) { func TestUpdateHandlerEnqueuesUpdate(t *testing.T) {
bot := &Bot[NoData]{ bot := &Bot[NoData]{
updateQueue: make(chan *tgapi.Update, 1), updateQueue: make(chan *tgapi.Update, 1),
webHookLogger: sneklog.NewLogger(), webhookLogger: sneklog.NewLogger(),
} }
t.Cleanup(func() { t.Cleanup(func() {
_ = bot.webHookLogger.Close() _ = bot.webhookLogger.Close()
}) })
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"update_id":7,"message":{"message_id":1,"date":1,"chat":{"id":1,"type":"private"},"text":"/start"}}`)) req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"update_id":7,"message":{"message_id":1,"date":1,"chat":{"id":1,"type":"private"},"text":"/start"}}`))
@@ -94,7 +94,7 @@ func TestRunWebhookRuntimeExecutesRunners(t *testing.T) {
NewRunner("runner", func(bot *Bot[NoData]) error { NewRunner("runner", func(bot *Bot[NoData]) error {
calls.Add(1) calls.Add(1)
return nil return nil
}).Onetime(true).Async(false), }).Once(true).Async(false),
}, },
} }
t.Cleanup(func() { t.Cleanup(func() {
@@ -109,17 +109,40 @@ func TestRunWebhookRuntimeExecutesRunners(t *testing.T) {
} }
} }
func TestRunWebhookRuntimePreservesConfiguredWebhookLogger(t *testing.T) {
webhookLogger := sneklog.NewLogger()
bot := &Bot[NoData]{
logger: sneklog.NewLogger(),
webhookLogger: webhookLogger,
updateQueue: make(chan *tgapi.Update, 1),
maxWorkers: 1,
}
t.Cleanup(func() {
_ = bot.logger.Close()
if bot.webhookLogger != nil {
_ = bot.webhookLogger.Close()
}
})
if err := bot.runWebhookRuntime(context.Background(), func(context.Context) error { return nil }); err != nil {
t.Fatalf("runWebhookRuntime returned error: %v", err)
}
if bot.webhookLogger != webhookLogger {
t.Fatal("expected runWebhookRuntime to preserve configured webhook logger")
}
}
func TestRunWebhookRuntimeProcessesEnqueuedUpdate(t *testing.T) { func TestRunWebhookRuntimeProcessesEnqueuedUpdate(t *testing.T) {
var calls atomic.Int32 var calls atomic.Int32
plugin := NewPlugin[NoData]("demo") plugin := NewPlugin[NoData]("demo")
plugin.NewCommand(func(ctx *MsgContext, db NoData) error { plugin.Command("start", func(ctx *MsgContext, db NoData) error {
calls.Add(1) calls.Add(1)
return nil return nil
}, "start") })
bot := &Bot[NoData]{ bot := &Bot[NoData]{
logger: sneklog.NewLogger(), logger: sneklog.NewLogger(),
webHookLogger: sneklog.NewLogger(), webhookLogger: sneklog.NewLogger(),
prefixes: []string{"/"}, prefixes: []string{"/"},
plugins: []Plugin[NoData]{*plugin}, plugins: []Plugin[NoData]{*plugin},
updateQueue: make(chan *tgapi.Update, 1), updateQueue: make(chan *tgapi.Update, 1),
@@ -127,7 +150,7 @@ func TestRunWebhookRuntimeProcessesEnqueuedUpdate(t *testing.T) {
} }
t.Cleanup(func() { t.Cleanup(func() {
_ = bot.logger.Close() _ = bot.logger.Close()
_ = bot.webHookLogger.Close() _ = bot.webhookLogger.Close()
}) })
err := bot.runWebhookRuntime(context.Background(), func(ctx context.Context) error { err := bot.runWebhookRuntime(context.Background(), func(ctx context.Context) error {
@@ -162,7 +185,7 @@ func TestWebhookAllowedUpdatesUsesBotUpdateTypesByDefault(t *testing.T) {
bot := &Bot[NoData]{ bot := &Bot[NoData]{
updateTypes: []tgapi.UpdateType{tgapi.UpdateTypeMessage, tgapi.UpdateTypeCallbackQuery}, updateTypes: []tgapi.UpdateType{tgapi.UpdateTypeMessage, tgapi.UpdateTypeCallbackQuery},
} }
opts := NewBotWebHookOpts() opts := NewBotWebhookOpts()
got := bot.webhookAllowedUpdates(opts) got := bot.webhookAllowedUpdates(opts)
if len(got) != 2 { if len(got) != 2 {
@@ -235,10 +258,10 @@ func TestValidateWebhookTLSFiles(t *testing.T) {
func TestUpdateHandlerRejectsOversizedBody(t *testing.T) { func TestUpdateHandlerRejectsOversizedBody(t *testing.T) {
bot := &Bot[NoData]{ bot := &Bot[NoData]{
updateQueue: make(chan *tgapi.Update, 1), updateQueue: make(chan *tgapi.Update, 1),
webHookLogger: sneklog.NewLogger(), webhookLogger: sneklog.NewLogger(),
} }
t.Cleanup(func() { t.Cleanup(func() {
_ = bot.webHookLogger.Close() _ = bot.webhookLogger.Close()
}) })
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(strings.Repeat("a", (256<<10)+1))) req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(strings.Repeat("a", (256<<10)+1)))
@@ -272,13 +295,13 @@ func TestStatusHandlerRequiresMatchingSecret(t *testing.T) {
bot := &Bot[NoData]{ bot := &Bot[NoData]{
api: api, api: api,
webHookLogger: sneklog.NewLogger(), webhookLogger: sneklog.NewLogger(),
} }
t.Cleanup(func() { t.Cleanup(func() {
_ = bot.webHookLogger.Close() _ = bot.webhookLogger.Close()
}) })
handler := statusHandler(bot, &BotWebHookOpts{SecretToken: "secret"}) handler := statusHandler(bot, &BotWebhookOpts{SecretToken: "secret"})
tests := []struct { tests := []struct {
name string name string
@@ -308,14 +331,14 @@ func TestStatusHandlerRequiresMatchingSecret(t *testing.T) {
} }
} }
func TestRunWebHookWithContextRejectsInvalidTLSFilesBeforeRemoteSetup(t *testing.T) { func TestRunWebhookWithContextRejectsInvalidTLSFilesBeforeRemoteSetup(t *testing.T) {
bot := &Bot[NoData]{ bot := &Bot[NoData]{
prefixes: []string{"/"}, prefixes: []string{"/"},
plugins: []Plugin[NoData]{{name: "demo"}}, plugins: []Plugin[NoData]{{name: "demo"}},
} }
opts := NewBotWebHookOpts().SetURL("https://bot.example.com") opts := NewBotWebhookOpts().SetURL("https://bot.example.com")
err := bot.RunWebHookWithContext(context.Background(), opts, "cert.pem") err := bot.RunWebhookWithContext(context.Background(), opts, "cert.pem")
if err == nil { if err == nil {
t.Fatal("expected tls validation error, got nil") t.Fatal("expected tls validation error, got nil")
} }
@@ -324,16 +347,16 @@ func TestRunWebHookWithContextRejectsInvalidTLSFilesBeforeRemoteSetup(t *testing
} }
} }
func TestRunWebHookWithContextRequiresSecretWhenStatusPathEnabled(t *testing.T) { func TestRunWebhookWithContextRequiresSecretWhenStatusPathEnabled(t *testing.T) {
bot := &Bot[NoData]{ bot := &Bot[NoData]{
prefixes: []string{"/"}, prefixes: []string{"/"},
plugins: []Plugin[NoData]{{name: "demo"}}, plugins: []Plugin[NoData]{{name: "demo"}},
} }
opts := NewBotWebHookOpts(). opts := NewBotWebhookOpts().
SetURL("https://bot.example.com"). SetURL("https://bot.example.com").
SetUseStatusPath(true) SetUseStatusPath(true)
err := bot.RunWebHookWithContext(context.Background(), opts) err := bot.RunWebhookWithContext(context.Background(), opts)
if err == nil { if err == nil {
t.Fatal("expected status-path secret validation error, got nil") t.Fatal("expected status-path secret validation error, got nil")
} }
+3 -3
View File
@@ -10,8 +10,8 @@ import (
"git.scuroneko.dev/scuroneko/laniakea/tgapi" "git.scuroneko.dev/scuroneko/laniakea/tgapi"
) )
// CmdRegexp matches command names allowed for Telegram command registration. // cmdRegexp matches command names allowed for Telegram command registration.
var CmdRegexp = regexp.MustCompile("^[_a-z0-9]{1,32}$") var cmdRegexp = regexp.MustCompile("^[_a-z0-9]{1,32}$")
// ErrTooManyCommands is returned when the total number of registered commands // ErrTooManyCommands is returned when the total number of registered commands
// exceeds Telegram's limit of 100 bot commands per bot. // exceeds Telegram's limit of 100 bot commands per bot.
@@ -46,7 +46,7 @@ func generateBotCommand[T any](cmd *Command[T]) tgapi.BotCommand {
} }
// Internal helper to validate Telegram command names. // Internal helper to validate Telegram command names.
func checkCmdRegex(cmd string) bool { return CmdRegexp.MatchString(cmd) } func checkCmdRegex(cmd string) bool { return cmdRegexp.MatchString(cmd) }
// Internal helper to collect non-skipped, valid commands from one plugin. // Internal helper to collect non-skipped, valid commands from one plugin.
func gatherCommandsForPlugin[T any](pl Plugin[T]) []tgapi.BotCommand { func gatherCommandsForPlugin[T any](pl Plugin[T]) []tgapi.BotCommand {
+4 -4
View File
@@ -46,7 +46,7 @@ func TestAutoGenerateCommandsChecksLimitBeforeDelete(t *testing.T) {
plugin := NewPlugin[NoData]("overflow") plugin := NewPlugin[NoData]("overflow")
exec := func(ctx *MsgContext, db NoData) error { return nil } exec := func(ctx *MsgContext, db NoData) error { return nil }
for i := 0; i < 101; i++ { for i := 0; i < 101; i++ {
plugin.AddCommand(NewCommand(exec, "cmd"+strconv.Itoa(i))) plugin.Command("cmd"+strconv.Itoa(i), exec)
} }
bot := &Bot[NoData]{ bot := &Bot[NoData]{
@@ -68,9 +68,9 @@ func TestGatherCommandsForPluginReturnsSortedCommands(t *testing.T) {
plugin := NewPlugin[NoData]("sorted") plugin := NewPlugin[NoData]("sorted")
exec := func(ctx *MsgContext, db NoData) error { return nil } exec := func(ctx *MsgContext, db NoData) error { return nil }
plugin.AddCommand(NewCommand(exec, "zeta")) plugin.Command("zeta", exec)
plugin.AddCommand(NewCommand(exec, "alpha")) plugin.Command("alpha", exec)
plugin.AddCommand(NewCommand(exec, "mid")) plugin.Command("mid", exec)
commands := gatherCommandsForPlugin(*plugin) commands := gatherCommandsForPlugin(*plugin)
got := make([]string, 0, len(commands)) got := make([]string, 0, len(commands))
+25 -33
View File
@@ -2,7 +2,6 @@ package laniakea
import ( import (
"errors" "errors"
"fmt"
"regexp" "regexp"
"git.scuroneko.dev/scuroneko/extypes" "git.scuroneko.dev/scuroneko/extypes"
@@ -12,14 +11,14 @@ import (
type CommandValueType string type CommandValueType string
const ( const (
// CommandValueStringType expects any non-empty string. // CommandValueString expects any non-empty string.
CommandValueStringType CommandValueType = "string" CommandValueString CommandValueType = "string"
// CommandValueIntType expects a decimal integer (digits only). // CommandValueInt expects a decimal integer (digits only).
CommandValueIntType CommandValueType = "int" CommandValueInt CommandValueType = "int"
// CommandValueBoolType expects a exact "true" or "false". // CommandValueBool expects an exact "true" or "false".
CommandValueBoolType CommandValueType = "bool" CommandValueBool CommandValueType = "bool"
// CommandValueAnyType accepts any input without validation. // CommandValueAny accepts any input without validation.
CommandValueAnyType CommandValueType = "any" CommandValueAny CommandValueType = "any"
) )
var ( var (
@@ -52,22 +51,23 @@ type CommandArg struct {
required bool // Whether this argument must be provided required bool // Whether this argument must be provided
} }
// NewCommandArg creates a new CommandArg with the given text and type. // NewCommandArg creates an optional argument without value validation.
// Uses a default regex based on the type (string or int).
// For CommandValueAnyType, no validation is performed.
func NewCommandArg(text string) CommandArg { func NewCommandArg(text string) CommandArg {
return CommandArg{CommandValueAnyType, text, CommandRegexString, false} return CommandArg{CommandValueAny, text, nil, false}
} }
// SetValueType sets expected value type and switches built-in validation regexp. // SetValueType sets expected value type and switches built-in validation regexp.
func (c CommandArg) SetValueType(t CommandValueType) CommandArg { func (c CommandArg) SetValueType(t CommandValueType) CommandArg {
regex := CommandRegexString var regex *regexp.Regexp
switch t { switch t {
case CommandValueIntType: case CommandValueInt:
regex = CommandRegexInt regex = CommandRegexInt
case CommandValueBoolType: case CommandValueBool:
regex = CommandRegexBool regex = CommandRegexBool
case CommandValueAnyType: case CommandValueString:
regex = CommandRegexString
case CommandValueAny:
default:
regex = nil // Skip validation regex = nil // Skip validation
} }
c.valueType = t c.valueType = t
@@ -98,15 +98,15 @@ type Command[T AppData] struct {
skipAutoCmd bool // If true, this command won't be auto-added to help menus skipAutoCmd bool // If true, this command won't be auto-added to help menus
} }
// NewCommand creates a new Command with the given executor, command string, and arguments. // NewCommand creates a new Command with the given command string, executor, and arguments.
// The command string should not include the leading slash (e.g., "start", not "/start"). // The command string should not include the leading slash (e.g., "start", not "/start").
func NewCommand[T any](exec CommandExecutor[T], command string, args ...CommandArg) *Command[T] { func NewCommand[T any](command string, exec CommandExecutor[T], args ...CommandArg) *Command[T] {
return &Command[T]{command, "", exec, args, make(extypes.Slice[Middleware[T]], 0), false} return &Command[T]{command, "", exec, args, make(extypes.Slice[Middleware[T]], 0), false}
} }
// NewPayload creates a new Command with the given executor, command payload string, and arguments. // NewPayload creates a new callback payload handler command.
// The command string can contain any symbols, but it is recommended to use only "_", "-", ".", a-z, A-Z, and 0-9. // The command string can contain any symbols, but it is recommended to use only "_", "-", ".", a-z, A-Z, and 0-9.
func NewPayload[T any](exec CommandExecutor[T], command string, args ...CommandArg) *Command[T] { func NewPayload[T any](command string, exec CommandExecutor[T], args ...CommandArg) *Command[T] {
return &Command[T]{command, "", exec, args, make(extypes.Slice[Middleware[T]], 0), false} return &Command[T]{command, "", exec, args, make(extypes.Slice[Middleware[T]], 0), false}
} }
@@ -145,7 +145,7 @@ func (c *Command[T]) validateArgs(args []string) error {
} }
cmdArg := c.args.Get(i) cmdArg := c.args.Get(i)
if cmdArg.regex == nil { if cmdArg.regex == nil {
continue // Skip validation for CommandValueAnyType continue // Skip validation for CommandValueAny.
} }
if !cmdArg.regex.MatchString(arg) { if !cmdArg.regex.MatchString(arg) {
return ErrCmdArgRegexpMismatch return ErrCmdArgRegexpMismatch
@@ -167,9 +167,7 @@ func (c *Command[T]) clone() *Command[T] {
// CommandGroup builds a set of commands with a shared name prefix and middleware. // CommandGroup builds a set of commands with a shared name prefix and middleware.
type CommandGroup[T any] struct { type CommandGroup[T any] struct {
prefix string prefix string
separator string
middlewares extypes.Slice[Middleware[T]] middlewares extypes.Slice[Middleware[T]]
commands extypes.Slice[*Command[T]] commands extypes.Slice[*Command[T]]
} }
@@ -177,19 +175,13 @@ type CommandGroup[T any] struct {
// NewCommandGroup creates a command group that prefixes every added command. // NewCommandGroup creates a command group that prefixes every added command.
func NewCommandGroup[T any](prefix string) *CommandGroup[T] { func NewCommandGroup[T any](prefix string) *CommandGroup[T] {
return &CommandGroup[T]{ return &CommandGroup[T]{
prefix: prefix, separator: "", prefix: prefix,
middlewares: make([]Middleware[T], 0), middlewares: make([]Middleware[T], 0),
commands: make([]*Command[T], 0), commands: make([]*Command[T], 0),
} }
} }
// SetSeparator sets the text inserted between the group prefix and command name.
func (g *CommandGroup[T]) SetSeparator(separator string) *CommandGroup[T] {
g.separator = separator
return g
}
// Use adds middleware that runs before each command's own middleware. // Use adds middleware that runs before each command's own middleware.
func (g *CommandGroup[T]) Use(m Middleware[T]) *CommandGroup[T] { func (g *CommandGroup[T]) Use(m Middleware[T]) *CommandGroup[T] {
g.middlewares = append(g.middlewares, m) g.middlewares = append(g.middlewares, m)
@@ -202,7 +194,7 @@ func (g *CommandGroup[T]) AddCommand(cmd *Command[T]) *CommandGroup[T] {
return g return g
} }
newCmd := cmd.clone() newCmd := cmd.clone()
newCmd.command = fmt.Sprintf("%s%s%s", g.prefix, g.separator, cmd.command) newCmd.command = g.prefix + cmd.command
g.commands = g.commands.Push(newCmd) g.commands = g.commands.Push(newCmd)
return g return g
} }
+1 -1
View File
@@ -27,7 +27,7 @@ Example usage:
return bot.Run() return bot.Run()
Configure bots, plugins, and localization before starting Run, RunWithContext, or RunWebHookWithContext. Configure bots, plugins, and localization before starting Run, RunWithContext, or RunWebhookWithContext.
Runtime accessors are safe for concurrent use unless stated otherwise. Runtime accessors are safe for concurrent use unless stated otherwise.
*/ */
package laniakea package laniakea
+22 -22
View File
@@ -757,10 +757,10 @@ func TestHandleMessageFallbackDoesNotRunWhenCommandMatches(t *testing.T) {
commandCalled := false commandCalled := false
fallbackCalled := false fallbackCalled := false
plugin := NewPlugin[NoData]("test") plugin := NewPlugin[NoData]("test")
plugin.NewCommand(func(ctx *MsgContext, db NoData) error { plugin.Command("start", func(ctx *MsgContext, db NoData) error {
commandCalled = true commandCalled = true
return nil return nil
}, "start") })
plugin.SetMessageFallback(func(ctx *MsgContext, db NoData) error { plugin.SetMessageFallback(func(ctx *MsgContext, db NoData) error {
fallbackCalled = true fallbackCalled = true
return nil return nil
@@ -794,7 +794,7 @@ func TestHandleMessageFallbackDoesNotRunWhenCommandMatches(t *testing.T) {
func TestHandleChannelPostCommandWithSenderChat(t *testing.T) { func TestHandleChannelPostCommandWithSenderChat(t *testing.T) {
called := false called := false
plugin := NewPlugin[NoData]("test") plugin := NewPlugin[NoData]("test")
plugin.NewCommand(func(ctx *MsgContext, db NoData) error { plugin.Command("ping", func(ctx *MsgContext, db NoData) error {
called = true called = true
if ctx.Msg == nil { if ctx.Msg == nil {
t.Fatal("expected message context") t.Fatal("expected message context")
@@ -809,7 +809,7 @@ func TestHandleChannelPostCommandWithSenderChat(t *testing.T) {
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 return nil
}, "ping") })
bot := &Bot[NoData]{ bot := &Bot[NoData]{
logger: sneklog.NewLogger(), logger: sneklog.NewLogger(),
@@ -841,10 +841,10 @@ func TestCommandHandlerBindArgsEndToEnd(t *testing.T) {
var got banInput var got banInput
plugin := NewPlugin[NoData]("test") plugin := NewPlugin[NoData]("test")
plugin.NewCommand(func(ctx *MsgContext, db NoData) error { plugin.Command("ban", func(ctx *MsgContext, db NoData) error {
return ctx.BindArgs(&got) return ctx.BindArgs(&got)
}, "ban", },
NewCommandArg("user_id").SetValueType(CommandValueIntType).SetRequired(), NewCommandArg("user_id").SetValueType(CommandValueInt).SetRequired(),
NewCommandArg("reason").SetRequired(), NewCommandArg("reason").SetRequired(),
) )
@@ -878,10 +878,10 @@ func TestPayloadHandlerBindArgsEndToEnd(t *testing.T) {
var got payloadInput var got payloadInput
plugin := NewPlugin[NoData]("test") plugin := NewPlugin[NoData]("test")
plugin.NewPayload(func(ctx *MsgContext, db NoData) error { plugin.Payload("approve", func(ctx *MsgContext, db NoData) error {
return ctx.BindArgs(&got) return ctx.BindArgs(&got)
}, "approve", },
NewCommandArg("id").SetValueType(CommandValueIntType).SetRequired(), NewCommandArg("id").SetValueType(CommandValueInt).SetRequired(),
NewCommandArg("note").SetRequired(), NewCommandArg("note").SetRequired(),
) )
@@ -920,10 +920,10 @@ func TestHandleEditedMessageStaysOutOfCommandFlow(t *testing.T) {
updateCalled := false updateCalled := false
plugin := NewPlugin[NoData]("test") plugin := NewPlugin[NoData]("test")
plugin.NewCommand(func(ctx *MsgContext, db NoData) error { plugin.Command("ping", func(ctx *MsgContext, db NoData) error {
commandCalled = true commandCalled = true
return nil return nil
}, "ping") })
plugin.AddUpdateHandler(tgapi.UpdateTypeEditedMessage, func(ctx *MsgContext, db NoData) error { plugin.AddUpdateHandler(tgapi.UpdateTypeEditedMessage, func(ctx *MsgContext, db NoData) error {
updateCalled = true updateCalled = true
if ctx.Msg == nil { if ctx.Msg == nil {
@@ -968,10 +968,10 @@ func TestHandleEditedChannelPostStaysOutOfCommandFlow(t *testing.T) {
updateCalled := false updateCalled := false
plugin := NewPlugin[NoData]("test") plugin := NewPlugin[NoData]("test")
plugin.NewCommand(func(ctx *MsgContext, db NoData) error { plugin.Command("ping", func(ctx *MsgContext, db NoData) error {
commandCalled = true commandCalled = true
return nil return nil
}, "ping") })
plugin.AddUpdateHandler(tgapi.UpdateTypeEditedChannelPost, func(ctx *MsgContext, db NoData) error { plugin.AddUpdateHandler(tgapi.UpdateTypeEditedChannelPost, func(ctx *MsgContext, db NoData) error {
updateCalled = true updateCalled = true
if ctx.Msg == nil { if ctx.Msg == nil {
@@ -1007,7 +1007,7 @@ func TestHandleEditedChannelPostStaysOutOfCommandFlow(t *testing.T) {
func TestHandleCallbackPopulatesMessageTargets(t *testing.T) { func TestHandleCallbackPopulatesMessageTargets(t *testing.T) {
called := false called := false
plugin := NewPlugin[NoData]("test") plugin := NewPlugin[NoData]("test")
plugin.NewPayload(func(ctx *MsgContext, db NoData) error { plugin.Payload("approve", func(ctx *MsgContext, db NoData) error {
called = true called = true
if ctx.CallbackQueryID != "cb-msg" { if ctx.CallbackQueryID != "cb-msg" {
t.Fatalf("unexpected CallbackQueryID: %q", ctx.CallbackQueryID) t.Fatalf("unexpected CallbackQueryID: %q", ctx.CallbackQueryID)
@@ -1031,7 +1031,7 @@ func TestHandleCallbackPopulatesMessageTargets(t *testing.T) {
t.Fatalf("unexpected callback args: got %v want %v", got, want) t.Fatalf("unexpected callback args: got %v want %v", got, want)
} }
return nil return nil
}, "approve") })
bot := &Bot[NoData]{ bot := &Bot[NoData]{
logger: sneklog.NewLogger(), logger: sneklog.NewLogger(),
@@ -1066,7 +1066,7 @@ func TestHandleCallbackPopulatesMessageTargets(t *testing.T) {
func TestHandleCallbackPopulatesInlineTargets(t *testing.T) { func TestHandleCallbackPopulatesInlineTargets(t *testing.T) {
called := false called := false
plugin := NewPlugin[NoData]("test") plugin := NewPlugin[NoData]("test")
plugin.NewPayload(func(ctx *MsgContext, db NoData) error { plugin.Payload("inline.approve", func(ctx *MsgContext, db NoData) error {
called = true called = true
if ctx.CallbackQueryID != "cb-inline" { if ctx.CallbackQueryID != "cb-inline" {
t.Fatalf("unexpected CallbackQueryID: %q", ctx.CallbackQueryID) t.Fatalf("unexpected CallbackQueryID: %q", ctx.CallbackQueryID)
@@ -1090,7 +1090,7 @@ func TestHandleCallbackPopulatesInlineTargets(t *testing.T) {
t.Fatalf("unexpected callback args: got %v want %v", got, want) t.Fatalf("unexpected callback args: got %v want %v", got, want)
} }
return nil return nil
}, "inline.approve") })
bot := &Bot[NoData]{ bot := &Bot[NoData]{
logger: sneklog.NewLogger(), logger: sneklog.NewLogger(),
@@ -1122,9 +1122,9 @@ func TestHandleCallbackPopulatesInlineTargets(t *testing.T) {
func TestHandleCallbackObserverEmitsPayloadEvents(t *testing.T) { func TestHandleCallbackObserverEmitsPayloadEvents(t *testing.T) {
observer := &recordingObserver{} observer := &recordingObserver{}
plugin := NewPlugin[NoData]("test") plugin := NewPlugin[NoData]("test")
plugin.NewPayload(func(ctx *MsgContext, db NoData) error { plugin.Payload("approve", func(ctx *MsgContext, db NoData) error {
return nil return nil
}, "approve") })
bot := &Bot[NoData]{ bot := &Bot[NoData]{
logger: sneklog.NewLogger(), logger: sneklog.NewLogger(),
@@ -1173,9 +1173,9 @@ func TestHandleCallbackObserverEmitsPayloadErrors(t *testing.T) {
observer := &recordingObserver{} observer := &recordingObserver{}
plugin := NewPlugin[NoData]("test") plugin := NewPlugin[NoData]("test")
wantErr := AsInternalError(errors.New("boom")) wantErr := AsInternalError(errors.New("boom"))
plugin.NewPayload(func(ctx *MsgContext, db NoData) error { plugin.Payload("approve", func(ctx *MsgContext, db NoData) error {
return wantErr return wantErr
}, "approve") })
bot := &Bot[NoData]{ bot := &Bot[NoData]{
logger: sneklog.NewLogger(), logger: sneklog.NewLogger(),
+14 -14
View File
@@ -16,9 +16,9 @@ const (
ButtonStylePrimary tgapi.KeyboardButtonStyle = "primary" ButtonStylePrimary tgapi.KeyboardButtonStyle = "primary"
) )
// InlineKbButtonBuilder is a fluent builder for creating a single inline keyboard button. // InlineKeyboardButtonBuilder is a fluent builder for creating a single inline keyboard button.
// //
// Use NewInlineKbButton() to start, then chain methods to configure: // Use NewInlineKeyboardButton() to start, then chain methods to configure:
// - SetIconCustomEmojiID() — adds a custom emoji icon // - SetIconCustomEmojiID() — adds a custom emoji icon
// - SetStyle() — sets visual style (danger/success/primary) // - SetStyle() — sets visual style (danger/success/primary)
// - SetURL() — makes button open a URL // - SetURL() — makes button open a URL
@@ -26,7 +26,7 @@ const (
// //
// Call build() to produce the final tgapi.InlineKeyboardButton. // Call build() to produce the final tgapi.InlineKeyboardButton.
// Builder methods are immutable — each returns a copy. // Builder methods are immutable — each returns a copy.
type InlineKbButtonBuilder struct { type InlineKeyboardButtonBuilder struct {
text string text string
iconCustomEmojiID string iconCustomEmojiID string
style tgapi.KeyboardButtonStyle style tgapi.KeyboardButtonStyle
@@ -34,15 +34,15 @@ type InlineKbButtonBuilder struct {
callbackData string callbackData string
} }
// NewInlineKbButton creates a new button builder with the given display text. // NewInlineKeyboardButton creates a new button builder with the given display text.
// The button will have no URL, no style, and no callback data by default. // The button will have no URL, no style, and no callback data by default.
func NewInlineKbButton(text string) InlineKbButtonBuilder { func NewInlineKeyboardButton(text string) InlineKeyboardButtonBuilder {
return InlineKbButtonBuilder{text: text} return InlineKeyboardButtonBuilder{text: text}
} }
// SetIconCustomEmojiID sets a custom emoji ID to display as the button's icon. // SetIconCustomEmojiID sets a custom emoji ID to display as the button's icon.
// This is a Telegram Bot API feature for custom emoji icons. // This is a Telegram Bot API feature for custom emoji icons.
func (b InlineKbButtonBuilder) SetIconCustomEmojiID(id string) InlineKbButtonBuilder { func (b InlineKeyboardButtonBuilder) SetIconCustomEmojiID(id string) InlineKeyboardButtonBuilder {
b.iconCustomEmojiID = id b.iconCustomEmojiID = id
return b return b
} }
@@ -50,14 +50,14 @@ func (b InlineKbButtonBuilder) SetIconCustomEmojiID(id string) InlineKbButtonBui
// SetStyle sets the visual style of the button. // SetStyle sets the visual style of the button.
// Valid values: ButtonStyleDanger, ButtonStyleSuccess, ButtonStylePrimary. // Valid values: ButtonStyleDanger, ButtonStyleSuccess, ButtonStylePrimary.
// If not set, the button uses the default style. // If not set, the button uses the default style.
func (b InlineKbButtonBuilder) SetStyle(style tgapi.KeyboardButtonStyle) InlineKbButtonBuilder { func (b InlineKeyboardButtonBuilder) SetStyle(style tgapi.KeyboardButtonStyle) InlineKeyboardButtonBuilder {
b.style = style b.style = style
return b return b
} }
// SetURL sets a URL that will be opened when the button is pressed. // SetURL sets a URL that will be opened when the button is pressed.
// If both URL and CallbackData are set, Telegram will prioritize URL. // If both URL and CallbackData are set, Telegram will prioritize URL.
func (b InlineKbButtonBuilder) SetURL(url string) InlineKbButtonBuilder { func (b InlineKeyboardButtonBuilder) SetURL(url string) InlineKeyboardButtonBuilder {
b.url = url b.url = url
return b return b
} }
@@ -69,7 +69,7 @@ func (b InlineKbButtonBuilder) SetURL(url string) InlineKbButtonBuilder {
// are safely serialized, but complex structs may not serialize usefully. // are safely serialized, but complex structs may not serialize usefully.
// //
// Example: SetCallbackDataJSON("delete_user", 123, "confirm") → {"cmd":"delete_user","args":["123","confirm"]}. // Example: SetCallbackDataJSON("delete_user", 123, "confirm") → {"cmd":"delete_user","args":["123","confirm"]}.
func (b InlineKbButtonBuilder) SetCallbackDataJSON(cmd string, args ...any) InlineKbButtonBuilder { func (b InlineKeyboardButtonBuilder) SetCallbackDataJSON(cmd string, args ...any) InlineKeyboardButtonBuilder {
b.callbackData = NewCallbackData(cmd, args...).ToJSON() b.callbackData = NewCallbackData(cmd, args...).ToJSON()
return b return b
} }
@@ -77,13 +77,13 @@ func (b InlineKbButtonBuilder) SetCallbackDataJSON(cmd string, args ...any) Inli
// SetCallbackDataBase64 sets a structured callback payload encoded as Base64. // SetCallbackDataBase64 sets a structured callback payload encoded as Base64.
// This can be useful when the JSON payload exceeds Telegram's callback data length limit. // This can be useful when the JSON payload exceeds Telegram's callback data length limit.
// Args are converted to strings using fmt.Sprint. // Args are converted to strings using fmt.Sprint.
func (b InlineKbButtonBuilder) SetCallbackDataBase64(cmd string, args ...any) InlineKbButtonBuilder { func (b InlineKeyboardButtonBuilder) SetCallbackDataBase64(cmd string, args ...any) InlineKeyboardButtonBuilder {
b.callbackData = NewCallbackData(cmd, args...).ToBase64() b.callbackData = NewCallbackData(cmd, args...).ToBase64()
return b return b
} }
// Internal helper that converts the builder state into a Telegram button. // Internal helper that converts the builder state into a Telegram button.
func (b InlineKbButtonBuilder) build() tgapi.InlineKeyboardButton { func (b InlineKeyboardButtonBuilder) build() tgapi.InlineKeyboardButton {
return tgapi.InlineKeyboardButton{ return tgapi.InlineKeyboardButton{
Text: b.text, Text: b.text,
URL: b.url, URL: b.url,
@@ -194,9 +194,9 @@ func (in *InlineKeyboard) AddCallbackButtonStyle(text string, style tgapi.Keyboa
}) })
} }
// AddButton adds a button pre-configured via InlineKbButtonBuilder. // AddButton adds a button pre-configured via InlineKeyboardButtonBuilder.
// This is the most flexible way to create buttons with custom emoji, style, URL, and callback. // This is the most flexible way to create buttons with custom emoji, style, URL, and callback.
func (in *InlineKeyboard) AddButton(b InlineKbButtonBuilder) *InlineKeyboard { func (in *InlineKeyboard) AddButton(b InlineKeyboardButtonBuilder) *InlineKeyboard {
return in.append(b.build()) return in.append(b.build())
} }
+1 -1
View File
@@ -31,7 +31,7 @@ func TestInlineKeyboardWrapsRowsAndEncodesJSONPayloads(t *testing.T) {
func TestInlineKeyboardBuilderPreservesConfiguredButtonFields(t *testing.T) { func TestInlineKeyboardBuilderPreservesConfiguredButtonFields(t *testing.T) {
kb := NewInlineKeyboardBase64(3). kb := NewInlineKeyboardBase64(3).
AddButton( AddButton(
NewInlineKbButton("Docs"). NewInlineKeyboardButton("Docs").
SetStyle(ButtonStylePrimary). SetStyle(ButtonStylePrimary).
SetURL("https://example.test"), SetURL("https://example.test"),
) )
+21 -21
View File
@@ -142,7 +142,7 @@ func (m *AnswerMessage) Edit(text string) *AnswerMessage {
// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here. // ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here.
// Unescaped input may cause Telegram API errors or broken formatting. // Unescaped input may cause Telegram API errors or broken formatting.
func (m *AnswerMessage) EditMarkdown(text string) *AnswerMessage { func (m *AnswerMessage) EditMarkdown(text string) *AnswerMessage {
return m.ctx.edit(m.MessageID, text, nil, tgapi.ParseMDV2) return m.ctx.edit(m.MessageID, text, nil, tgapi.ParseMarkdownV2)
} }
// Internal helper for editing callback-linked messages. // Internal helper for editing callback-linked messages.
@@ -163,7 +163,7 @@ func (ctx *MsgContext) EditCallback(text string, keyboard *InlineKeyboard) *Answ
// //
// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here. // ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here.
func (ctx *MsgContext) EditCallbackMarkdown(text string, keyboard *InlineKeyboard) *AnswerMessage { func (ctx *MsgContext) EditCallbackMarkdown(text string, keyboard *InlineKeyboard) *AnswerMessage {
return ctx.editCallback(text, keyboard, tgapi.ParseMDV2) return ctx.editCallback(text, keyboard, tgapi.ParseMarkdownV2)
} }
// EditCallbackf formats a string using fmt.Sprintf and edits the callback message with plain text. // EditCallbackf formats a string using fmt.Sprintf and edits the callback message with plain text.
@@ -175,7 +175,7 @@ func (ctx *MsgContext) EditCallbackf(format string, keyboard *InlineKeyboard, ar
// //
// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here. // ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here.
func (ctx *MsgContext) EditCallbackfMarkdown(format string, keyboard *InlineKeyboard, args ...any) *AnswerMessage { func (ctx *MsgContext) EditCallbackfMarkdown(format string, keyboard *InlineKeyboard, args ...any) *AnswerMessage {
return ctx.editCallback(fmt.Sprintf(format, args...), keyboard, tgapi.ParseMDV2) return ctx.editCallback(fmt.Sprintf(format, args...), keyboard, tgapi.ParseMarkdownV2)
} }
// Internal helper for media-caption edits. // Internal helper for media-caption edits.
@@ -225,7 +225,7 @@ func (m *AnswerMessage) EditCaption(text string) *AnswerMessage {
// //
// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here. // ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here.
func (m *AnswerMessage) EditCaptionMarkdown(text string) *AnswerMessage { func (m *AnswerMessage) EditCaptionMarkdown(text string) *AnswerMessage {
return m.ctx.editPhotoText(m.MessageID, text, nil, tgapi.ParseMDV2) return m.ctx.editPhotoText(m.MessageID, text, nil, tgapi.ParseMarkdownV2)
} }
// EditCaptionKeyboard edits the caption of a media message with a new inline keyboard (plain text). // EditCaptionKeyboard edits the caption of a media message with a new inline keyboard (plain text).
@@ -237,7 +237,7 @@ func (m *AnswerMessage) EditCaptionKeyboard(text string, kb *InlineKeyboard) *An
// //
// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here. // ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here.
func (m *AnswerMessage) EditCaptionKeyboardMarkdown(text string, kb *InlineKeyboard) *AnswerMessage { func (m *AnswerMessage) EditCaptionKeyboardMarkdown(text string, kb *InlineKeyboard) *AnswerMessage {
return m.ctx.editPhotoText(m.MessageID, text, kb, tgapi.ParseMDV2) return m.ctx.editPhotoText(m.MessageID, text, kb, tgapi.ParseMarkdownV2)
} }
// Internal helper for message replies with optional keyboard and parse mode. // Internal helper for message replies with optional keyboard and parse mode.
@@ -292,7 +292,7 @@ func (ctx *MsgContext) AnswerLong(text string) []*AnswerMessage {
// //
// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here. // ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here.
func (ctx *MsgContext) AnswerMarkdown(text string) *AnswerMessage { func (ctx *MsgContext) AnswerMarkdown(text string) *AnswerMessage {
return ctx.answer(text, nil, tgapi.ParseMDV2) return ctx.answer(text, nil, tgapi.ParseMarkdownV2)
} }
// Answerf formats a string using fmt.Sprintf and sends it as a plain text message. // Answerf formats a string using fmt.Sprintf and sends it as a plain text message.
@@ -309,7 +309,7 @@ func (ctx *MsgContext) AnswerLongf(template string, args ...any) []*AnswerMessag
// //
// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here. // ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here.
func (ctx *MsgContext) AnswerfMarkdown(template string, args ...any) *AnswerMessage { func (ctx *MsgContext) AnswerfMarkdown(template string, args ...any) *AnswerMessage {
return ctx.answer(fmt.Sprintf(template, args...), nil, tgapi.ParseMDV2) return ctx.answer(fmt.Sprintf(template, args...), nil, tgapi.ParseMarkdownV2)
} }
// Keyboard sends a message with an inline keyboard (plain text). // Keyboard sends a message with an inline keyboard (plain text).
@@ -328,7 +328,7 @@ func (ctx *MsgContext) KeyboardLong(text string, kb *InlineKeyboard) []*AnswerMe
// //
// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here. // ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here.
func (ctx *MsgContext) KeyboardMarkdown(text string, keyboard *InlineKeyboard) *AnswerMessage { func (ctx *MsgContext) KeyboardMarkdown(text string, keyboard *InlineKeyboard) *AnswerMessage {
return ctx.answer(text, keyboard, tgapi.ParseMDV2) return ctx.answer(text, keyboard, tgapi.ParseMarkdownV2)
} }
func (ctx *MsgContext) answerLong(text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) []*AnswerMessage { func (ctx *MsgContext) answerLong(text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) []*AnswerMessage {
@@ -415,7 +415,7 @@ func (ctx *MsgContext) AnswerPhoto(photoID, text string) *AnswerMessage {
// //
// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here. // ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here.
func (ctx *MsgContext) AnswerPhotoMarkdown(photoID, text string) *AnswerMessage { func (ctx *MsgContext) AnswerPhotoMarkdown(photoID, text string) *AnswerMessage {
return ctx.answerPhoto(photoID, text, nil, tgapi.ParseMDV2) return ctx.answerPhoto(photoID, text, nil, tgapi.ParseMarkdownV2)
} }
// AnswerPhotoKeyboard sends a photo with caption and inline keyboard (plain text). // AnswerPhotoKeyboard sends a photo with caption and inline keyboard (plain text).
@@ -427,7 +427,7 @@ func (ctx *MsgContext) AnswerPhotoKeyboard(photoID, text string, kb *InlineKeybo
// //
// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here. // ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here.
func (ctx *MsgContext) AnswerPhotoKeyboardMarkdown(photoID, text string, kb *InlineKeyboard) *AnswerMessage { func (ctx *MsgContext) AnswerPhotoKeyboardMarkdown(photoID, text string, kb *InlineKeyboard) *AnswerMessage {
return ctx.answerPhoto(photoID, text, kb, tgapi.ParseMDV2) return ctx.answerPhoto(photoID, text, kb, tgapi.ParseMarkdownV2)
} }
// AnswerPhotof formats a string and sends it as a photo caption (plain text). // AnswerPhotof formats a string and sends it as a photo caption (plain text).
@@ -439,7 +439,7 @@ func (ctx *MsgContext) AnswerPhotof(photoID, template string, args ...any) *Answ
// //
// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here. // ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here.
func (ctx *MsgContext) AnswerPhotofMarkdown(photoID, template string, args ...any) *AnswerMessage { func (ctx *MsgContext) AnswerPhotofMarkdown(photoID, template string, args ...any) *AnswerMessage {
return ctx.answerPhoto(photoID, fmt.Sprintf(template, args...), nil, tgapi.ParseMDV2) return ctx.answerPhoto(photoID, fmt.Sprintf(template, args...), nil, tgapi.ParseMarkdownV2)
} }
// Internal helper that deletes a message by ID. // Internal helper that deletes a message by ID.
@@ -487,17 +487,17 @@ func (ctx *MsgContext) answerCallbackQuery(url, text string, showAlert bool) {
} }
} }
// AnswerCbQuery answers the callback query with no text or alert. // AnswerCallback answers the callback query with no text or alert.
func (ctx *MsgContext) AnswerCbQuery() { ctx.answerCallbackQuery("", "", false) } func (ctx *MsgContext) AnswerCallback() { ctx.answerCallbackQuery("", "", false) }
// AnswerCbQueryText answers the callback query with a text notification. // AnswerCallbackText answers the callback query with a text notification.
func (ctx *MsgContext) AnswerCbQueryText(text string) { ctx.answerCallbackQuery("", text, false) } func (ctx *MsgContext) AnswerCallbackText(text string) { ctx.answerCallbackQuery("", text, false) }
// AnswerCbQueryAlert answers the callback query with a user-visible alert. // AnswerCallbackAlert answers the callback query with a user-visible alert.
func (ctx *MsgContext) AnswerCbQueryAlert(text string) { ctx.answerCallbackQuery("", text, true) } func (ctx *MsgContext) AnswerCallbackAlert(text string) { ctx.answerCallbackQuery("", text, true) }
// AnswerCbQueryURL answers the callback query with a URL redirect. // AnswerCallbackURL answers the callback query with a URL redirect.
func (ctx *MsgContext) AnswerCbQueryURL(u string) { ctx.answerCallbackQuery(u, "", false) } func (ctx *MsgContext) AnswerCallbackURL(u string) { ctx.answerCallbackQuery(u, "", false) }
// SendAction sends a chat action (typing, uploading_photo, etc.) to indicate bot activity. // SendAction sends a chat action (typing, uploading_photo, etc.) to indicate bot activity.
func (ctx *MsgContext) SendAction(action tgapi.ChatActionType) { func (ctx *MsgContext) SendAction(action tgapi.ChatActionType) {
@@ -575,7 +575,7 @@ func (ctx *MsgContext) NewDraft() *Draft {
// with Markdown V2 parse mode enabled. // with Markdown V2 parse mode enabled.
// Uses the API limiter to avoid rate limiting. // Uses the API limiter to avoid rate limiting.
func (ctx *MsgContext) NewDraftMarkdown() *Draft { func (ctx *MsgContext) NewDraftMarkdown() *Draft {
return ctx.newDraft(tgapi.ParseMDV2) return ctx.newDraft(tgapi.ParseMarkdownV2)
} }
// Translate looks up a key in the current user's language. // Translate looks up a key in the current user's language.
@@ -816,5 +816,5 @@ func (ctx *MsgContext) UpsertKeyboard(text string, keyboard *InlineKeyboard) *An
// UpsertKeyboardMarkdown edits a callback message or sends a new MarkdownV2 message with a keyboard. // UpsertKeyboardMarkdown edits a callback message or sends a new MarkdownV2 message with a keyboard.
func (ctx *MsgContext) UpsertKeyboardMarkdown(text string, keyboard *InlineKeyboard) *AnswerMessage { func (ctx *MsgContext) UpsertKeyboardMarkdown(text string, keyboard *InlineKeyboard) *AnswerMessage {
return ctx.upsertKeyboard(text, keyboard, tgapi.ParseMDV2) return ctx.upsertKeyboard(text, keyboard, tgapi.ParseMarkdownV2)
} }
+32 -32
View File
@@ -57,10 +57,10 @@ func (p *Plugin[T]) AddCommand(command *Command[T]) *Plugin[T] {
return p return p
} }
// NewCommand creates and immediately adds a new command to the plugin. // Command creates and immediately adds a new command to the plugin.
// Returns the created command for further configuration. // Returns the created command for further configuration.
func (p *Plugin[T]) NewCommand(exec CommandExecutor[T], command string, args ...CommandArg) *Command[T] { func (p *Plugin[T]) Command(command string, exec CommandExecutor[T], args ...CommandArg) *Command[T] {
cmd := NewCommand(exec, command, args...) cmd := NewCommand(command, exec, args...)
p.AddCommand(cmd) p.AddCommand(cmd)
return cmd return cmd
} }
@@ -78,6 +78,33 @@ func (p *Plugin[T]) AddPayload(command *Command[T]) *Plugin[T] {
return p return p
} }
// Payload creates and immediately adds a new payload command to the plugin.
// Returns the created payload command for further configuration.
func (p *Plugin[T]) Payload(command string, exec CommandExecutor[T], args ...CommandArg) *Command[T] {
cmd := NewPayload(command, exec, args...)
p.AddPayload(cmd)
return cmd
}
// Scene creates, registers, and returns a new scene owned by the plugin.
func (p *Plugin[T]) Scene(name string) *Scene[T] {
scene := NewScene[T](name)
scene.setPluginName(p.name)
p.AddScene(scene)
return scene
}
// AddScene registers a multi-step scene in the plugin.
func (p *Plugin[T]) AddScene(scene *Scene[T]) *Plugin[T] {
if scene == nil {
return p
}
scene.PluginName = p.name
scene.setPluginName(p.name)
p.scenes[scene.Name] = scene
return p
}
// CommandGroup configures and registers a prefixed command group. // CommandGroup configures and registers a prefixed command group.
func (p *Plugin[T]) CommandGroup(prefix string, groupFunc func(group *CommandGroup[T])) *Plugin[T] { func (p *Plugin[T]) CommandGroup(prefix string, groupFunc func(group *CommandGroup[T])) *Plugin[T] {
if groupFunc == nil { if groupFunc == nil {
@@ -108,33 +135,6 @@ func (p *Plugin[T]) AddCommandGroup(group *CommandGroup[T]) *Plugin[T] {
return p return p
} }
// NewPayload creates and immediately adds a new payload command to the plugin.
// Returns the created payload command for further configuration.
func (p *Plugin[T]) NewPayload(exec CommandExecutor[T], command string, args ...CommandArg) *Command[T] {
cmd := NewPayload(exec, command, args...)
p.AddPayload(cmd)
return cmd
}
// AddScene registers a multi-step scene in the plugin.
func (p *Plugin[T]) AddScene(scene *Scene[T]) *Plugin[T] {
if scene == nil {
return p
}
scene.PluginName = p.name
scene.setPluginName(p.name)
p.scenes[scene.Name] = scene
return p
}
// NewScene creates, registers, and returns a new scene owned by the plugin.
func (p *Plugin[T]) NewScene(name string) *Scene[T] {
scene := NewScene[T](name)
scene.setPluginName(p.name)
p.AddScene(scene)
return scene
}
// UsePolicy registers a Policy as plugin middleware for all plugin handlers. // UsePolicy registers a Policy as plugin middleware for all plugin handlers.
func (p *Plugin[T]) UsePolicy(name string, policy Policy[T]) *Plugin[T] { func (p *Plugin[T]) UsePolicy(name string, policy Policy[T]) *Plugin[T] {
mw := RequirePolicy(name, policy) mw := RequirePolicy(name, policy)
@@ -148,11 +148,11 @@ func (p *Plugin[T]) AddUpdateHandler(t tgapi.UpdateType, handler CommandExecutor
case tgapi.UpdateTypeMessage, tgapi.UpdateTypeChannelPost, tgapi.UpdateTypeCallbackQuery: case tgapi.UpdateTypeMessage, tgapi.UpdateTypeChannelPost, tgapi.UpdateTypeCallbackQuery:
if p.logger == nil { if p.logger == nil {
logger := utils.CreateLogger(p.name, utils.GetLoggerLevel(), utils.LogFormatText, nil) logger := utils.CreateLogger(p.name, utils.GetLoggerLevel(), utils.LogFormatText, nil)
logger.Warnf("%s can't be registred through AddUpdateHandler. Use AddPayload/NewPayload or AddCommand/NewCommand", t) logger.Warnf("%s can't be registered through AddUpdateHandler. Use AddPayload/Payload or AddCommand/Command", t)
_ = logger.Close() _ = logger.Close()
return p return p
} }
p.logger.Warnf("%s can't be registred through AddUpdateHandler. Use AddPayload/NewPayload or AddCommand/NewCommand", t) p.logger.Warnf("%s can't be registered through AddUpdateHandler. Use AddPayload/Payload or AddCommand/Command", t)
return p return p
} }
p.handlers[t] = handler p.handlers[t] = handler
+8 -10
View File
@@ -6,7 +6,7 @@ import (
) )
func TestValidateArgsRequiresFullMatch(t *testing.T) { func TestValidateArgsRequiresFullMatch(t *testing.T) {
intCmd := NewCommand(func(ctx *MsgContext, db NoData) error { return nil }, "int", NewCommandArg("n").SetValueType(CommandValueIntType).SetRequired()) intCmd := NewCommand("int", func(ctx *MsgContext, db NoData) error { return nil }, NewCommandArg("n").SetValueType(CommandValueInt).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(func(ctx *MsgContext, db NoData) error { return nil }, "bool", NewCommandArg("flag").SetValueType(CommandValueBoolType).SetRequired()) boolCmd := NewCommand("bool", func(ctx *MsgContext, db NoData) error { return nil }, NewCommandArg("flag").SetValueType(CommandValueBool).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,8 +25,8 @@ func TestValidateArgsRequiresFullMatch(t *testing.T) {
func TestValidateArgsEnforcesRequiredArgIndex(t *testing.T) { func TestValidateArgsEnforcesRequiredArgIndex(t *testing.T) {
cmd := NewCommand( cmd := NewCommand(
func(ctx *MsgContext, db NoData) error { return nil },
"mixed", "mixed",
func(ctx *MsgContext, db NoData) error { return nil },
NewCommandArg("optional"), NewCommandArg("optional"),
NewCommandArg("required").SetRequired(), NewCommandArg("required").SetRequired(),
) )
@@ -42,12 +42,11 @@ func TestValidateArgsEnforcesRequiredArgIndex(t *testing.T) {
func TestCommandGroupBuildsPrefixedCommandsWithoutMutatingOriginal(t *testing.T) { func TestCommandGroupBuildsPrefixedCommandsWithoutMutatingOriginal(t *testing.T) {
groupMiddleware := NewMiddleware("group", func(ctx *MsgContext, db NoData) bool { return true }) groupMiddleware := NewMiddleware("group", func(ctx *MsgContext, db NoData) bool { return true })
commandMiddleware := NewMiddleware("command", func(ctx *MsgContext, db NoData) bool { return true }) commandMiddleware := NewMiddleware("command", func(ctx *MsgContext, db NoData) bool { return true })
cmd := NewCommand(func(ctx *MsgContext, db NoData) error { return nil }, "ban"). cmd := NewCommand("ban", func(ctx *MsgContext, db NoData) error { return nil }).
SetDescription("Ban user"). SetDescription("Ban user").
Use(commandMiddleware) Use(commandMiddleware)
group := NewCommandGroup[NoData]("admin"). group := NewCommandGroup[NoData]("admin_").
SetSeparator("_").
Use(groupMiddleware). Use(groupMiddleware).
AddCommand(cmd) AddCommand(cmd)
@@ -80,7 +79,7 @@ func TestCommandGroupBuildsPrefixedCommandsWithoutMutatingOriginal(t *testing.T)
func TestCommandGroupBuildIsRepeatable(t *testing.T) { func TestCommandGroupBuildIsRepeatable(t *testing.T) {
group := NewCommandGroup[NoData]("admin"). group := NewCommandGroup[NoData]("admin").
Use(NewMiddleware("group", func(ctx *MsgContext, db NoData) bool { return true })). Use(NewMiddleware("group", func(ctx *MsgContext, db NoData) bool { return true })).
AddCommand(NewCommand(func(ctx *MsgContext, db NoData) error { return nil }, "ban"). AddCommand(NewCommand("ban", func(ctx *MsgContext, db NoData) error { return nil }).
Use(NewMiddleware("command", func(ctx *MsgContext, db NoData) bool { return true }))) Use(NewMiddleware("command", func(ctx *MsgContext, db NoData) bool { return true })))
first := group.Build() first := group.Build()
@@ -103,9 +102,8 @@ func TestCommandGroupBuildIsRepeatable(t *testing.T) {
func TestPluginCommandGroupRegistersBuiltCommands(t *testing.T) { func TestPluginCommandGroupRegistersBuiltCommands(t *testing.T) {
plugin := NewPlugin[NoData]("admin") plugin := NewPlugin[NoData]("admin")
plugin.CommandGroup("admin", func(group *CommandGroup[NoData]) { plugin.CommandGroup("admin_", func(group *CommandGroup[NoData]) {
group.SetSeparator("_") group.AddCommand(NewCommand("ban", func(ctx *MsgContext, db NoData) error { return nil }))
group.AddCommand(NewCommand(func(ctx *MsgContext, db NoData) error { return nil }, "ban"))
}) })
if _, ok := plugin.commands["admin_ban"]; !ok { if _, ok := plugin.commands["admin_ban"]; !ok {
+41 -41
View File
@@ -12,41 +12,41 @@ type RunnerFn[T AppData] func(*Bot[T]) error
// Runner represents a configurable background or one-time task to be // Runner represents a configurable background or one-time task to be
// executed by a Bot. // executed by a Bot.
// //
// Runners are configured using builder methods: Onetime(), Async(), Timeout(). // Runners are configured using builder methods: Once(), Async(), Every().
// Once Execute() is called, the Runner should not be modified. // Once Execute() is called, the Runner should not be modified.
// //
// Execution semantics: // Execution semantics:
// - onetime=true, async=false: Run once synchronously (blocks). // - once=true, async=false: Run once synchronously (blocks).
// - onetime=true, async=true: Run once in a goroutine (non-blocking). // - once=true, async=true: Run once in a goroutine (non-blocking).
// - onetime=false, async=true: Run repeatedly in a goroutine with timeout. // - once=false, async=true: Run repeatedly in a goroutine with timeout.
// - onetime=false, async=false: Invalid configuration — ignored with warning. // - once=false, async=false: Invalid configuration — ignored with warning.
type Runner[T AppData] struct { type Runner[T AppData] struct {
name string // Human-readable name for logging name string // Human-readable name for logging
onetime bool // If true, runs once; if false, runs periodically once bool // If true, runs once; if false, runs periodically
async bool // If true, runs in a goroutine; else, runs synchronously async bool // If true, runs in a goroutine; else, runs synchronously
timeout time.Duration // Duration to wait between periodic executions (ignored if onetime=true) every time.Duration // Duration to wait between periodic executions (ignored if once=true)
fn RunnerFn[T] // The function to execute fn RunnerFn[T] // The function to execute
} }
// NewRunner creates a new Runner with the given name and function. // NewRunner creates a new Runner with the given name and function.
// By default, the Runner is configured as async=true (non-blocking). // By default, the Runner is configured as async=true (non-blocking).
// //
// Builder methods (Onetime, Async, Timeout) can be chained to customize behavior. // Builder methods (Once, Async, Every) can be chained to customize behavior.
// DO NOT call builder methods concurrently or after Execute(). // DO NOT call builder methods concurrently or after Execute().
func NewRunner[T AppData](name string, fn RunnerFn[T]) Runner[T] { func NewRunner[T AppData](name string, fn RunnerFn[T]) Runner[T] {
return Runner[T]{ return Runner[T]{
name: name, name: name,
fn: fn, fn: fn,
async: true, // Default: run asynchronously async: true, // Default: run asynchronously
timeout: 0, // Default: no timeout (ignored if onetime=true) every: 0, // Default: no timeout (ignored if once=true)
} }
} }
// Onetime sets whether the runner executes once or repeatedly. // Once sets whether the runner executes once or repeatedly.
// If true, the runner runs only once. // If true, the runner runs only once.
// If false, the runner runs in a loop with the configured timeout. // If false, the runner runs in a loop with the configured timeout.
func (r Runner[T]) Onetime(onetime bool) Runner[T] { func (r Runner[T]) Once(once bool) Runner[T] {
r.onetime = onetime r.once = once
return r return r
} }
@@ -54,56 +54,56 @@ func (r Runner[T]) Onetime(onetime bool) Runner[T] {
// If true, the runner runs in a goroutine (non-blocking). // If true, the runner runs in a goroutine (non-blocking).
// If false, the runner blocks the caller during execution. // If false, the runner blocks the caller during execution.
// //
// Note: If onetime=false and async=false, the runner will be skipped with a warning. // Note: If once=false and async=false, the runner will be skipped with a warning.
func (r Runner[T]) Async(async bool) Runner[T] { func (r Runner[T]) Async(async bool) Runner[T] {
r.async = async r.async = async
return r return r
} }
// Timeout sets the duration to wait between repeated executions for // Every sets the duration to wait between repeated executions for
// non-onetime runners. // non-once runners.
// //
// If onetime=true, this value is ignored. // If once=true, this value is ignored.
// If onetime=false and async=true, this timeout determines the sleep interval // If once=false and async=true, this timeout determines the sleep interval
// between loop iterations. // between loop iterations.
// //
// A zero value (time.Duration(0)) is allowed but may trigger a warning // A zero value (time.Duration(0)) is allowed but may trigger a warning
// if used with a background (non-onetime) async runner. // if used with a background (non-once) async runner.
func (r Runner[T]) Timeout(timeout time.Duration) Runner[T] { func (r Runner[T]) Every(timeout time.Duration) Runner[T] {
r.timeout = timeout r.every = timeout
return r return r
} }
// ExecRunners executes all runners registered on the Bot with context-based lifecycle management. // ExecRunners executes all runners registered on the Bot with context-based lifecycle management.
// //
// It logs warnings for misconfigured runners: // It logs warnings for misconfigured runners:
// - Sync, non-onetime runners are skipped (invalid configuration). // - Sync, non-once runners are skipped (invalid configuration).
// - Background (non-onetime, async) runners without a timeout trigger a warning. // - Background (non-once, async) runners without a timeout trigger a warning.
// //
// Execution logic: // Execution logic:
// - onetime + async: Runs once in a goroutine. // - once + async: Runs once in a goroutine.
// - onetime + sync: Runs once synchronously; warns if slower than 2 seconds. // - once + sync: Runs once synchronously; warns if slower than 2 seconds.
// - !onetime + async: Runs in a loop with timeout between iterations until ctx.Done(). // - !once + async: Runs in a loop with timeout between iterations until ctx.Done().
// - !onetime + sync: Skipped with warning. // - !once + sync: Skipped with warning.
// //
// Background runners listen for ctx.Done() and gracefully shut down when the context is canceled. // Background runners listen for ctx.Done() and gracefully shut down when the context is canceled.
// //
// This method is typically called once during bot startup from RunWithContext or // This method is typically called once during bot startup from RunWithContext or
// RunWebHookWithContext. // RunWebhookWithContext.
func (bot *Bot[T]) ExecRunners(ctx context.Context) { func (bot *Bot[T]) ExecRunners(ctx context.Context) {
bot.logger.Infoln("Executing runners...") bot.logger.Infoln("Executing runners...")
for _, runner := range bot.runners { for _, runner := range bot.runners {
// Validate configuration // Validate configuration
if !runner.onetime && !runner.async { if !runner.once && !runner.async {
bot.logger.Warnf("Runner %s not onetime, but sync — skipping\n", runner.name) bot.logger.Warnf("Runner %s not once, but sync — skipping\n", runner.name)
continue continue
} }
if !runner.onetime && runner.async && runner.timeout == 0 { if !runner.once && runner.async && runner.every == 0 {
bot.logger.Warnf("Background runner \"%s\" has no timeout — skipping\n", runner.name) bot.logger.Warnf("Background runner \"%s\" has no timeout — skipping\n", runner.name)
continue continue
} }
if runner.onetime && runner.async { if runner.once && runner.async {
// One-time async: fire and forget // One-time async: fire and forget
bot.runnerOnceWG.Add(1) bot.runnerOnceWG.Add(1)
go func(r Runner[T]) { go func(r Runner[T]) {
@@ -126,7 +126,7 @@ func (bot *Bot[T]) ExecRunners(ctx context.Context) {
bot.logger.Warnf("Runner %s failed: %s\n", r.name, err) bot.logger.Warnf("Runner %s failed: %s\n", r.name, err)
} }
}(runner) }(runner)
} else if runner.onetime && !runner.async { } else if runner.once && !runner.async {
// One-time sync: block until done // One-time sync: block until done
t := time.Now() t := time.Now()
err := runner.fn(bot) err := runner.fn(bot)
@@ -149,12 +149,12 @@ func (bot *Bot[T]) ExecRunners(ctx context.Context) {
if elapsed > time.Second*2 { if elapsed > time.Second*2 {
bot.logger.Warnf("Runner %s too slow. Elapsed time %v >= 2s\n", runner.name, elapsed) bot.logger.Warnf("Runner %s too slow. Elapsed time %v >= 2s\n", runner.name, elapsed)
} }
} else if !runner.onetime && runner.async { } else if !runner.once && runner.async {
// Background loop: periodic execution with graceful shutdown // Background loop: periodic execution with graceful shutdown
bot.runnerBgWG.Add(1) bot.runnerBgWG.Add(1)
go func(r Runner[T]) { go func(r Runner[T]) {
defer bot.runnerBgWG.Done() defer bot.runnerBgWG.Done()
ticker := time.NewTicker(r.timeout) ticker := time.NewTicker(r.every)
defer ticker.Stop() defer ticker.Stop()
for { for {
select { select {
@@ -182,6 +182,6 @@ func (bot *Bot[T]) ExecRunners(ctx context.Context) {
} }
}(runner) }(runner)
} }
// Note: !onetime && !async is already skipped above // Note: !once && !async is already skipped above
} }
} }
+4 -4
View File
@@ -14,7 +14,7 @@ type runnerObserver struct {
recordingObserver recordingObserver
} }
func TestExecRunnersRunsOnetimeSyncRunner(t *testing.T) { func TestExecRunnersRunsOnceSyncRunner(t *testing.T) {
var calls atomic.Int32 var calls atomic.Int32
bot := &Bot[NoData]{ bot := &Bot[NoData]{
logger: sneklog.NewLogger(), logger: sneklog.NewLogger(),
@@ -22,7 +22,7 @@ func TestExecRunnersRunsOnetimeSyncRunner(t *testing.T) {
NewRunner("sync-once", func(*Bot[NoData]) error { NewRunner("sync-once", func(*Bot[NoData]) error {
calls.Add(1) calls.Add(1)
return nil return nil
}).Onetime(true).Async(false), }).Once(true).Async(false),
}, },
} }
@@ -46,7 +46,7 @@ func TestExecRunnersStopsBackgroundRunnerOnCancel(t *testing.T) {
triggered <- struct{}{} triggered <- struct{}{}
} }
return nil return nil
}).Timeout(5 * time.Millisecond), }).Every(5 * time.Millisecond),
}, },
} }
@@ -76,7 +76,7 @@ func TestExecRunnersEmitObserverEvents(t *testing.T) {
runners: []Runner[NoData]{ runners: []Runner[NoData]{
NewRunner("sync-once", func(*Bot[NoData]) error { NewRunner("sync-once", func(*Bot[NoData]) error {
return wantErr return wantErr
}).Onetime(true).Async(false), }).Once(true).Async(false),
}, },
} }
+18 -18
View File
@@ -45,7 +45,7 @@ func TestBotAddPluginsPreservesScenesAndHandlesThem(t *testing.T) {
called := false called := false
plugin := NewPlugin[NoData]("wizard") plugin := NewPlugin[NoData]("wizard")
plugin.NewScene("signup"). plugin.Scene("signup").
SetEntry("start"). SetEntry("start").
OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) { OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) {
called = true called = true
@@ -146,7 +146,7 @@ func TestBuildSceneKeyRejectsMissingContextFields(t *testing.T) {
func TestEnterSceneRejectsMissingEntryConfiguration(t *testing.T) { func TestEnterSceneRejectsMissingEntryConfiguration(t *testing.T) {
t.Run("empty entry", func(t *testing.T) { t.Run("empty entry", func(t *testing.T) {
plugin := NewPlugin[NoData]("wizard") plugin := NewPlugin[NoData]("wizard")
plugin.NewScene("signup") plugin.Scene("signup")
bot := &Bot[NoData]{ bot := &Bot[NoData]{
logger: sneklog.NewLogger(), logger: sneklog.NewLogger(),
@@ -169,7 +169,7 @@ func TestEnterSceneRejectsMissingEntryConfiguration(t *testing.T) {
t.Run("missing entry step", func(t *testing.T) { t.Run("missing entry step", func(t *testing.T) {
plugin := NewPlugin[NoData]("wizard") plugin := NewPlugin[NoData]("wizard")
plugin.NewScene("signup").SetEntry("start") plugin.Scene("signup").SetEntry("start")
bot := &Bot[NoData]{ bot := &Bot[NoData]{
logger: sneklog.NewLogger(), logger: sneklog.NewLogger(),
@@ -210,7 +210,7 @@ func TestSceneCommandHandlerRunsBeforeStep(t *testing.T) {
stepCalled := false stepCalled := false
plugin := NewPlugin[NoData]("wizard") plugin := NewPlugin[NoData]("wizard")
plugin.NewScene("signup"). plugin.Scene("signup").
SetEntry("start"). SetEntry("start").
OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) { OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) {
stepCalled = true stepCalled = true
@@ -269,7 +269,7 @@ func TestSceneCommandHandlerRunsBeforeStep(t *testing.T) {
func TestSceneCommandObserverEmitsLifecycleEvents(t *testing.T) { func TestSceneCommandObserverEmitsLifecycleEvents(t *testing.T) {
observer := &recordingObserver{} observer := &recordingObserver{}
plugin := NewPlugin[NoData]("wizard") plugin := NewPlugin[NoData]("wizard")
plugin.NewScene("signup"). plugin.Scene("signup").
SetEntry("start"). SetEntry("start").
OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) { OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) {
return ctx.Stay(), nil return ctx.Stay(), nil
@@ -324,7 +324,7 @@ func TestSceneCommandObserverEmitsLifecycleEvents(t *testing.T) {
func TestSceneStepObserverEmitsLifecycleEvents(t *testing.T) { func TestSceneStepObserverEmitsLifecycleEvents(t *testing.T) {
observer := &recordingObserver{} observer := &recordingObserver{}
plugin := NewPlugin[NoData]("wizard") plugin := NewPlugin[NoData]("wizard")
plugin.NewScene("signup"). plugin.Scene("signup").
SetEntry("start"). SetEntry("start").
OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) { OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) {
return ctx.Stay(), nil return ctx.Stay(), nil
@@ -376,7 +376,7 @@ func TestSceneStepObserverEmitsLifecycleEvents(t *testing.T) {
func TestSceneMessageObserverEmitsLifecycleEvents(t *testing.T) { func TestSceneMessageObserverEmitsLifecycleEvents(t *testing.T) {
observer := &recordingObserver{} observer := &recordingObserver{}
plugin := NewPlugin[NoData]("wizard") plugin := NewPlugin[NoData]("wizard")
scene := plugin.NewScene("signup"). scene := plugin.Scene("signup").
SetEntry("start"). SetEntry("start").
OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) { OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) {
return ctx.Stay(), nil return ctx.Stay(), nil
@@ -435,7 +435,7 @@ func TestScenePayloadHandlerRunsBeforeStep(t *testing.T) {
stepCalled := false stepCalled := false
plugin := NewPlugin[NoData]("wizard") plugin := NewPlugin[NoData]("wizard")
plugin.NewScene("signup"). plugin.Scene("signup").
SetEntry("start"). SetEntry("start").
OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) { OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) {
stepCalled = true stepCalled = true
@@ -499,7 +499,7 @@ func TestScenePayloadHandlerRunsBeforeStep(t *testing.T) {
func TestScenePayloadObserverEmitsLifecycleEvents(t *testing.T) { func TestScenePayloadObserverEmitsLifecycleEvents(t *testing.T) {
observer := &recordingObserver{} observer := &recordingObserver{}
plugin := NewPlugin[NoData]("wizard") plugin := NewPlugin[NoData]("wizard")
plugin.NewScene("signup"). plugin.Scene("signup").
SetEntry("start"). SetEntry("start").
OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) { OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) {
return ctx.Stay(), nil return ctx.Stay(), nil
@@ -563,8 +563,8 @@ func TestSceneUnmatchedPayloadFallsThroughWithoutRunningStep(t *testing.T) {
stepCalled := false stepCalled := false
plugin := NewPlugin[NoData]("wizard") plugin := NewPlugin[NoData]("wizard")
plugin.NewPayload(func(ctx *MsgContext, db NoData) error { return nil }, "ping") plugin.Payload("ping", func(ctx *MsgContext, db NoData) error { return nil })
plugin.NewScene("signup"). plugin.Scene("signup").
SetEntry("start"). SetEntry("start").
OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) { OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) {
stepCalled = true stepCalled = true
@@ -632,11 +632,11 @@ func TestScenePassDoesNotPersistSessionData(t *testing.T) {
commandCalled := false commandCalled := false
plugin := NewPlugin[NoData]("wizard") plugin := NewPlugin[NoData]("wizard")
plugin.NewCommand(func(ctx *MsgContext, db NoData) error { plugin.Command("ping", func(ctx *MsgContext, db NoData) error {
commandCalled = true commandCalled = true
return nil return nil
}, "ping") })
plugin.NewScene("signup"). plugin.Scene("signup").
SetEntry("start"). SetEntry("start").
OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) { OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) {
if err := ctx.SaveData(struct { if err := ctx.SaveData(struct {
@@ -712,16 +712,16 @@ func TestSceneUnmatchedCommandFallsThroughWithoutRunningStep(t *testing.T) {
stepCalled := false stepCalled := false
plugin := NewPlugin[NoData]("wizard") plugin := NewPlugin[NoData]("wizard")
plugin.NewScene("signup"). plugin.Scene("signup").
SetEntry("start"). SetEntry("start").
OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) { OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) {
stepCalled = true stepCalled = true
return ctx.Stay(), nil return ctx.Stay(), nil
}) })
plugin.NewCommand(func(ctx *MsgContext, db NoData) error { plugin.Command("ping", func(ctx *MsgContext, db NoData) error {
commandCalled = true commandCalled = true
return nil return nil
}, "ping") })
bot := &Bot[NoData]{ bot := &Bot[NoData]{
logger: sneklog.NewLogger(), logger: sneklog.NewLogger(),
@@ -779,7 +779,7 @@ func TestSceneMessageFallbackRunsWhenNoCommandOrStepMatch(t *testing.T) {
fallbackCalled := false fallbackCalled := false
plugin := NewPlugin[NoData]("wizard") plugin := NewPlugin[NoData]("wizard")
plugin.NewScene("signup"). plugin.Scene("signup").
SetEntry("start"). SetEntry("start").
OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) { OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) {
return ctx.Stay(), nil return ctx.Stay(), nil
+2 -2
View File
@@ -83,10 +83,10 @@ func (opts *APIOpts) SetLimiter(limiter *utils.RateLimiter) *APIOpts {
return opts return opts
} }
// SetLimiterDrop enables "drop mode" for rate limiting. // SetDropRateLimitOverflow enables "drop mode" for rate limiting.
// If true, requests exceeding limits return ErrDropOverflow immediately. // If true, requests exceeding limits return ErrDropOverflow immediately.
// If false, requests block until capacity is available. // If false, requests block until capacity is available.
func (opts *APIOpts) SetLimiterDrop(b bool) *APIOpts { func (opts *APIOpts) SetDropRateLimitOverflow(b bool) *APIOpts {
opts.dropOverflowLimit = b opts.dropOverflowLimit = b
return opts return opts
} }
+4 -4
View File
@@ -652,15 +652,15 @@ func (api *API) GetChatAdministratorsWithContext(ctx context.Context, params Get
return req.DoWithContext(ctx, api) return req.DoWithContext(ctx, api)
} }
// GetChatMembersCount holds parameters for the getChatMemberCount method. // GetChatMemberCount holds parameters for the getChatMemberCount method.
// See https://core.telegram.org/bots/api#getchatmembercount // See https://core.telegram.org/bots/api#getchatmembercount
type GetChatMembersCount struct { type GetChatMemberCount struct {
ChatID int64 `json:"chat_id"` ChatID int64 `json:"chat_id"`
} }
// GetChatMemberCount returns the number of members in a chat. // GetChatMemberCount returns the number of members in a chat.
// See https://core.telegram.org/bots/api#getchatmembercount // See https://core.telegram.org/bots/api#getchatmembercount
func (api *API) GetChatMemberCount(params GetChatMembersCount) (int, error) { func (api *API) GetChatMemberCount(params GetChatMemberCount) (int, error) {
req := NewRequestWithChatID[int]("getChatMemberCount", params, params.ChatID) req := NewRequestWithChatID[int]("getChatMemberCount", params, params.ChatID)
return req.Do(api) return req.Do(api)
} }
@@ -668,7 +668,7 @@ func (api *API) GetChatMemberCount(params GetChatMembersCount) (int, error) {
// GetChatMemberCountWithContext is the context-aware variant of GetChatMemberCount. // GetChatMemberCountWithContext is the context-aware variant of GetChatMemberCount.
// It executes the same request but uses ctx for cancellation and deadlines. // It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#getchatmembercount // See https://core.telegram.org/bots/api#getchatmembercount
func (api *API) GetChatMemberCountWithContext(ctx context.Context, params GetChatMembersCount) (int, error) { func (api *API) GetChatMemberCountWithContext(ctx context.Context, params GetChatMemberCount) (int, error) {
req := NewRequestWithChatID[int]("getChatMemberCount", params, params.ChatID) req := NewRequestWithChatID[int]("getChatMemberCount", params, params.ChatID)
return req.DoWithContext(ctx, api) return req.DoWithContext(ctx, api)
} }
+1 -1
View File
@@ -362,7 +362,7 @@ type MessageEntity struct {
Language string `json:"language,omitempty"` Language string `json:"language,omitempty"`
CustomEmojiID string `json:"custom_emoji_id,omitempty"` CustomEmojiID string `json:"custom_emoji_id,omitempty"`
UnixTime int `json:"unix_time,omitempty"` UnixTime int64 `json:"unix_time,omitempty"`
DateTimeFormat string `json:"date_time_format,omitempty"` DateTimeFormat string `json:"date_time_format,omitempty"`
} }
+4 -4
View File
@@ -4,12 +4,12 @@ package tgapi
type ParseMode string type ParseMode string
const ( const (
// ParseMDV2 enables MarkdownV2 style parsing. // ParseMarkdownV2 enables MarkdownV2 style parsing.
ParseMDV2 ParseMode = "MarkdownV2" ParseMarkdownV2 ParseMode = "MarkdownV2"
// ParseHTML enables HTML style parsing. // ParseHTML enables HTML style parsing.
ParseHTML ParseMode = "HTML" ParseHTML ParseMode = "HTML"
// ParseMD enables legacy Markdown style parsing. // ParseMarkdown enables legacy Markdown style parsing.
ParseMD ParseMode = "Markdown" ParseMarkdown ParseMode = "Markdown"
// ParseNone disables parse_mode and leaves plain-text requests unannotated. // ParseNone disables parse_mode and leaves plain-text requests unannotated.
ParseNone ParseMode = "" ParseNone ParseMode = ""
) )
+1 -1
View File
@@ -25,7 +25,7 @@ func TestParseModeStillSerializesExplicitModes(t *testing.T) {
data, err := json.Marshal(SendMessage{ data, err := json.Marshal(SendMessage{
ChatID: 42, ChatID: 42,
Text: "hello", Text: "hello",
ParseMode: ParseMDV2, ParseMode: ParseMarkdownV2,
}) })
if err != nil { if err != nil {
t.Fatalf("Marshal returned error: %v", err) t.Fatalf("Marshal returned error: %v", err)
+2
View File
@@ -0,0 +1,2 @@
// Package tgmd provides small helpers for Telegram Markdown text.
package tgmd
+295
View File
@@ -0,0 +1,295 @@
package tgmd
import (
"strings"
"time"
"git.scuroneko.dev/scuroneko/extypes"
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
)
//TODO GoDoc, tests. Maybe escape Markdown v2
// MessageBuilder builds Telegram message text with explicit message entities.
// MessageBuilder is not safe for concurrent use.
type MessageBuilder struct {
str string
offset int
entities extypes.Slice[tgapi.MessageEntity]
entries extypes.Slice[*MessageBuilderEntry]
isDirty bool
}
// NewMessageBuilder returns an empty MessageBuilder.
func NewMessageBuilder() *MessageBuilder {
return &MessageBuilder{
entities: make([]tgapi.MessageEntity, 0),
entries: make(extypes.Slice[*MessageBuilderEntry], 0),
isDirty: false,
}
}
// String returns the built message text.
func (b *MessageBuilder) String() string {
if b.isDirty {
b.update()
}
return b.str
}
// Entities returns a copy of the built message entities.
func (b *MessageBuilder) Entities() []tgapi.MessageEntity {
if b.isDirty {
b.update()
}
return append([]tgapi.MessageEntity(nil), b.entities...)
}
func (b *MessageBuilder) Build() (string, []tgapi.MessageEntity) {
if b.isDirty {
b.update()
}
return b.str, append([]tgapi.MessageEntity(nil), b.entities...)
}
func (b *MessageBuilder) Reset() {
b.str = ""
b.offset = 0
b.entities = b.entities[:0]
b.entries = b.entries[:0]
b.isDirty = false
}
func (b *MessageBuilder) update() *MessageBuilder {
b.offset = 0
var textLen int
var entitiesLen int
for _, e := range b.entries {
textLen += len(e.text) // bytes, для Grow нормально
entitiesLen += len(e.entities)
}
b.entities = make(extypes.Slice[tgapi.MessageEntity], 0, entitiesLen)
var sb strings.Builder
sb.Grow(textLen)
for _, e := range b.entries {
sb.WriteString(e.text)
for _, entity := range e.entities {
entity.Offset += b.offset
b.entities = append(b.entities, entity)
}
b.offset += e.length
}
b.str = sb.String()
b.isDirty = false
return b
}
func (b *MessageBuilder) markDirty() {
b.isDirty = true
}
type MessageBuilderEntry struct {
text string
length int
b *MessageBuilder
entities extypes.Slice[tgapi.MessageEntity]
}
// Add appends plain text to the message and returns its entry for formatting.
func (b *MessageBuilder) Add(text string) *MessageBuilderEntry {
e := &MessageBuilderEntry{
b: b,
entities: make(extypes.Slice[tgapi.MessageEntity], 0),
text: text,
length: telegramTextLen(text),
}
b.entries = b.entries.Push(e)
b.markDirty()
return e
}
func (e *MessageBuilderEntry) Mention() *MessageBuilderEntry {
e.addEntity(tgapi.MessageEntity{
Type: tgapi.MessageEntityMention,
Offset: 0, Length: e.length,
})
return e
}
func (e *MessageBuilderEntry) Hashtag() *MessageBuilderEntry {
e.addEntity(tgapi.MessageEntity{
Type: tgapi.MessageEntityHashtag,
Offset: 0, Length: e.length,
})
return e
}
func (e *MessageBuilderEntry) Cashtag() *MessageBuilderEntry {
e.addEntity(tgapi.MessageEntity{
Type: tgapi.MessageEntityCashtag,
Offset: 0, Length: e.length,
})
return e
}
func (e *MessageBuilderEntry) BotCommand() *MessageBuilderEntry {
e.addEntity(tgapi.MessageEntity{
Type: tgapi.MessageEntityBotCommand,
Offset: 0, Length: e.length,
})
return e
}
func (e *MessageBuilderEntry) Email() *MessageBuilderEntry {
e.addEntity(tgapi.MessageEntity{
Type: tgapi.MessageEntityEmail,
Offset: 0, Length: e.length,
})
return e
}
func (e *MessageBuilderEntry) Phone() *MessageBuilderEntry {
e.addEntity(tgapi.MessageEntity{
Type: tgapi.MessageEntityPhoneNumber,
Offset: 0, Length: e.length,
})
return e
}
func (e *MessageBuilderEntry) Bold() *MessageBuilderEntry {
e.addEntity(tgapi.MessageEntity{
Type: tgapi.MessageEntityBold,
Offset: 0, Length: e.length,
})
return e
}
func (e *MessageBuilderEntry) Italic() *MessageBuilderEntry {
e.addEntity(tgapi.MessageEntity{
Type: tgapi.MessageEntityItalic,
Offset: 0, Length: e.length,
})
return e
}
func (e *MessageBuilderEntry) Underline() *MessageBuilderEntry {
e.addEntity(tgapi.MessageEntity{
Type: tgapi.MessageEntityUnderline,
Offset: 0, Length: e.length,
})
return e
}
func (e *MessageBuilderEntry) Strikethrough() *MessageBuilderEntry {
e.addEntity(tgapi.MessageEntity{
Type: tgapi.MessageEntityStrike,
Offset: 0, Length: e.length,
})
return e
}
func (e *MessageBuilderEntry) Spoiler() *MessageBuilderEntry {
e.addEntity(tgapi.MessageEntity{
Type: tgapi.MessageEntitySpoiler,
Offset: 0, Length: e.length,
})
return e
}
func (e *MessageBuilderEntry) Quote() *MessageBuilderEntry {
e.addEntity(tgapi.MessageEntity{
Type: tgapi.MessageEntityBlockquote,
Offset: 0, Length: e.length,
})
return e
}
func (e *MessageBuilderEntry) ExpandableQuote() *MessageBuilderEntry {
e.addEntity(tgapi.MessageEntity{
Type: tgapi.MessageEntityExpandableBlockquote,
Offset: 0, Length: e.length,
})
return e
}
func (e *MessageBuilderEntry) InlineCode() *MessageBuilderEntry {
e.addEntity(tgapi.MessageEntity{
Type: tgapi.MessageEntityCode,
Offset: 0, Length: e.length,
})
return e
}
func (e *MessageBuilderEntry) CodeBlock() *MessageBuilderEntry {
e.addEntity(tgapi.MessageEntity{
Type: tgapi.MessageEntityPre,
Offset: 0, Length: e.length,
})
return e
}
func (e *MessageBuilderEntry) CodeBlockWithLanguage(lang string) *MessageBuilderEntry {
e.addEntity(tgapi.MessageEntity{
Type: tgapi.MessageEntityPre,
Offset: 0, Length: e.length, Language: lang,
})
return e
}
func (e *MessageBuilderEntry) Link(url string) *MessageBuilderEntry {
e.addEntity(tgapi.MessageEntity{
Type: tgapi.MessageEntityTextLink,
Offset: 0, Length: e.length, URL: url,
})
return e
}
func (e *MessageBuilderEntry) TextMention(user *tgapi.User) *MessageBuilderEntry {
e.addEntity(tgapi.MessageEntity{
Type: tgapi.MessageEntityTextMention,
Offset: 0, Length: e.length, User: user,
})
return e
}
func (e *MessageBuilderEntry) CustomEmoji(emojiID string) *MessageBuilderEntry {
e.addEntity(tgapi.MessageEntity{
Type: tgapi.MessageEntityCustomEmoji,
Offset: 0, Length: e.length, CustomEmojiID: emojiID,
})
return e
}
func (e *MessageBuilderEntry) DateTime(time time.Time) *MessageBuilderEntry {
e.addEntity(tgapi.MessageEntity{
Type: tgapi.MessageEntityDateTime,
Offset: 0, Length: e.length, UnixTime: time.Unix(),
})
return e
}
func (e *MessageBuilderEntry) DateTimeFormat(time time.Time, format string) *MessageBuilderEntry {
e.addEntity(tgapi.MessageEntity{
Type: tgapi.MessageEntityDateTime,
Offset: 0, Length: e.length,
UnixTime: time.Unix(), DateTimeFormat: format,
})
return e
}
func telegramTextLen(text string) int {
n := 0
for _, r := range text {
if r <= 0xFFFF {
n++
} else {
n += 2
}
}
return n
}
func (e *MessageBuilderEntry) addEntity(entity tgapi.MessageEntity) {
if entity.Length <= 0 {
return
}
e.entities = append(e.entities, entity)
if e.b != nil {
e.b.markDirty()
}
}
+416
View File
@@ -0,0 +1,416 @@
package tgmd
import (
"reflect"
"testing"
"time"
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
)
func TestMessageBuilder_BuildPlainText(t *testing.T) {
b := NewMessageBuilder()
b.Add("Hello")
b.Add(", ")
b.Add("world")
text, entities := b.Build()
if text != "Hello, world" {
t.Fatalf("text = %q, want %q", text, "Hello, world")
}
if len(entities) != 0 {
t.Fatalf("entities len = %d, want 0", len(entities))
}
}
func TestMessageBuilder_EntityOffsetsAreUTF16(t *testing.T) {
b := NewMessageBuilder()
b.Add("Hi ")
b.Add("👋") // 2 UTF-16 code units
b.Add(" ")
b.Add("world").Bold()
text, entities := b.Build()
if text != "Hi 👋 world" {
t.Fatalf("text = %q, want %q", text, "Hi 👋 world")
}
want := []tgapi.MessageEntity{
{
Type: tgapi.MessageEntityBold,
Offset: 6, // H i space = 3, 👋 = 2, space = 1
Length: 5,
},
}
if !reflect.DeepEqual(entities, want) {
t.Fatalf("entities = %#v, want %#v", entities, want)
}
}
func TestMessageBuilder_EntityLengthIsUTF16(t *testing.T) {
b := NewMessageBuilder()
b.Add("👋").Bold()
text, entities := b.Build()
if text != "👋" {
t.Fatalf("text = %q, want %q", text, "👋")
}
want := []tgapi.MessageEntity{
{
Type: tgapi.MessageEntityBold,
Offset: 0,
Length: 2,
},
}
if !reflect.DeepEqual(entities, want) {
t.Fatalf("entities = %#v, want %#v", entities, want)
}
}
func TestMessageBuilder_MultipleEntitiesOnSameEntry(t *testing.T) {
b := NewMessageBuilder()
b.Add("hello").Bold().Italic()
_, entities := b.Build()
want := []tgapi.MessageEntity{
{
Type: tgapi.MessageEntityBold,
Offset: 0,
Length: 5,
},
{
Type: tgapi.MessageEntityItalic,
Offset: 0,
Length: 5,
},
}
if !reflect.DeepEqual(entities, want) {
t.Fatalf("entities = %#v, want %#v", entities, want)
}
}
func TestMessageBuilder_DoesNotDuplicateAfterRepeatedReads(t *testing.T) {
b := NewMessageBuilder()
b.Add("hello").Bold()
text1 := b.String()
entities1 := b.Entities()
text2 := b.String()
entities2 := b.Entities()
if text1 != text2 {
t.Fatalf("texts differ: %q != %q", text1, text2)
}
if !reflect.DeepEqual(entities1, entities2) {
t.Fatalf("entities differ: %#v != %#v", entities1, entities2)
}
want := []tgapi.MessageEntity{
{
Type: tgapi.MessageEntityBold,
Offset: 0,
Length: 5,
},
}
if !reflect.DeepEqual(entities2, want) {
t.Fatalf("entities = %#v, want %#v", entities2, want)
}
}
func TestMessageBuilder_AddEntityAfterStringMarksDirty(t *testing.T) {
b := NewMessageBuilder()
entry := b.Add("hello")
if got := b.String(); got != "hello" {
t.Fatalf("String() = %q, want %q", got, "hello")
}
entry.Bold()
entities := b.Entities()
want := []tgapi.MessageEntity{
{
Type: tgapi.MessageEntityBold,
Offset: 0,
Length: 5,
},
}
if !reflect.DeepEqual(entities, want) {
t.Fatalf("entities = %#v, want %#v", entities, want)
}
}
func TestMessageBuilder_EntitiesReturnsCopy(t *testing.T) {
b := NewMessageBuilder()
b.Add("hello").Bold()
entities1 := b.Entities()
entities1[0].Offset = 999
entities2 := b.Entities()
if entities2[0].Offset != 0 {
t.Fatalf("Entities() did not return copy: offset = %d, want 0", entities2[0].Offset)
}
}
func TestMessageBuilder_BuildReturnsEntitiesCopy(t *testing.T) {
b := NewMessageBuilder()
b.Add("hello").Bold()
_, entities1 := b.Build()
entities1[0].Offset = 999
_, entities2 := b.Build()
if entities2[0].Offset != 0 {
t.Fatalf("Build() did not return entities copy: offset = %d, want 0", entities2[0].Offset)
}
}
func TestMessageBuilder_Reset(t *testing.T) {
b := NewMessageBuilder()
b.Add("hello").Bold()
if got := b.String(); got != "hello" {
t.Fatalf("String() before Reset = %q, want %q", got, "hello")
}
b.Reset()
text, entities := b.Build()
if text != "" {
t.Fatalf("text after Reset = %q, want empty", text)
}
if len(entities) != 0 {
t.Fatalf("entities len after Reset = %d, want 0", len(entities))
}
b.Add("world").Italic()
text, entities = b.Build()
if text != "world" {
t.Fatalf("text after reuse = %q, want %q", text, "world")
}
want := []tgapi.MessageEntity{
{
Type: tgapi.MessageEntityItalic,
Offset: 0,
Length: 5,
},
}
if !reflect.DeepEqual(entities, want) {
t.Fatalf("entities after reuse = %#v, want %#v", entities, want)
}
}
func TestMessageBuilder_EmptyEntryDoesNotCreateEntity(t *testing.T) {
b := NewMessageBuilder()
b.Add("").Bold()
b.Add("x")
text, entities := b.Build()
if text != "x" {
t.Fatalf("text = %q, want %q", text, "x")
}
if len(entities) != 0 {
t.Fatalf("entities len = %d, want 0: %#v", len(entities), entities)
}
}
func TestMessageBuilder_Link(t *testing.T) {
b := NewMessageBuilder()
b.Add("OpenAI").Link("https://openai.com")
_, entities := b.Build()
want := []tgapi.MessageEntity{
{
Type: tgapi.MessageEntityTextLink,
Offset: 0,
Length: 6,
URL: "https://openai.com",
},
}
if !reflect.DeepEqual(entities, want) {
t.Fatalf("entities = %#v, want %#v", entities, want)
}
}
func TestMessageBuilder_CodeBlockWithLanguage(t *testing.T) {
b := NewMessageBuilder()
b.Add("fmt.Println(\"hi\")").CodeBlockWithLanguage("go")
_, entities := b.Build()
want := []tgapi.MessageEntity{
{
Type: tgapi.MessageEntityPre,
Offset: 0,
Length: 17,
Language: "go",
},
}
if !reflect.DeepEqual(entities, want) {
t.Fatalf("entities = %#v, want %#v", entities, want)
}
}
func TestMessageBuilder_DateTimeFormat(t *testing.T) {
b := NewMessageBuilder()
ts := time.Unix(1772323200, 0)
b.Add("date").DateTimeFormat(ts, "MMMM d, yyyy")
_, entities := b.Build()
want := []tgapi.MessageEntity{
{
Type: tgapi.MessageEntityDateTime,
Offset: 0,
Length: 4,
UnixTime: 1772323200,
DateTimeFormat: "MMMM d, yyyy",
},
}
if !reflect.DeepEqual(entities, want) {
t.Fatalf("entities = %#v, want %#v", entities, want)
}
}
func TestTelegramTextLen(t *testing.T) {
tests := []struct {
name string
text string
want int
}{
{
name: "ascii",
text: "hello",
want: 5,
},
{
name: "cyrillic",
text: "привет",
want: 6,
},
{
name: "emoji",
text: "👋",
want: 2,
},
{
name: "mixed",
text: "a👋b",
want: 4,
},
{
name: "zwj sequence",
text: "👨‍👩‍👧‍👦",
want: 11,
},
{
name: "flag",
text: "🇫🇮",
want: 4,
},
{
name: "variation selector",
text: "❤️",
want: 2,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := telegramTextLen(tt.text)
if got != tt.want {
t.Fatalf("telegramTextLen(%q) = %d, want %d", tt.text, got, tt.want)
}
})
}
}
func TestMessageBuilder_SimpleEntityTypes(t *testing.T) {
tests := []struct {
name string
add func(*MessageBuilderEntry)
want tgapi.MessageEntityType
}{
{"mention", func(e *MessageBuilderEntry) { e.Mention() }, tgapi.MessageEntityMention},
{"hashtag", func(e *MessageBuilderEntry) { e.Hashtag() }, tgapi.MessageEntityHashtag},
{"cashtag", func(e *MessageBuilderEntry) { e.Cashtag() }, tgapi.MessageEntityCashtag},
{"bot command", func(e *MessageBuilderEntry) { e.BotCommand() }, tgapi.MessageEntityBotCommand},
{"email", func(e *MessageBuilderEntry) { e.Email() }, tgapi.MessageEntityEmail},
{"phone", func(e *MessageBuilderEntry) { e.Phone() }, tgapi.MessageEntityPhoneNumber},
{"bold", func(e *MessageBuilderEntry) { e.Bold() }, tgapi.MessageEntityBold},
{"italic", func(e *MessageBuilderEntry) { e.Italic() }, tgapi.MessageEntityItalic},
{"underline", func(e *MessageBuilderEntry) { e.Underline() }, tgapi.MessageEntityUnderline},
{"strikethrough", func(e *MessageBuilderEntry) { e.Strikethrough() }, tgapi.MessageEntityStrike},
{"spoiler", func(e *MessageBuilderEntry) { e.Spoiler() }, tgapi.MessageEntitySpoiler},
{"quote", func(e *MessageBuilderEntry) { e.Quote() }, tgapi.MessageEntityBlockquote},
{"expandable quote", func(e *MessageBuilderEntry) { e.ExpandableQuote() }, tgapi.MessageEntityExpandableBlockquote},
{"inline code", func(e *MessageBuilderEntry) { e.InlineCode() }, tgapi.MessageEntityCode},
{"code block", func(e *MessageBuilderEntry) { e.CodeBlock() }, tgapi.MessageEntityPre},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
b := NewMessageBuilder()
e := b.Add("hello")
tt.add(e)
_, entities := b.Build()
want := []tgapi.MessageEntity{
{
Type: tt.want,
Offset: 0,
Length: 5,
},
}
if !reflect.DeepEqual(entities, want) {
t.Fatalf("entities = %#v, want %#v", entities, want)
}
})
}
}
+71
View File
@@ -0,0 +1,71 @@
package tgmd
import (
"strconv"
"strings"
"git.scuroneko.dev/scuroneko/laniakea"
)
// Helpers in this file generate Telegram MarkdownV2.
// All user-provided text is escaped.
// TODO Markdown v2 escaping. GoDoc and tests
// WithBold returns s wrapped as bold Telegram Markdown text.
func WithBold(s string) string {
return "*" + s + "*"
}
// WithItalic returns s wrapped as italic Telegram Markdown text.
func WithItalic(s string) string {
return "_" + s + "_"
}
func WithUnderline(s string) string {
return "__" + s + "__"
}
func WithStrikethrough(s string) string {
return "~" + s + "~"
}
func WithSpoiler(s string) string {
return "||" + s + "||"
}
// WithLink returns a Telegram Markdown link for text and URL.
func WithLink(text, url string) string {
return "[" + text + "](" + url + ")"
}
func WithMention(text string, userID uint64) string {
return "[" + text + "](tg://user?id=" + strconv.FormatUint(userID, 10) + ")"
}
func WithEmoji(text, emojiID string) string {
return "[" + text + "](tg://emoji?id=" + emojiID + ")"
}
func WithTime(text string, unix uint64) string {
return "![" + text + "](tg://time?unix=" + strconv.FormatUint(unix, 10) + ")"
}
func WithTimeFormat(text string, unix uint64, format string) string {
return "![" + text + "](tg://time?unix=" +
strconv.FormatUint(unix, 10) +
"&format=" + format + ")"
}
// WithInlineCode returns s wrapped as inline code Telegram Markdown text.
func WithInlineCode(s string) string {
return "`" + s + "`"
}
func WithBlockCode(s string) string {
return "```\n" + s + "\n```"
}
func WithBlockCodeLanguage(s, lang string) string {
return "```" + lang + "\n" + s + "\n```"
}
func WithQuote(s string) string {
return ">" + strings.ReplaceAll(laniakea.EscapeMarkdownV2(s), "\n", "\n>")
}
func WithQuoteExpandable(s string) string {
return "**>" + s
}
+24
View File
@@ -0,0 +1,24 @@
package tgmd
import "testing"
func TestFormattingHelpers(t *testing.T) {
tests := []struct {
name string
got string
want string
}{
{name: "bold", got: WithBold("text"), want: "*text*"},
{name: "italic", got: WithItalic("text"), want: "_text_"},
{name: "inline code", got: WithInlineCode("text"), want: "`text`"},
{name: "link", got: WithLink("Laniakea", "https://example.test"), want: "[Laniakea](https://example.test)"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if tc.got != tc.want {
t.Fatalf("unexpected formatted text: got %q want %q", tc.got, tc.want)
}
})
}
}