From 071fc2375e8d4560daca841c57664f0dadc78450 Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Thu, 30 Apr 2026 13:56:36 +0300 Subject: [PATCH] (draft): telegram markdown v2 string builder --- CHANGELOG.md | 14 +- README.md | 36 +-- README_RU.md | 36 +-- bot.go | 42 ++-- bot_opts.go | 16 +- bot_opts_loader.go | 13 +- bot_opts_loader_test.go | 30 +-- bot_register.go | 2 +- bot_test.go | 6 +- bot_webhook.go | 126 +++++------ bot_webhook_test.go | 61 +++-- cmd_generator.go | 6 +- cmd_generator_test.go | 8 +- commands.go | 58 +++-- doc.go | 2 +- handler_test.go | 44 ++-- keyboard.go | 28 +-- keyboard_test.go | 2 +- msg_context.go | 42 ++-- plugins.go | 64 +++--- plugins_test.go | 18 +- runners.go | 82 +++---- runners_test.go | 8 +- scene_test.go | 36 +-- tgapi/api.go | 4 +- tgapi/chat_methods.go | 8 +- tgapi/messages_types.go | 2 +- tgapi/methods_types.go | 8 +- tgapi/parse_mode_test.go | 2 +- tgmd/doc.go | 2 + tgmd/message_builder.go | 295 +++++++++++++++++++++++++ tgmd/message_builder_test.go | 416 +++++++++++++++++++++++++++++++++++ tgmd/utils.go | 71 ++++++ tgmd/utils_test.go | 24 ++ 34 files changed, 1227 insertions(+), 385 deletions(-) create mode 100644 tgmd/doc.go create mode 100644 tgmd/message_builder.go create mode 100644 tgmd/message_builder_test.go create mode 100644 tgmd/utils.go create mode 100644 tgmd/utils_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index b9d0774..82879cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,15 +2,27 @@ ## 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 `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 `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 - 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. -- `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 - Added regression coverage proving bot-level middleware blocks still complete the observer update lifecycle. diff --git a/README.md b/README.md index 09a8cd6..91cf0c1 100644 --- a/README.md +++ b/README.md @@ -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). * **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(...)`). -* **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") // 4. Add a command to the plugin. - // p.NewCommand(echo, "echo") creates a command that triggers the 'echo' function on the "/echo" command. - p.AddCommand(p.NewCommand(echo, "echo")) + // p.Command("echo", echo) creates a command that triggers the 'echo' function on the "/echo" command. + p.Command("echo", echo) // 5. Add another command using an anonymous function (closure). // This command simply replies "Pong" when the user sends "/ping". - p.AddCommand(p.NewCommand(func(ctx *laniakea.MsgContext, data laniakea.NoData) error { + p.Command("ping", func(ctx *laniakea.MsgContext, data laniakea.NoData) error { ctx.Answer("Pong") return nil - }, "ping")) + }) // 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). @@ -112,13 +112,13 @@ func main() { 1. `BotOpts`: Holds configuration like the API token. 2. `NewBot[T]`: Creates a bot instance. The type parameter T allows you to pass 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. -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. 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. 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. -10. A `Bot` instance is single-use. After `Run()`, `RunWithContext()`, or `RunWebHookWithContext()` returns, create a new bot instance for the next session. +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. ## File-Based Config @@ -151,7 +151,7 @@ See the full guide in the wiki: [Bot Options and Configuration](https://git.scur ## 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: - 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. Production notes: -- Set `BotWebHookOpts.SecretToken` for request authentication. -- `BotWebHookOpts.SecretToken` is required when `BotWebHookOpts.UseStatusPath` is enabled. -- 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. -- Use `RunWebHookWithContext(...)` with a cancelable context, then call `Close()` after runtime shutdown. +- Set `BotWebhookOpts.SecretToken` for request authentication. +- `BotWebhookOpts.SecretToken` is required when `BotWebhookOpts.UseStatusPath` is enabled. +- 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. +- 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) @@ -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. ```go plugin := laniakea.NewPlugin[*MyDB]("admin") -plugin.AddCommand(plugin.NewCommand(banUser, "ban")) +plugin.Command("ban", banUser) bot.AddPlugins(plugin) ``` @@ -238,7 +238,7 @@ Scenes model multi-step conversations inside a plugin. Each active scene is stor ```go plugin := laniakea.NewPlugin[MyDB]("signup") -plugin.NewScene("signup"). +plugin.Scene("signup"). SetScope(laniakea.SceneScopeUserChat). SetEntry("ask_name"). 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.AddMiddleware(laniakea.NewMiddleware("logging", loggingMiddleware)) plugin.AddMiddleware(laniakea.NewMiddleware("admin-only", adminOnlyMiddleware)) -plugin.AddCommand(plugin.NewCommand(banUser, "ban")) +plugin.Command("ban", banUser) ``` ### 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. - **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. -- **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 - Commands and payloads are handled through plugins. diff --git a/README_RU.md b/README_RU.md index a9595f5..8e22937 100644 --- a/README_RU.md +++ b/README_RU.md @@ -24,7 +24,7 @@ * **Встроенный ограничитель запросов (Rate Limiter):** Защитите бота от превышения лимитов Telegram API (с обработкой `retry_after`). * **Контекст данных:** Передавайте общие данные приложения или state в обработчики. * **Настраиваемый 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") // 4. Добавляем команду в плагин. - // p.NewCommand(echo, "echo") создаёт команду, которая вызывает функцию 'echo' по команде "/echo". - p.AddCommand(p.NewCommand(echo, "echo")) + // p.Command("echo", echo) создаёт команду, которая вызывает функцию 'echo' по команде "/echo". + p.Command("echo", echo) // 5. Добавляем ещё одну команду, используя анонимную функцию (замыкание). // Эта команда просто отвечает "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") return nil - }, "ping")) + }) // 6. Настраиваем бота: задаём шаблон ошибки и добавляем плагин. // SetErrorTemplate устанавливает формат для сообщений об ошибках (где %s будет заменён на текст ошибки). @@ -113,13 +113,13 @@ func main() { 1. `BotOpts`: Содержит конфигурацию, например, токен API. 2. `NewBot[T]`: Создаёт экземпляр бота. Параметр типа T позволяет передать общие данные приложения (например, *sql.DB или контейнер сервисов), которые будут доступны во всех обработчиках. Используйте laniakea.NoData, если они не нужны. 3. `NewPlugin`: Создаёт логическую группу для команд и Middleware. -4. `AddCommand`: Регистрирует команду. Первый аргумент — функция-обработчик (`func(*MsgContext, T) error`), второй — имя команды (без слеша). +4. `Command`: Создаёт и регистрирует команду. Первый аргумент — имя команды без слеша, второй — функция-обработчик (`func(*MsgContext, T) error`). 5. **Функции-обработчики**: Получают *MsgContext (детали сообщения, методы типа Answer) и ваши данные приложения типа T, а ошибку возвращают для централизованной обработки. 6. `SetErrorTemplate`: Устанавливает шаблон для сообщений об ошибках. Плейсхолдер %s заменяется на текст ошибки. 7. `AutoGenerateCommands`: Регистрирует команды из плагинов в Telegram для поддерживаемых scope. 8. `Run()`: Запускает цикл опроса обновлений бота и возвращает ошибку, если старт или polling завершился неуспешно. -9. `RunWebHookWithContext(...)`: Запускает bot-owned webhook runtime, когда Telegram должен доставлять update по HTTP вместо long polling. -10. Экземпляр `Bot` одноразовый. После завершения `Run()`, `RunWithContext()` или `RunWebHookWithContext()` для следующего запуска создавайте новый бот. +9. `RunWebhookWithContext(...)`: Запускает bot-owned webhook runtime, когда Telegram должен доставлять update по HTTP вместо long polling. +10. Экземпляр `Bot` одноразовый. После завершения `Run()`, `RunWithContext()` или `RunWebhookWithContext()` для следующего запуска создавайте новый бот. ## Конфиг из файла @@ -152,7 +152,7 @@ if err != nil { ## Webhook Runtime -Laniakea также поддерживает bot-owned webhook runtime через `RunWebHookWithContext(...)` и `RunWebHook(...)`. +Laniakea также поддерживает bot-owned webhook runtime через `RunWebhookWithContext(...)` и `RunWebhook(...)`. Используй его, когда: - Telegram должен сам отправлять update на твой HTTP endpoint вместо polling. @@ -160,11 +160,11 @@ Laniakea также поддерживает bot-owned webhook runtime чере - Ты хочешь, чтобы Laniakea сама регистрировала webhook и владела локальным HTTP server. Практические замечания: -- Задавай `BotWebHookOpts.SecretToken` для аутентификации запросов. -- Непустой `BotWebHookOpts.SecretToken` обязателен, если включён `BotWebHookOpts.UseStatusPath`. -- Используй явный `BotWebHookOpts.Path`, а не `/`. -- Если ты переводишь уже существующий deployment с webhook-режима на long polling, сначала удали webhook через `CloseWebHook()` или `tgapi.DeleteWebhook(...)`. Пока webhook не удалён, Telegram продолжает доставку через него. -- Запускай `RunWebHookWithContext(...)` с cancelable context и после остановки runtime всё равно вызывай `Close()`. +- Задавай `BotWebhookOpts.SecretToken` для аутентификации запросов. +- Непустой `BotWebhookOpts.SecretToken` обязателен, если включён `BotWebhookOpts.UseStatusPath`. +- Используй явный `BotWebhookOpts.Path`, а не `/`. +- Если ты переводишь уже существующий deployment с webhook-режима на long polling, сначала удали webhook через `CloseWebhook()` или `tgapi.DeleteWebhook(...)`. Пока webhook не удалён, Telegram продолжает доставку через него. +- Запускай `RunWebhookWithContext(...)` с cancelable context и после остановки runtime всё равно вызывай `Close()`. Полное руководство есть в wiki: [Webhook Runtime](https://git.scuroneko.dev/ScuroNeko/Laniakea/wiki/Webhook-Runtime-RU) @@ -174,7 +174,7 @@ Laniakea также поддерживает bot-owned webhook runtime чере ```go plugin := laniakea.NewPlugin[*MyDB]("admin") -plugin.AddCommand(plugin.NewCommand(banUser, "ban")) +plugin.Command("ban", banUser) bot.AddPlugins(plugin) ``` @@ -226,7 +226,7 @@ bot.SetAppData(db) ```go plugin := laniakea.NewPlugin[MyDB]("signup") -plugin.NewScene("signup"). +plugin.Scene("signup"). SetScope(laniakea.SceneScopeUserChat). SetEntry("ask_name"). 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.AddMiddleware(laniakea.NewMiddleware("logging", loggingMiddleware)) plugin.AddMiddleware(laniakea.NewMiddleware("admin-only", adminOnlyMiddleware)) -plugin.AddCommand(plugin.NewCommand(banUser, "ban")) +plugin.Command("ban", banUser) ``` ### Примеры middleware @@ -317,7 +317,7 @@ func adminOnlyMiddleware(ctx *laniakea.MsgContext, db *MyDB) bool { - **Ограничение запросов**: Передайте настроенный `utils.RateLimiter` через `BotOpts` для корректной обработки лимитов Telegram. - **Локализация**: `L10n` безопасен для конкурентного использования после подключения к боту. - **Пользовательские update handlers**: Используйте `plugin.AddUpdateHandler(...)` для Telegram update types вне command/payload flow. -- **Жизненный цикл**: `RunWithContext(...)` и `RunWebHookWithContext(...)` не вызывают `Close()` автоматически. Завершайте бот явно и создавайте новый `Bot` для следующего запуска. +- **Жизненный цикл**: `RunWithContext(...)` и `RunWebhookWithContext(...)` не вызывают `Close()` автоматически. Завершайте бот явно и создавайте новый `Bot` для следующего запуска. ## Обработка Telegram Updates - Команды и payload-ы обрабатываются через плагины. diff --git a/bot.go b/bot.go index 7f3a8ff..782403b 100644 --- a/bot.go +++ b/bot.go @@ -59,7 +59,7 @@ var ( ErrNoPrefixes = errors.New("no prefixes defined") // ErrNoPlugins reports that the bot was started without any registered plugins. 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") // ErrTokenRequired reports that BotOpts.Token was empty. @@ -78,8 +78,8 @@ var ( // - Localization and draft message support // // Runtime accessors are safe for concurrent use. Configure the bot before Run, -// RunWithContext, or RunWebHookWithContext. -// A Bot is single-use: after Run, RunWithContext, or RunWebHookWithContext returns, +// RunWithContext, or RunWebhookWithContext. +// A Bot is single-use: after Run, RunWithContext, or RunWebhookWithContext returns, // create a new Bot for the next session. type Bot[T AppData] struct { token string @@ -95,7 +95,7 @@ type Bot[T AppData] struct { logger *sneklog.Logger // Main bot logger (JSON stdout + optional file) requestLogger *sneklog.Logger // Optional request-level API logging 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 plugins []Plugin[T] // Command/event handlers @@ -164,7 +164,7 @@ func NewBot[T any](opts *BotOpts) (*Bot[T], error) { SetAPIURL(opts.APIURL). UseTestServer(opts.UseTestServer). SetLimiter(limiter). - SetLimiterDrop(opts.DropRLOverflow). + SetDropRateLimitOverflow(opts.DropRateLimitOverflow). SetLogFormat(opts.LogFormat). SetLogFormatter(opts.LogFormatter) api := tgapi.NewAPI(apiOpts) @@ -256,12 +256,16 @@ func (bot *Bot[T]) SetRequestLogger(l *sneklog.Logger) *Bot[T] { return bot } -// SetWebHookLogger replaces the webhook logger. -func (bot *Bot[T]) SetWebHookLogger(l *sneklog.Logger) *Bot[T] { - bot.webHookLogger = l +// SetWebhookLogger replaces the webhook logger. +func (bot *Bot[T]) SetWebhookLogger(l *sneklog.Logger) *Bot[T] { + bot.webhookLogger = l 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 shuts down, in order: @@ -272,7 +276,7 @@ func (bot *Bot[T]) SetWebHookLogger(l *sneklog.Logger) *Bot[T] { // - RequestLogger (if enabled) // - 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 // these resources. // @@ -294,11 +298,11 @@ func (bot *Bot[T]) Close() error { e = append(e, err) } } - if bot.webHookLogger != nil { - if err := bot.webHookLogger.Close(); err != nil { + if bot.webhookLogger != nil { + if err := bot.webhookLogger.Close(); err != nil { logCloseErr(err) } - bot.webHookLogger = nil + bot.webhookLogger = nil } if bot.uploader != 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. func (bot *Bot[T]) GetRequestLogger() *sneklog.Logger { return bot.requestLogger } -// GetWebHookLogger returns the webhook logger, if configured. -func (bot *Bot[T]) GetWebHookLogger() *sneklog.Logger { return bot.webHookLogger } +// GetWebhookLogger returns the webhook logger, if configured. +func (bot *Bot[T]) GetWebhookLogger() *sneklog.Logger { return bot.webhookLogger } // GetLoggerLevel returns the effective log level derived from the bot's debug // flag. @@ -387,7 +391,7 @@ func (bot *Bot[T]) L10n(lang, key string) string { // - Waits for registered runners to exit // // 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. // // 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 } - if bot.webHookLogger != nil { - bot.logger.Warnln("Bot#webHookLogger present. You shouldn't set this, if ran in Long Polling mode!") - err := bot.webHookLogger.Close() + if bot.webhookLogger != nil { + bot.logger.Warnln("Bot#webhookLogger present. You shouldn't set this, if ran in Long Polling mode!") + err := bot.webhookLogger.Close() if err != nil { bot.logger.Errorln(err) } - bot.webHookLogger = nil + bot.webhookLogger = nil } bot.ExecRunners(ctx) diff --git a/bot_opts.go b/bot_opts.go index 790927d..7e0c793 100644 --- a/bot_opts.go +++ b/bot_opts.go @@ -54,9 +54,9 @@ type BotOpts struct { // Telegram allows up to 30 req/s for most bots. Defaults to 30. 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. - DropRLOverflow bool + DropRateLimitOverflow bool // StrictPayloadType disables callback payload fallback decoding. // 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", APIURL: os.Getenv("API_URL"), - RateLimit: rateLimit, - DropRLOverflow: os.Getenv("DROP_RL_OVERFLOW") == "true", - StrictPayloadType: os.Getenv("STRICT_PAYLOAD_TYPE") == "true", + RateLimit: rateLimit, + DropRateLimitOverflow: os.Getenv("DROP_RL_OVERFLOW") == "true", + StrictPayloadType: os.Getenv("STRICT_PAYLOAD_TYPE") == "true", MaxWorkers: maxWorkers, FileConfigVersion: 0, @@ -223,10 +223,10 @@ func (opts *BotOpts) SetRateLimit(limit int) *BotOpts { 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. -func (opts *BotOpts) SetDropRLOverflow(drop bool) *BotOpts { - opts.DropRLOverflow = drop +func (opts *BotOpts) SetDropRateLimitOverflow(drop bool) *BotOpts { + opts.DropRateLimitOverflow = drop return opts } diff --git a/bot_opts_loader.go b/bot_opts_loader.go index 99f4ba7..4e86993 100644 --- a/bot_opts_loader.go +++ b/bot_opts_loader.go @@ -70,10 +70,10 @@ func (codec BotOptsFileJSONCodec) FromBytes(data []byte) (*BotOpts, error) { WriteToFile: fileOpts.Logger.WriteToFile, LogFormat: fileOpts.Logger.LogFormat, - UseTestServer: fileOpts.API.UseTestServer, - APIURL: fileOpts.API.APIURL, - RateLimit: fileOpts.API.RateLimit, - DropRLOverflow: fileOpts.API.DropRLOverflow, + UseTestServer: fileOpts.API.UseTestServer, + APIURL: fileOpts.API.APIURL, + RateLimit: fileOpts.API.RateLimit, + DropRateLimitOverflow: fileOpts.API.DropRLOverflow, StrictPayloadType: fileOpts.StrictPayloadType, MaxWorkers: fileOpts.MaxWorkers, @@ -102,7 +102,7 @@ func (codec BotOptsFileJSONCodec) ToBytes(opts *BotOpts) ([]byte, error) { UseTestServer: opts.UseTestServer, APIURL: opts.APIURL, RateLimit: opts.RateLimit, - DropRLOverflow: opts.DropRLOverflow, + DropRLOverflow: opts.DropRateLimitOverflow, }, StrictPayloadType: opts.StrictPayloadType, MaxWorkers: opts.MaxWorkers, @@ -114,9 +114,12 @@ func (codec BotOptsFileJSONCodec) ToBytes(opts *BotOpts) ([]byte, error) { return data, nil } +// Load reads BotOpts from a JSON config file. func (codec BotOptsFileJSONCodec) Load(filename string) (*BotOpts, error) { return LoadBotOptsFile(codec, filename) } + +// Save writes BotOpts to a JSON config file. func (codec BotOptsFileJSONCodec) Save(filename string, opts *BotOpts) error { return SaveBotOptsFile(codec, filename, opts) } diff --git a/bot_opts_loader_test.go b/bot_opts_loader_test.go index bb8c82d..63419ca 100644 --- a/bot_opts_loader_test.go +++ b/bot_opts_loader_test.go @@ -13,21 +13,21 @@ import ( func TestBotOptsFileJSONCodecRoundTrip(t *testing.T) { codec := BotOptsFileJSONCodec{} want := &BotOpts{ - Token: "TOKEN", - UpdateTypes: []tgapi.UpdateType{tgapi.UpdateTypeMessage, tgapi.UpdateTypeCallbackQuery}, - Debug: true, - ErrorTemplate: "Error: %s", - Prefixes: []string{"/", "!"}, - LoggerBasePath: "/tmp/logs", - UseRequestLogger: true, - WriteToFile: true, - UseTestServer: true, - APIURL: "https://api.example.invalid", - RateLimit: 42, - DropRLOverflow: true, - StrictPayloadType: true, - MaxWorkers: 64, - FileConfigVersion: ConfigVersion, + Token: "TOKEN", + UpdateTypes: []tgapi.UpdateType{tgapi.UpdateTypeMessage, tgapi.UpdateTypeCallbackQuery}, + Debug: true, + ErrorTemplate: "Error: %s", + Prefixes: []string{"/", "!"}, + LoggerBasePath: "/tmp/logs", + UseRequestLogger: true, + WriteToFile: true, + UseTestServer: true, + APIURL: "https://api.example.invalid", + RateLimit: 42, + DropRateLimitOverflow: true, + StrictPayloadType: true, + MaxWorkers: 64, + FileConfigVersion: ConfigVersion, } data, err := codec.ToBytes(want) diff --git a/bot_register.go b/bot_register.go index f12c250..2791d3f 100644 --- a/bot_register.go +++ b/bot_register.go @@ -95,7 +95,7 @@ func (bot *Bot[T]) UsePolicy(name string, policy Policy[T]) *Bot[T] { // - Scheduled tasks (e.g., daily announcements) // // Runners start from the bot runtime entry points, immediately after -// RunWithContext or RunWebHookWithContext begins. +// RunWithContext or RunWebhookWithContext begins. // // Example: // diff --git a/bot_test.go b/bot_test.go index c35b5fb..9ca08a9 100644 --- a/bot_test.go +++ b/bot_test.go @@ -63,13 +63,13 @@ func TestAddPluginsSnapshotsConfiguration(t *testing.T) { bot := &Bot[NoData]{logger: sneklog.NewLogger()} 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 })) bot.AddPlugins(plugin) 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 })) registered := bot.plugins[0] @@ -446,7 +446,7 @@ func TestCloseDoesNotDeleteWebhook(t *testing.T) { bot := &Bot[NoData]{ logger: sneklog.NewLogger(), - webHookLogger: sneklog.NewLogger(), + webhookLogger: sneklog.NewLogger(), api: api, uploader: uploader, } diff --git a/bot_webhook.go b/bot_webhook.go index 72ea5cb..6d26778 100644 --- a/bot_webhook.go +++ b/bot_webhook.go @@ -15,8 +15,8 @@ import ( "git.scuroneko.dev/scuroneko/laniakea/utils" ) -// BotWebHookOpts configures Telegram webhook registration and the local HTTP server. -type BotWebHookOpts struct { +// BotWebhookOpts configures Telegram webhook registration and the local HTTP server. +type BotWebhookOpts struct { Path string LocalPort int UseStatusPath bool @@ -30,9 +30,9 @@ type BotWebHookOpts struct { SecretToken string } -// NewBotWebHookOpts returns webhook options with the default path, local port, and max connections. -func NewBotWebHookOpts() *BotWebHookOpts { - return &BotWebHookOpts{ +// NewBotWebhookOpts returns webhook options with the default path, local port, and max connections. +func NewBotWebhookOpts() *BotWebhookOpts { + return &BotWebhookOpts{ Path: "/", LocalPort: 8080, MaxConnections: 40, @@ -40,38 +40,38 @@ func NewBotWebHookOpts() *BotWebHookOpts { } // 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 return opts } // 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 return opts } // SetUseStatusPath enables or disables the optional /status endpoint. // 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 return opts } // 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 return opts } // 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 return opts } // 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) if err != nil { 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. -func (opts *BotWebHookOpts) SetIPAddress(ip string) *BotWebHookOpts { +func (opts *BotWebhookOpts) SetIPAddress(ip string) *BotWebhookOpts { opts.IPAddress = ip return opts } // 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 return opts } // 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...) return opts } // 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 return opts } // SetSecretToken sets the secret token expected in Telegram webhook requests. // 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 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 // 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 // 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 { - return errors.New("nil BotWebHookOpts") + return errors.New("nil BotWebhookOpts") } if len(bot.prefixes) == 0 { return ErrNoPrefixes @@ -137,44 +137,42 @@ func (bot *Bot[T]) RunWebHookWithContext(ctx context.Context, opts *BotWebHookOp return ErrNoPlugins } if opts.URL == "" { - return errors.New("empty BotWebHookOpts.URL") + return errors.New("empty BotWebhookOpts.URL") } 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 { return err } 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 { 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 { return errors.New("bot uploader nil, but certificate set") } 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) if err != nil { return err } 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 { _, err = bot.api.DeleteWebhookWithContext(runCtx, tgapi.DeleteWebhook{}) if err != nil { return err } - bot.webHookLogger.Infof("Bot webhook deleted: %s", i.URL) + bot.webhookLogger.Infof("Bot webhook deleted: %s", i.URL) } allowedUpdates := bot.webhookAllowedUpdates(opts) @@ -207,48 +205,48 @@ func (bot *Bot[T]) RunWebHookWithContext(ctx context.Context, opts *BotWebHookOp } 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...). -func (bot *Bot[T]) RunWebHook(opts *BotWebHookOpts, tlsFiles ...string) error { - return bot.RunWebHookWithContext(context.Background(), opts, tlsFiles...) +// It is shorthand for RunWebhookWithContext(context.Background(), opts, tlsFiles...). +func (bot *Bot[T]) RunWebhook(opts *BotWebhookOpts, tlsFiles ...string) error { + 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. // 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 if bot.api == nil { e = append(e, errors.New("bot api nil")) } else { if _, err := bot.api.DeleteWebhook(tgapi.DeleteWebhook{}); err != nil { - if bot.webHookLogger != nil { - bot.webHookLogger.Errorf("Failed to close webhook: %s", err.Error()) + if bot.webhookLogger != nil { + bot.webhookLogger.Errorf("Failed to close webhook: %s", err.Error()) } else if bot.logger != nil { bot.logger.Errorf("Failed to close webhook: %s", err.Error()) } e = append(e, err) } } - if bot.webHookLogger != nil { - if err := bot.webHookLogger.Close(); err != nil { + if bot.webhookLogger != nil { + if err := bot.webhookLogger.Close(); err != nil { e = append(e, err) } - bot.webHookLogger = nil + bot.webhookLogger = nil } 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 { 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) defer cancel() + if bot.webhookLogger == nil { + bot.webhookLogger = utils.CreateLogger("WEBHOOK", bot.GetLoggerLevel(), bot.logFormat, bot.logFormatter) + } + bot.addTokenReplacer(bot.webhookLogger) bot.ExecRunners(runCtx) 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 if err := json.Unmarshal(data, &up); err != nil { w.WriteHeader(http.StatusBadRequest) - bot.webHookLogger.Errorln(err) + bot.webhookLogger.Errorln(err) 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 { - bot.webHookLogger.Errorln(err) + bot.webhookLogger.Errorln(err) w.WriteHeader(http.StatusServiceUnavailable) 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) { auth := "" 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()) if err != nil { - bot.webHookLogger.Errorln(err) + bot.webhookLogger.Errorln(err) w.WriteHeader(http.StatusInternalServerError) return } data, err := json.MarshalIndent(i, "", " ") if err != nil { - bot.webHookLogger.Errorln(err) + bot.webhookLogger.Errorln(err) w.WriteHeader(http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") 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() if opts.UseStatusPath { 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)) 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{ Addr: fmt.Sprintf(":%d", opts.LocalPort), - Handler: bot.newWebHookMux(ctx, opts), + Handler: bot.newWebhookMux(ctx, opts), } errCh := make(chan error, 1) @@ -384,7 +386,7 @@ func (bot *Bot[T]) runWebHook(ctx context.Context, opts *BotWebHookOpts) error { 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 { case <-ctx.Done(): @@ -401,10 +403,10 @@ func (bot *Bot[T]) runWebHook(ctx context.Context, opts *BotWebHookOpts) error { 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{ Addr: fmt.Sprintf(":%d", opts.LocalPort), - Handler: bot.newWebHookMux(ctx, opts), + Handler: bot.newWebhookMux(ctx, opts), } errCh := make(chan error, 1) @@ -417,7 +419,7 @@ func (bot *Bot[T]) runWebHookTLS(ctx context.Context, opts *BotWebHookOpts, key, 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 { 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 { if path == "" { - return errors.New("empty BotWebHookOpts.Path") + return errors.New("empty BotWebhookOpts.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, "#") { - 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" { - 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 } diff --git a/bot_webhook_test.go b/bot_webhook_test.go index 7df0fb2..6a00f17 100644 --- a/bot_webhook_test.go +++ b/bot_webhook_test.go @@ -36,10 +36,10 @@ func TestEnqueueUpdateCopiesValue(t *testing.T) { func TestUpdateHandlerEnqueuesUpdate(t *testing.T) { bot := &Bot[NoData]{ updateQueue: make(chan *tgapi.Update, 1), - webHookLogger: sneklog.NewLogger(), + webhookLogger: sneklog.NewLogger(), } 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"}}`)) @@ -94,7 +94,7 @@ func TestRunWebhookRuntimeExecutesRunners(t *testing.T) { NewRunner("runner", func(bot *Bot[NoData]) error { calls.Add(1) return nil - }).Onetime(true).Async(false), + }).Once(true).Async(false), }, } 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) { var calls atomic.Int32 plugin := NewPlugin[NoData]("demo") - plugin.NewCommand(func(ctx *MsgContext, db NoData) error { + plugin.Command("start", func(ctx *MsgContext, db NoData) error { calls.Add(1) return nil - }, "start") + }) bot := &Bot[NoData]{ logger: sneklog.NewLogger(), - webHookLogger: sneklog.NewLogger(), + webhookLogger: sneklog.NewLogger(), prefixes: []string{"/"}, plugins: []Plugin[NoData]{*plugin}, updateQueue: make(chan *tgapi.Update, 1), @@ -127,7 +150,7 @@ func TestRunWebhookRuntimeProcessesEnqueuedUpdate(t *testing.T) { } t.Cleanup(func() { _ = bot.logger.Close() - _ = bot.webHookLogger.Close() + _ = bot.webhookLogger.Close() }) err := bot.runWebhookRuntime(context.Background(), func(ctx context.Context) error { @@ -162,7 +185,7 @@ func TestWebhookAllowedUpdatesUsesBotUpdateTypesByDefault(t *testing.T) { bot := &Bot[NoData]{ updateTypes: []tgapi.UpdateType{tgapi.UpdateTypeMessage, tgapi.UpdateTypeCallbackQuery}, } - opts := NewBotWebHookOpts() + opts := NewBotWebhookOpts() got := bot.webhookAllowedUpdates(opts) if len(got) != 2 { @@ -235,10 +258,10 @@ func TestValidateWebhookTLSFiles(t *testing.T) { func TestUpdateHandlerRejectsOversizedBody(t *testing.T) { bot := &Bot[NoData]{ updateQueue: make(chan *tgapi.Update, 1), - webHookLogger: sneklog.NewLogger(), + webhookLogger: sneklog.NewLogger(), } t.Cleanup(func() { - _ = bot.webHookLogger.Close() + _ = bot.webhookLogger.Close() }) 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]{ api: api, - webHookLogger: sneklog.NewLogger(), + webhookLogger: sneklog.NewLogger(), } t.Cleanup(func() { - _ = bot.webHookLogger.Close() + _ = bot.webhookLogger.Close() }) - handler := statusHandler(bot, &BotWebHookOpts{SecretToken: "secret"}) + handler := statusHandler(bot, &BotWebhookOpts{SecretToken: "secret"}) tests := []struct { name string @@ -308,14 +331,14 @@ func TestStatusHandlerRequiresMatchingSecret(t *testing.T) { } } -func TestRunWebHookWithContextRejectsInvalidTLSFilesBeforeRemoteSetup(t *testing.T) { +func TestRunWebhookWithContextRejectsInvalidTLSFilesBeforeRemoteSetup(t *testing.T) { bot := &Bot[NoData]{ prefixes: []string{"/"}, 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 { 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]{ prefixes: []string{"/"}, plugins: []Plugin[NoData]{{name: "demo"}}, } - opts := NewBotWebHookOpts(). + opts := NewBotWebhookOpts(). SetURL("https://bot.example.com"). SetUseStatusPath(true) - err := bot.RunWebHookWithContext(context.Background(), opts) + err := bot.RunWebhookWithContext(context.Background(), opts) if err == nil { t.Fatal("expected status-path secret validation error, got nil") } diff --git a/cmd_generator.go b/cmd_generator.go index 253757b..3008d50 100644 --- a/cmd_generator.go +++ b/cmd_generator.go @@ -10,8 +10,8 @@ import ( "git.scuroneko.dev/scuroneko/laniakea/tgapi" ) -// CmdRegexp matches command names allowed for Telegram command registration. -var CmdRegexp = regexp.MustCompile("^[_a-z0-9]{1,32}$") +// cmdRegexp matches command names allowed for Telegram command registration. +var cmdRegexp = regexp.MustCompile("^[_a-z0-9]{1,32}$") // ErrTooManyCommands is returned when the total number of registered commands // 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. -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. func gatherCommandsForPlugin[T any](pl Plugin[T]) []tgapi.BotCommand { diff --git a/cmd_generator_test.go b/cmd_generator_test.go index ee6bc7d..72a1221 100644 --- a/cmd_generator_test.go +++ b/cmd_generator_test.go @@ -46,7 +46,7 @@ func TestAutoGenerateCommandsChecksLimitBeforeDelete(t *testing.T) { plugin := NewPlugin[NoData]("overflow") exec := func(ctx *MsgContext, db NoData) error { return nil } for i := 0; i < 101; i++ { - plugin.AddCommand(NewCommand(exec, "cmd"+strconv.Itoa(i))) + plugin.Command("cmd"+strconv.Itoa(i), exec) } bot := &Bot[NoData]{ @@ -68,9 +68,9 @@ func TestGatherCommandsForPluginReturnsSortedCommands(t *testing.T) { plugin := NewPlugin[NoData]("sorted") exec := func(ctx *MsgContext, db NoData) error { return nil } - plugin.AddCommand(NewCommand(exec, "zeta")) - plugin.AddCommand(NewCommand(exec, "alpha")) - plugin.AddCommand(NewCommand(exec, "mid")) + plugin.Command("zeta", exec) + plugin.Command("alpha", exec) + plugin.Command("mid", exec) commands := gatherCommandsForPlugin(*plugin) got := make([]string, 0, len(commands)) diff --git a/commands.go b/commands.go index d98fd79..fd801b6 100644 --- a/commands.go +++ b/commands.go @@ -2,7 +2,6 @@ package laniakea import ( "errors" - "fmt" "regexp" "git.scuroneko.dev/scuroneko/extypes" @@ -12,14 +11,14 @@ import ( type CommandValueType string const ( - // CommandValueStringType expects any non-empty string. - CommandValueStringType CommandValueType = "string" - // CommandValueIntType expects a decimal integer (digits only). - CommandValueIntType CommandValueType = "int" - // CommandValueBoolType expects a exact "true" or "false". - CommandValueBoolType CommandValueType = "bool" - // CommandValueAnyType accepts any input without validation. - CommandValueAnyType CommandValueType = "any" + // CommandValueString expects any non-empty string. + CommandValueString CommandValueType = "string" + // CommandValueInt expects a decimal integer (digits only). + CommandValueInt CommandValueType = "int" + // CommandValueBool expects an exact "true" or "false". + CommandValueBool CommandValueType = "bool" + // CommandValueAny accepts any input without validation. + CommandValueAny CommandValueType = "any" ) var ( @@ -52,22 +51,23 @@ type CommandArg struct { required bool // Whether this argument must be provided } -// NewCommandArg creates a new CommandArg with the given text and type. -// Uses a default regex based on the type (string or int). -// For CommandValueAnyType, no validation is performed. +// NewCommandArg creates an optional argument without value validation. 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. func (c CommandArg) SetValueType(t CommandValueType) CommandArg { - regex := CommandRegexString + var regex *regexp.Regexp switch t { - case CommandValueIntType: + case CommandValueInt: regex = CommandRegexInt - case CommandValueBoolType: + case CommandValueBool: regex = CommandRegexBool - case CommandValueAnyType: + case CommandValueString: + regex = CommandRegexString + case CommandValueAny: + default: regex = nil // Skip validation } 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 } -// 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"). -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} } -// 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. -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} } @@ -145,7 +145,7 @@ func (c *Command[T]) validateArgs(args []string) error { } cmdArg := c.args.Get(i) if cmdArg.regex == nil { - continue // Skip validation for CommandValueAnyType + continue // Skip validation for CommandValueAny. } if !cmdArg.regex.MatchString(arg) { 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. type CommandGroup[T any] struct { - prefix string - separator string - + prefix string middlewares extypes.Slice[Middleware[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. func NewCommandGroup[T any](prefix string) *CommandGroup[T] { return &CommandGroup[T]{ - prefix: prefix, separator: "", + prefix: prefix, middlewares: make([]Middleware[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. func (g *CommandGroup[T]) Use(m Middleware[T]) *CommandGroup[T] { g.middlewares = append(g.middlewares, m) @@ -202,7 +194,7 @@ func (g *CommandGroup[T]) AddCommand(cmd *Command[T]) *CommandGroup[T] { return g } 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) return g } diff --git a/doc.go b/doc.go index 3be732d..f30fb46 100644 --- a/doc.go +++ b/doc.go @@ -27,7 +27,7 @@ Example usage: 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. */ package laniakea diff --git a/handler_test.go b/handler_test.go index f61b4ff..6441097 100644 --- a/handler_test.go +++ b/handler_test.go @@ -757,10 +757,10 @@ func TestHandleMessageFallbackDoesNotRunWhenCommandMatches(t *testing.T) { commandCalled := false fallbackCalled := false plugin := NewPlugin[NoData]("test") - plugin.NewCommand(func(ctx *MsgContext, db NoData) error { + plugin.Command("start", func(ctx *MsgContext, db NoData) error { commandCalled = true return nil - }, "start") + }) plugin.SetMessageFallback(func(ctx *MsgContext, db NoData) error { fallbackCalled = true return nil @@ -794,7 +794,7 @@ func TestHandleMessageFallbackDoesNotRunWhenCommandMatches(t *testing.T) { func TestHandleChannelPostCommandWithSenderChat(t *testing.T) { called := false plugin := NewPlugin[NoData]("test") - plugin.NewCommand(func(ctx *MsgContext, db NoData) error { + plugin.Command("ping", func(ctx *MsgContext, db NoData) error { called = true if ctx.Msg == nil { 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) } return nil - }, "ping") + }) bot := &Bot[NoData]{ logger: sneklog.NewLogger(), @@ -841,10 +841,10 @@ func TestCommandHandlerBindArgsEndToEnd(t *testing.T) { var got banInput 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) - }, "ban", - NewCommandArg("user_id").SetValueType(CommandValueIntType).SetRequired(), + }, + NewCommandArg("user_id").SetValueType(CommandValueInt).SetRequired(), NewCommandArg("reason").SetRequired(), ) @@ -878,10 +878,10 @@ func TestPayloadHandlerBindArgsEndToEnd(t *testing.T) { var got payloadInput 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) - }, "approve", - NewCommandArg("id").SetValueType(CommandValueIntType).SetRequired(), + }, + NewCommandArg("id").SetValueType(CommandValueInt).SetRequired(), NewCommandArg("note").SetRequired(), ) @@ -920,10 +920,10 @@ func TestHandleEditedMessageStaysOutOfCommandFlow(t *testing.T) { updateCalled := false plugin := NewPlugin[NoData]("test") - plugin.NewCommand(func(ctx *MsgContext, db NoData) error { + plugin.Command("ping", func(ctx *MsgContext, db NoData) error { commandCalled = true return nil - }, "ping") + }) plugin.AddUpdateHandler(tgapi.UpdateTypeEditedMessage, func(ctx *MsgContext, db NoData) error { updateCalled = true if ctx.Msg == nil { @@ -968,10 +968,10 @@ func TestHandleEditedChannelPostStaysOutOfCommandFlow(t *testing.T) { updateCalled := false plugin := NewPlugin[NoData]("test") - plugin.NewCommand(func(ctx *MsgContext, db NoData) error { + plugin.Command("ping", func(ctx *MsgContext, db NoData) error { commandCalled = true return nil - }, "ping") + }) plugin.AddUpdateHandler(tgapi.UpdateTypeEditedChannelPost, func(ctx *MsgContext, db NoData) error { updateCalled = true if ctx.Msg == nil { @@ -1007,7 +1007,7 @@ func TestHandleEditedChannelPostStaysOutOfCommandFlow(t *testing.T) { func TestHandleCallbackPopulatesMessageTargets(t *testing.T) { called := false plugin := NewPlugin[NoData]("test") - plugin.NewPayload(func(ctx *MsgContext, db NoData) error { + plugin.Payload("approve", func(ctx *MsgContext, db NoData) error { called = true if ctx.CallbackQueryID != "cb-msg" { 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) } return nil - }, "approve") + }) bot := &Bot[NoData]{ logger: sneklog.NewLogger(), @@ -1066,7 +1066,7 @@ func TestHandleCallbackPopulatesMessageTargets(t *testing.T) { func TestHandleCallbackPopulatesInlineTargets(t *testing.T) { called := false plugin := NewPlugin[NoData]("test") - plugin.NewPayload(func(ctx *MsgContext, db NoData) error { + plugin.Payload("inline.approve", func(ctx *MsgContext, db NoData) error { called = true if ctx.CallbackQueryID != "cb-inline" { 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) } return nil - }, "inline.approve") + }) bot := &Bot[NoData]{ logger: sneklog.NewLogger(), @@ -1122,9 +1122,9 @@ func TestHandleCallbackPopulatesInlineTargets(t *testing.T) { func TestHandleCallbackObserverEmitsPayloadEvents(t *testing.T) { observer := &recordingObserver{} plugin := NewPlugin[NoData]("test") - plugin.NewPayload(func(ctx *MsgContext, db NoData) error { + plugin.Payload("approve", func(ctx *MsgContext, db NoData) error { return nil - }, "approve") + }) bot := &Bot[NoData]{ logger: sneklog.NewLogger(), @@ -1173,9 +1173,9 @@ func TestHandleCallbackObserverEmitsPayloadErrors(t *testing.T) { observer := &recordingObserver{} plugin := NewPlugin[NoData]("test") wantErr := AsInternalError(errors.New("boom")) - plugin.NewPayload(func(ctx *MsgContext, db NoData) error { + plugin.Payload("approve", func(ctx *MsgContext, db NoData) error { return wantErr - }, "approve") + }) bot := &Bot[NoData]{ logger: sneklog.NewLogger(), diff --git a/keyboard.go b/keyboard.go index 309bef6..b874afa 100644 --- a/keyboard.go +++ b/keyboard.go @@ -16,9 +16,9 @@ const ( 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 // - SetStyle() — sets visual style (danger/success/primary) // - SetURL() — makes button open a URL @@ -26,7 +26,7 @@ const ( // // Call build() to produce the final tgapi.InlineKeyboardButton. // Builder methods are immutable — each returns a copy. -type InlineKbButtonBuilder struct { +type InlineKeyboardButtonBuilder struct { text string iconCustomEmojiID string style tgapi.KeyboardButtonStyle @@ -34,15 +34,15 @@ type InlineKbButtonBuilder struct { 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. -func NewInlineKbButton(text string) InlineKbButtonBuilder { - return InlineKbButtonBuilder{text: text} +func NewInlineKeyboardButton(text string) InlineKeyboardButtonBuilder { + return InlineKeyboardButtonBuilder{text: text} } // SetIconCustomEmojiID sets a custom emoji ID to display as the button's icon. // 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 return b } @@ -50,14 +50,14 @@ func (b InlineKbButtonBuilder) SetIconCustomEmojiID(id string) InlineKbButtonBui // SetStyle sets the visual style of the button. // Valid values: ButtonStyleDanger, ButtonStyleSuccess, ButtonStylePrimary. // 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 return b } // SetURL sets a URL that will be opened when the button is pressed. // 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 return b } @@ -69,7 +69,7 @@ func (b InlineKbButtonBuilder) SetURL(url string) InlineKbButtonBuilder { // are safely serialized, but complex structs may not serialize usefully. // // 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() return b } @@ -77,13 +77,13 @@ func (b InlineKbButtonBuilder) SetCallbackDataJSON(cmd string, args ...any) Inli // SetCallbackDataBase64 sets a structured callback payload encoded as Base64. // This can be useful when the JSON payload exceeds Telegram's callback data length limit. // 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() return b } // 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{ Text: b.text, 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. -func (in *InlineKeyboard) AddButton(b InlineKbButtonBuilder) *InlineKeyboard { +func (in *InlineKeyboard) AddButton(b InlineKeyboardButtonBuilder) *InlineKeyboard { return in.append(b.build()) } diff --git a/keyboard_test.go b/keyboard_test.go index 401c256..ac1f5ac 100644 --- a/keyboard_test.go +++ b/keyboard_test.go @@ -31,7 +31,7 @@ func TestInlineKeyboardWrapsRowsAndEncodesJSONPayloads(t *testing.T) { func TestInlineKeyboardBuilderPreservesConfiguredButtonFields(t *testing.T) { kb := NewInlineKeyboardBase64(3). AddButton( - NewInlineKbButton("Docs"). + NewInlineKeyboardButton("Docs"). SetStyle(ButtonStylePrimary). SetURL("https://example.test"), ) diff --git a/msg_context.go b/msg_context.go index a63bb3b..44862fe 100644 --- a/msg_context.go +++ b/msg_context.go @@ -142,7 +142,7 @@ func (m *AnswerMessage) Edit(text string) *AnswerMessage { // ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here. // Unescaped input may cause Telegram API errors or broken formatting. 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. @@ -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. 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. @@ -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. 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. @@ -225,7 +225,7 @@ func (m *AnswerMessage) EditCaption(text string) *AnswerMessage { // // ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here. 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). @@ -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. 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. @@ -292,7 +292,7 @@ func (ctx *MsgContext) AnswerLong(text string) []*AnswerMessage { // // ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here. 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. @@ -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. 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). @@ -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. 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 { @@ -415,7 +415,7 @@ func (ctx *MsgContext) AnswerPhoto(photoID, text string) *AnswerMessage { // // ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here. 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). @@ -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. 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). @@ -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. 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. @@ -487,17 +487,17 @@ func (ctx *MsgContext) answerCallbackQuery(url, text string, showAlert bool) { } } -// AnswerCbQuery answers the callback query with no text or alert. -func (ctx *MsgContext) AnswerCbQuery() { ctx.answerCallbackQuery("", "", false) } +// AnswerCallback answers the callback query with no text or alert. +func (ctx *MsgContext) AnswerCallback() { ctx.answerCallbackQuery("", "", false) } -// AnswerCbQueryText answers the callback query with a text notification. -func (ctx *MsgContext) AnswerCbQueryText(text string) { ctx.answerCallbackQuery("", text, false) } +// AnswerCallbackText answers the callback query with a text notification. +func (ctx *MsgContext) AnswerCallbackText(text string) { ctx.answerCallbackQuery("", text, false) } -// AnswerCbQueryAlert answers the callback query with a user-visible alert. -func (ctx *MsgContext) AnswerCbQueryAlert(text string) { ctx.answerCallbackQuery("", text, true) } +// AnswerCallbackAlert answers the callback query with a user-visible alert. +func (ctx *MsgContext) AnswerCallbackAlert(text string) { ctx.answerCallbackQuery("", text, true) } -// AnswerCbQueryURL answers the callback query with a URL redirect. -func (ctx *MsgContext) AnswerCbQueryURL(u string) { ctx.answerCallbackQuery(u, "", false) } +// AnswerCallbackURL answers the callback query with a URL redirect. +func (ctx *MsgContext) AnswerCallbackURL(u string) { ctx.answerCallbackQuery(u, "", false) } // SendAction sends a chat action (typing, uploading_photo, etc.) to indicate bot activity. func (ctx *MsgContext) SendAction(action tgapi.ChatActionType) { @@ -575,7 +575,7 @@ func (ctx *MsgContext) NewDraft() *Draft { // with Markdown V2 parse mode enabled. // Uses the API limiter to avoid rate limiting. 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. @@ -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. func (ctx *MsgContext) UpsertKeyboardMarkdown(text string, keyboard *InlineKeyboard) *AnswerMessage { - return ctx.upsertKeyboard(text, keyboard, tgapi.ParseMDV2) + return ctx.upsertKeyboard(text, keyboard, tgapi.ParseMarkdownV2) } diff --git a/plugins.go b/plugins.go index a51e4bc..a2ac49a 100644 --- a/plugins.go +++ b/plugins.go @@ -57,10 +57,10 @@ func (p *Plugin[T]) AddCommand(command *Command[T]) *Plugin[T] { 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. -func (p *Plugin[T]) NewCommand(exec CommandExecutor[T], command string, args ...CommandArg) *Command[T] { - cmd := NewCommand(exec, command, args...) +func (p *Plugin[T]) Command(command string, exec CommandExecutor[T], args ...CommandArg) *Command[T] { + cmd := NewCommand(command, exec, args...) p.AddCommand(cmd) return cmd } @@ -78,6 +78,33 @@ func (p *Plugin[T]) AddPayload(command *Command[T]) *Plugin[T] { 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. func (p *Plugin[T]) CommandGroup(prefix string, groupFunc func(group *CommandGroup[T])) *Plugin[T] { if groupFunc == nil { @@ -108,33 +135,6 @@ func (p *Plugin[T]) AddCommandGroup(group *CommandGroup[T]) *Plugin[T] { 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. func (p *Plugin[T]) UsePolicy(name string, policy Policy[T]) *Plugin[T] { 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: if p.logger == 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() 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 } p.handlers[t] = handler diff --git a/plugins_test.go b/plugins_test.go index a3e0699..ac12fd8 100644 --- a/plugins_test.go +++ b/plugins_test.go @@ -6,7 +6,7 @@ import ( ) 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 { t.Fatalf("expected valid integer argument, got %v", err) } @@ -14,7 +14,7 @@ func TestValidateArgsRequiresFullMatch(t *testing.T) { t.Fatalf("expected ErrCmdArgRegexpMismatch for partial int match, got %v", err) } - boolCmd := NewCommand(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 { t.Fatalf("expected valid bool argument, got %v", err) } @@ -25,8 +25,8 @@ func TestValidateArgsRequiresFullMatch(t *testing.T) { func TestValidateArgsEnforcesRequiredArgIndex(t *testing.T) { cmd := NewCommand( - func(ctx *MsgContext, db NoData) error { return nil }, "mixed", + func(ctx *MsgContext, db NoData) error { return nil }, NewCommandArg("optional"), NewCommandArg("required").SetRequired(), ) @@ -42,12 +42,11 @@ func TestValidateArgsEnforcesRequiredArgIndex(t *testing.T) { func TestCommandGroupBuildsPrefixedCommandsWithoutMutatingOriginal(t *testing.T) { groupMiddleware := NewMiddleware("group", 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"). Use(commandMiddleware) - group := NewCommandGroup[NoData]("admin"). - SetSeparator("_"). + group := NewCommandGroup[NoData]("admin_"). Use(groupMiddleware). AddCommand(cmd) @@ -80,7 +79,7 @@ func TestCommandGroupBuildsPrefixedCommandsWithoutMutatingOriginal(t *testing.T) func TestCommandGroupBuildIsRepeatable(t *testing.T) { group := NewCommandGroup[NoData]("admin"). 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 }))) first := group.Build() @@ -103,9 +102,8 @@ func TestCommandGroupBuildIsRepeatable(t *testing.T) { func TestPluginCommandGroupRegistersBuiltCommands(t *testing.T) { plugin := NewPlugin[NoData]("admin") - plugin.CommandGroup("admin", func(group *CommandGroup[NoData]) { - group.SetSeparator("_") - group.AddCommand(NewCommand(func(ctx *MsgContext, db NoData) error { return nil }, "ban")) + plugin.CommandGroup("admin_", func(group *CommandGroup[NoData]) { + group.AddCommand(NewCommand("ban", func(ctx *MsgContext, db NoData) error { return nil })) }) if _, ok := plugin.commands["admin_ban"]; !ok { diff --git a/runners.go b/runners.go index 7aa457c..72d075f 100644 --- a/runners.go +++ b/runners.go @@ -12,41 +12,41 @@ type RunnerFn[T AppData] func(*Bot[T]) error // Runner represents a configurable background or one-time task to be // 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. // // Execution semantics: -// - onetime=true, async=false: Run once synchronously (blocks). -// - onetime=true, async=true: Run once in a goroutine (non-blocking). -// - onetime=false, async=true: Run repeatedly in a goroutine with timeout. -// - onetime=false, async=false: Invalid configuration — ignored with warning. +// - once=true, async=false: Run once synchronously (blocks). +// - once=true, async=true: Run once in a goroutine (non-blocking). +// - once=false, async=true: Run repeatedly in a goroutine with timeout. +// - once=false, async=false: Invalid configuration — ignored with warning. type Runner[T AppData] struct { - name string // Human-readable name for logging - onetime bool // If true, runs once; if false, runs periodically - async bool // If true, runs in a goroutine; else, runs synchronously - timeout time.Duration // Duration to wait between periodic executions (ignored if onetime=true) - fn RunnerFn[T] // The function to execute + name string // Human-readable name for logging + once bool // If true, runs once; if false, runs periodically + async bool // If true, runs in a goroutine; else, runs synchronously + every time.Duration // Duration to wait between periodic executions (ignored if once=true) + fn RunnerFn[T] // The function to execute } // NewRunner creates a new Runner with the given name and function. // 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(). func NewRunner[T AppData](name string, fn RunnerFn[T]) Runner[T] { return Runner[T]{ - name: name, - fn: fn, - async: true, // Default: run asynchronously - timeout: 0, // Default: no timeout (ignored if onetime=true) + name: name, + fn: fn, + async: true, // Default: run asynchronously + 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 false, the runner runs in a loop with the configured timeout. -func (r Runner[T]) Onetime(onetime bool) Runner[T] { - r.onetime = onetime +func (r Runner[T]) Once(once bool) Runner[T] { + r.once = once 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 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] { r.async = async return r } -// Timeout sets the duration to wait between repeated executions for -// non-onetime runners. +// Every sets the duration to wait between repeated executions for +// non-once runners. // -// If onetime=true, this value is ignored. -// If onetime=false and async=true, this timeout determines the sleep interval +// If once=true, this value is ignored. +// If once=false and async=true, this timeout determines the sleep interval // between loop iterations. // // A zero value (time.Duration(0)) is allowed but may trigger a warning -// if used with a background (non-onetime) async runner. -func (r Runner[T]) Timeout(timeout time.Duration) Runner[T] { - r.timeout = timeout +// if used with a background (non-once) async runner. +func (r Runner[T]) Every(timeout time.Duration) Runner[T] { + r.every = timeout return r } // ExecRunners executes all runners registered on the Bot with context-based lifecycle management. // // It logs warnings for misconfigured runners: -// - Sync, non-onetime runners are skipped (invalid configuration). -// - Background (non-onetime, async) runners without a timeout trigger a warning. +// - Sync, non-once runners are skipped (invalid configuration). +// - Background (non-once, async) runners without a timeout trigger a warning. // // Execution logic: -// - onetime + async: Runs once in a goroutine. -// - onetime + sync: Runs once synchronously; warns if slower than 2 seconds. -// - !onetime + async: Runs in a loop with timeout between iterations until ctx.Done(). -// - !onetime + sync: Skipped with warning. +// - once + async: Runs once in a goroutine. +// - once + sync: Runs once synchronously; warns if slower than 2 seconds. +// - !once + async: Runs in a loop with timeout between iterations until ctx.Done(). +// - !once + sync: Skipped with warning. // // 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 -// RunWebHookWithContext. +// RunWebhookWithContext. func (bot *Bot[T]) ExecRunners(ctx context.Context) { bot.logger.Infoln("Executing runners...") for _, runner := range bot.runners { // Validate configuration - if !runner.onetime && !runner.async { - bot.logger.Warnf("Runner %s not onetime, but sync — skipping\n", runner.name) + if !runner.once && !runner.async { + bot.logger.Warnf("Runner %s not once, but sync — skipping\n", runner.name) 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) continue } - if runner.onetime && runner.async { + if runner.once && runner.async { // One-time async: fire and forget bot.runnerOnceWG.Add(1) 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) } }(runner) - } else if runner.onetime && !runner.async { + } else if runner.once && !runner.async { // One-time sync: block until done t := time.Now() err := runner.fn(bot) @@ -149,12 +149,12 @@ func (bot *Bot[T]) ExecRunners(ctx context.Context) { if elapsed > time.Second*2 { 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 bot.runnerBgWG.Add(1) go func(r Runner[T]) { defer bot.runnerBgWG.Done() - ticker := time.NewTicker(r.timeout) + ticker := time.NewTicker(r.every) defer ticker.Stop() for { select { @@ -182,6 +182,6 @@ func (bot *Bot[T]) ExecRunners(ctx context.Context) { } }(runner) } - // Note: !onetime && !async is already skipped above + // Note: !once && !async is already skipped above } } diff --git a/runners_test.go b/runners_test.go index 3d3f5b8..28a8b30 100644 --- a/runners_test.go +++ b/runners_test.go @@ -14,7 +14,7 @@ type runnerObserver struct { recordingObserver } -func TestExecRunnersRunsOnetimeSyncRunner(t *testing.T) { +func TestExecRunnersRunsOnceSyncRunner(t *testing.T) { var calls atomic.Int32 bot := &Bot[NoData]{ logger: sneklog.NewLogger(), @@ -22,7 +22,7 @@ func TestExecRunnersRunsOnetimeSyncRunner(t *testing.T) { NewRunner("sync-once", func(*Bot[NoData]) error { calls.Add(1) return nil - }).Onetime(true).Async(false), + }).Once(true).Async(false), }, } @@ -46,7 +46,7 @@ func TestExecRunnersStopsBackgroundRunnerOnCancel(t *testing.T) { triggered <- struct{}{} } return nil - }).Timeout(5 * time.Millisecond), + }).Every(5 * time.Millisecond), }, } @@ -76,7 +76,7 @@ func TestExecRunnersEmitObserverEvents(t *testing.T) { runners: []Runner[NoData]{ NewRunner("sync-once", func(*Bot[NoData]) error { return wantErr - }).Onetime(true).Async(false), + }).Once(true).Async(false), }, } diff --git a/scene_test.go b/scene_test.go index d904431..bde5f10 100644 --- a/scene_test.go +++ b/scene_test.go @@ -45,7 +45,7 @@ func TestBotAddPluginsPreservesScenesAndHandlesThem(t *testing.T) { called := false plugin := NewPlugin[NoData]("wizard") - plugin.NewScene("signup"). + plugin.Scene("signup"). SetEntry("start"). OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) { called = true @@ -146,7 +146,7 @@ func TestBuildSceneKeyRejectsMissingContextFields(t *testing.T) { func TestEnterSceneRejectsMissingEntryConfiguration(t *testing.T) { t.Run("empty entry", func(t *testing.T) { plugin := NewPlugin[NoData]("wizard") - plugin.NewScene("signup") + plugin.Scene("signup") bot := &Bot[NoData]{ logger: sneklog.NewLogger(), @@ -169,7 +169,7 @@ func TestEnterSceneRejectsMissingEntryConfiguration(t *testing.T) { t.Run("missing entry step", func(t *testing.T) { plugin := NewPlugin[NoData]("wizard") - plugin.NewScene("signup").SetEntry("start") + plugin.Scene("signup").SetEntry("start") bot := &Bot[NoData]{ logger: sneklog.NewLogger(), @@ -210,7 +210,7 @@ func TestSceneCommandHandlerRunsBeforeStep(t *testing.T) { stepCalled := false plugin := NewPlugin[NoData]("wizard") - plugin.NewScene("signup"). + plugin.Scene("signup"). SetEntry("start"). OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) { stepCalled = true @@ -269,7 +269,7 @@ func TestSceneCommandHandlerRunsBeforeStep(t *testing.T) { func TestSceneCommandObserverEmitsLifecycleEvents(t *testing.T) { observer := &recordingObserver{} plugin := NewPlugin[NoData]("wizard") - plugin.NewScene("signup"). + plugin.Scene("signup"). SetEntry("start"). OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) { return ctx.Stay(), nil @@ -324,7 +324,7 @@ func TestSceneCommandObserverEmitsLifecycleEvents(t *testing.T) { func TestSceneStepObserverEmitsLifecycleEvents(t *testing.T) { observer := &recordingObserver{} plugin := NewPlugin[NoData]("wizard") - plugin.NewScene("signup"). + plugin.Scene("signup"). SetEntry("start"). OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) { return ctx.Stay(), nil @@ -376,7 +376,7 @@ func TestSceneStepObserverEmitsLifecycleEvents(t *testing.T) { func TestSceneMessageObserverEmitsLifecycleEvents(t *testing.T) { observer := &recordingObserver{} plugin := NewPlugin[NoData]("wizard") - scene := plugin.NewScene("signup"). + scene := plugin.Scene("signup"). SetEntry("start"). OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) { return ctx.Stay(), nil @@ -435,7 +435,7 @@ func TestScenePayloadHandlerRunsBeforeStep(t *testing.T) { stepCalled := false plugin := NewPlugin[NoData]("wizard") - plugin.NewScene("signup"). + plugin.Scene("signup"). SetEntry("start"). OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) { stepCalled = true @@ -499,7 +499,7 @@ func TestScenePayloadHandlerRunsBeforeStep(t *testing.T) { func TestScenePayloadObserverEmitsLifecycleEvents(t *testing.T) { observer := &recordingObserver{} plugin := NewPlugin[NoData]("wizard") - plugin.NewScene("signup"). + plugin.Scene("signup"). SetEntry("start"). OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) { return ctx.Stay(), nil @@ -563,8 +563,8 @@ func TestSceneUnmatchedPayloadFallsThroughWithoutRunningStep(t *testing.T) { stepCalled := false plugin := NewPlugin[NoData]("wizard") - plugin.NewPayload(func(ctx *MsgContext, db NoData) error { return nil }, "ping") - plugin.NewScene("signup"). + plugin.Payload("ping", func(ctx *MsgContext, db NoData) error { return nil }) + plugin.Scene("signup"). SetEntry("start"). OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) { stepCalled = true @@ -632,11 +632,11 @@ func TestScenePassDoesNotPersistSessionData(t *testing.T) { commandCalled := false plugin := NewPlugin[NoData]("wizard") - plugin.NewCommand(func(ctx *MsgContext, db NoData) error { + plugin.Command("ping", func(ctx *MsgContext, db NoData) error { commandCalled = true return nil - }, "ping") - plugin.NewScene("signup"). + }) + plugin.Scene("signup"). SetEntry("start"). OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) { if err := ctx.SaveData(struct { @@ -712,16 +712,16 @@ func TestSceneUnmatchedCommandFallsThroughWithoutRunningStep(t *testing.T) { stepCalled := false plugin := NewPlugin[NoData]("wizard") - plugin.NewScene("signup"). + plugin.Scene("signup"). SetEntry("start"). OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) { stepCalled = true return ctx.Stay(), nil }) - plugin.NewCommand(func(ctx *MsgContext, db NoData) error { + plugin.Command("ping", func(ctx *MsgContext, db NoData) error { commandCalled = true return nil - }, "ping") + }) bot := &Bot[NoData]{ logger: sneklog.NewLogger(), @@ -779,7 +779,7 @@ func TestSceneMessageFallbackRunsWhenNoCommandOrStepMatch(t *testing.T) { fallbackCalled := false plugin := NewPlugin[NoData]("wizard") - plugin.NewScene("signup"). + plugin.Scene("signup"). SetEntry("start"). OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) { return ctx.Stay(), nil diff --git a/tgapi/api.go b/tgapi/api.go index 74d67ab..c6aea8a 100644 --- a/tgapi/api.go +++ b/tgapi/api.go @@ -83,10 +83,10 @@ func (opts *APIOpts) SetLimiter(limiter *utils.RateLimiter) *APIOpts { 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 false, requests block until capacity is available. -func (opts *APIOpts) SetLimiterDrop(b bool) *APIOpts { +func (opts *APIOpts) SetDropRateLimitOverflow(b bool) *APIOpts { opts.dropOverflowLimit = b return opts } diff --git a/tgapi/chat_methods.go b/tgapi/chat_methods.go index d33b720..79b9aa6 100644 --- a/tgapi/chat_methods.go +++ b/tgapi/chat_methods.go @@ -652,15 +652,15 @@ func (api *API) GetChatAdministratorsWithContext(ctx context.Context, params Get 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 -type GetChatMembersCount struct { +type GetChatMemberCount struct { ChatID int64 `json:"chat_id"` } // GetChatMemberCount returns the number of members in a chat. // 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) return req.Do(api) } @@ -668,7 +668,7 @@ func (api *API) GetChatMemberCount(params GetChatMembersCount) (int, error) { // GetChatMemberCountWithContext is the context-aware variant of GetChatMemberCount. // It executes the same request but uses ctx for cancellation and deadlines. // 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) return req.DoWithContext(ctx, api) } diff --git a/tgapi/messages_types.go b/tgapi/messages_types.go index 4d12516..7529531 100644 --- a/tgapi/messages_types.go +++ b/tgapi/messages_types.go @@ -362,7 +362,7 @@ type MessageEntity struct { Language string `json:"language,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"` } diff --git a/tgapi/methods_types.go b/tgapi/methods_types.go index 0c4d0d9..cd58a99 100644 --- a/tgapi/methods_types.go +++ b/tgapi/methods_types.go @@ -4,12 +4,12 @@ package tgapi type ParseMode string const ( - // ParseMDV2 enables MarkdownV2 style parsing. - ParseMDV2 ParseMode = "MarkdownV2" + // ParseMarkdownV2 enables MarkdownV2 style parsing. + ParseMarkdownV2 ParseMode = "MarkdownV2" // ParseHTML enables HTML style parsing. ParseHTML ParseMode = "HTML" - // ParseMD enables legacy Markdown style parsing. - ParseMD ParseMode = "Markdown" + // ParseMarkdown enables legacy Markdown style parsing. + ParseMarkdown ParseMode = "Markdown" // ParseNone disables parse_mode and leaves plain-text requests unannotated. ParseNone ParseMode = "" ) diff --git a/tgapi/parse_mode_test.go b/tgapi/parse_mode_test.go index 8b1f2d3..1f23437 100644 --- a/tgapi/parse_mode_test.go +++ b/tgapi/parse_mode_test.go @@ -25,7 +25,7 @@ func TestParseModeStillSerializesExplicitModes(t *testing.T) { data, err := json.Marshal(SendMessage{ ChatID: 42, Text: "hello", - ParseMode: ParseMDV2, + ParseMode: ParseMarkdownV2, }) if err != nil { t.Fatalf("Marshal returned error: %v", err) diff --git a/tgmd/doc.go b/tgmd/doc.go new file mode 100644 index 0000000..f480af2 --- /dev/null +++ b/tgmd/doc.go @@ -0,0 +1,2 @@ +// Package tgmd provides small helpers for Telegram Markdown text. +package tgmd diff --git a/tgmd/message_builder.go b/tgmd/message_builder.go new file mode 100644 index 0000000..3e86bac --- /dev/null +++ b/tgmd/message_builder.go @@ -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() + } +} diff --git a/tgmd/message_builder_test.go b/tgmd/message_builder_test.go new file mode 100644 index 0000000..d4f0d69 --- /dev/null +++ b/tgmd/message_builder_test.go @@ -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) + } + }) + } +} diff --git a/tgmd/utils.go b/tgmd/utils.go new file mode 100644 index 0000000..4de1911 --- /dev/null +++ b/tgmd/utils.go @@ -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 +} diff --git a/tgmd/utils_test.go b/tgmd/utils_test.go new file mode 100644 index 0000000..8b6ec1b --- /dev/null +++ b/tgmd/utils_test.go @@ -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) + } + }) + } +}