From 158625c220b7164529465f0b9a758fab7a27d2e7 Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Wed, 25 Mar 2026 18:02:04 +0300 Subject: [PATCH] fix bot lifecycle and docs --- .gitignore | 2 +- CHANGELOG.md | 146 ++++++++++++++++++++++ README.md | 47 ++++++-- README_RU.md | 66 +++++++--- SEMVER.md | 45 +++++++ bot.go | 201 +++++++++++++++++++++++++------ bot_opts.go | 26 ++-- bot_test.go | 139 ++++++++++++++++++++- cmd_generator.go | 22 ++-- cmd_generator_test.go | 23 +++- doc.go | 58 +++------ drafts.go | 4 +- handler.go | 178 +++++++++++++++++++++++---- handler_test.go | 226 ++++++++++++++++++++++++++++++++++- keyboard.go | 29 ++--- keyboard_test.go | 44 +++++++ l10n.go | 71 +++++------ l10n_test.go | 77 ++++++++++++ msg_context.go | 24 ++-- plugins.go | 76 +++++++----- plugins_test.go | 10 +- runners.go | 10 +- runners_test.go | 62 ++++++++++ tgapi/api.go | 31 ++--- tgapi/api_test.go | 34 ++++++ tgapi/attachments_methods.go | 4 +- tgapi/attachments_types.go | 12 +- tgapi/bot_methods.go | 10 +- tgapi/bot_types.go | 19 +-- tgapi/business_types.go | 15 ++- tgapi/chat_types.go | 26 ++-- tgapi/errors.go | 7 ++ tgapi/messages_types.go | 124 ++++++++++++------- tgapi/methods.go | 35 +++++- tgapi/methods_test.go | 38 ++++++ tgapi/pool.go | 84 ++++--------- tgapi/pool_test.go | 62 ++++++++++ tgapi/types.go | 123 ++++++++++++------- tgapi/types_test.go | 91 +++++++++----- tgapi/uploader_api.go | 19 +-- utils.go | 14 ++- utils/limiter.go | 12 +- utils/multipart.go | 3 +- utils/version.go | 15 ++- 44 files changed, 1831 insertions(+), 533 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 SEMVER.md create mode 100644 keyboard_test.go create mode 100644 l10n_test.go create mode 100644 runners_test.go create mode 100644 tgapi/pool_test.go diff --git a/.gitignore b/.gitignore index 4d72fe9..f26b8e6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,2 @@ .idea/ -test/ \ No newline at end of file +test/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e1e409a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,146 @@ +# Changelog + +## Unreleased + +### Added +- `Plugin.AddUpdateHandler` for routing non-command Telegram updates by `tgapi.UpdateType`. +- Derived `tgapi.Update.Type` assignment during JSON decoding, plus `tgapi.UpdateTypeUnknown` for unmatched payloads. +- `tgapi.API.OpenFileByLink(...)` and `OpenFileByLinkWithContext(...)` for streaming downloads from Telegram's file server. +- Regression tests for update dispatch, keyboard builders, localization fallback, runners, rate limiting, parse mode encoding, streaming downloads, and context isolation. +- Regression tests for bot single-run enforcement, nil plugin registration, `L10n` concurrent access, `API.Close()` idle-connection cleanup, and `tgapi` worker-pool edge cases. +- `SEMVER.md` documenting versioning expectations for the project. + +### Changed +- `NewBot` now returns `(*Bot[T], error)` instead of terminating the host process on configuration or startup failures. +- `Run` and `RunWithContext` now return errors; `RunWithContext` returns `ErrNoPrefixes` and `ErrNoPlugins` for invalid bot configuration. +- Polling retries now use exponential backoff instead of busy-looping on repeated `getUpdates` failures. +- `Bot` is now explicitly single-use; repeated `Run()` or `RunWithContext(...)` calls return `ErrBotAlreadyRun`. +- Database context wiring now uses `T` consistently instead of forcing `*T`; shared dependencies should typically use pointer types such as `*sql.DB`. +- `DatabaseContext`, `GetDBContext`, and `DbLogger` were updated to the new `T`-based dependency model. +- `DatabaseContext(...)` now warns once when `T` is a value type, to highlight likely unintended copying of shared dependencies. +- `AddDatabaseLoggerWriter(...)` now skips unset and nil database contexts instead of calling the writer with invalid values. +- `L10n` is now safe for concurrent use and copies added dictionary entries to avoid external mutation after registration. +- Plugin registration now snapshots commands, payloads, middlewares, and update handlers so later mutations of the original `*Plugin` do not leak into the bot. +- `AddPlugins(...)` now skips nil plugin pointers instead of panicking. +- `GetUpdateTypes()` now returns a copy instead of exposing internal slice state. +- Update handling now normalizes `MsgContext` for more Telegram update kinds and routes plugin-level update handlers with isolated context copies. +- `message`, `channel_post`, and `callback_query` remain on the command/payload flow; non-command updates can be handled through plugin update handlers. +- Command auto-generation now validates Telegram command names with the correct character set and `1..32` length limit, and emits commands in deterministic sorted order. +- Builder-style APIs were normalized to value returns for `NewCommandArg`, `NewMiddleware`, `NewRunner`, and `NewCallbackData`. +- `MenuButton` replaced `BaseMenuButton`, and `GetChatMenuButton(...)` now returns the renamed type. +- Several Telegram DTOs were tightened for optionality and serialization correctness, including `InputPaidMedia`, `MenuButton`, optional gift fields, and message entity slices. +- `tgapi.NewRequest(...)`, `NewRequestWithChatID(...)`, `NewUploaderRequest(...)`, and `NewUploaderRequestWithChatID(...)` are now documented as low-level unsafe escape hatches rather than internal helpers. +- `tgapi.API.Close()` now closes idle HTTP connections before releasing logger resources. +- Multipart form encoding now writes scalar field bytes directly instead of converting through temporary strings. +- README, README_RU, package docs, and exported godoc were updated to match the current APIs and concurrency/lifecycle model. +- Version constants were bumped to `v1.0.0-rc.10`. + +### Fixed +- Required command arguments are now enforced by declared argument index, not only by total required count. +- `ParseNone` now omits `parse_mode` from JSON requests instead of serializing `"None"`. +- Upload file type detection is now case-insensitive for file extensions. +- Draft creation no longer panics when no limiter is configured, and draft flushing now rejects zero chat IDs before sending invalid requests. +- Channel posts with `SenderChat` no longer panic in the command path and now preserve the expected `MsgContext` fields. +- File logger initialization now falls back to stdout loggers instead of terminating the process on logger setup failures. +- `GetChatMenuButton` and `SetChatMenuButton` now serialize `chat_id` correctly when omitted. +- Update decoding tests now match the canonical `deleted_business_messages` model and no longer rely on the removed singular alias. + +### Breaking Changes +- `NewBot[T](opts)` now returns `(*Bot[T], error)`. +- `Run()` now returns `error`. +- `RunWithContext(ctx)` now returns `error`. +- `Run()` and `RunWithContext(ctx)` are now single-use per bot instance; create a new `Bot` after they return. +- Database context handlers now receive `T` instead of `*T`. For shared dependencies, instantiate the bot with a pointer type, for example `Bot[*sql.DB]`. +- `DatabaseContext(...)` now takes `T` instead of `*T`. +- `GetDBContext()` now returns `T` instead of `*T`. +- `DbLogger[T]` now receives `T` instead of `*T`. +- `NewCommandArg(...)`, `NewMiddleware(...)`, `NewRunner(...)`, and `NewCallbackData(...)` now return values instead of pointers. +- `BaseMenuButton` was renamed to `MenuButton`, and `GetChatMenuButton(...)` now returns `MenuButton`. +- `tgapi.Update` no longer exposes the deprecated `DeletedBusinessMessage` alias; use `DeletedBusinessMessages`. + +### Tests +- Added coverage for polling backoff helpers, command sorting, database logger safety checks, update handler routing, update-context isolation, channel posts with `SenderChat`, parse mode encoding, streaming downloads, and rate limiter behavior. + +## v1.0.0-rc.7 + +### Added +- Package-level logger helpers: `utils.CreateLogger(prefix, level)` and `utils.CreateFileLogger(prefix, level, filePath)`. +- `MsgContext.Logger`, populated from the matched plugin and falling back to the bot logger. +- Plugin lifecycle/configuration APIs: `SetLogger`, `RemoveLogger`, `SetOnClose`, and `Close`. +- `Bot.CloseRemote(ctx)` as the explicit wrapper for Telegram Bot API close. + +### Changed +- Logger initialization is now unified across `Bot`, `tgapi.API`, and `tgapi.Uploader`. +- `Bot.Close()` now performs local resource teardown only and invokes `Plugin.Close()` for registered plugins. +- Local `tgapi.API` shutdown was renamed to `Close()`. +- Telegram Bot API close wrappers in `tgapi.API` were renamed to `CloseRemote()` and `CloseRemoteWithContext()`. +- `Bot.Debug()` now updates log levels for the bot logger, request logger, and already registered plugin loggers. +- `Bot.AddPlugins()` now creates a default plugin logger automatically when one is not provided. +- `Bot.AddDatabaseLoggerWriter()` now also attaches the writer to already registered plugin loggers. +- GoDoc was expanded for the new shutdown and logging APIs, and plugin registration is now documented as a configuration commit point. + +### Breaking Changes +- `(*Bot).Close(ctx context.Context)` was replaced with `(*Bot).Close()`. +- `(*tgapi.API).CloseApi()` was renamed to `(*tgapi.API).Close()`. +- `(*tgapi.API).Close()` was renamed to `(*tgapi.API).CloseRemote()`. +- `(*tgapi.API).CloseWithContext()` was renamed to `(*tgapi.API).CloseRemoteWithContext(ctx)`. + +### Migration +- Replace `bot.Close(ctx)` with `bot.Close()`. +- If you need Telegram Bot API close, use `bot.CloseRemote(ctx)`. +- Replace `api.CloseApi()` with `api.Close()`. +- Replace `api.Close()` with `api.CloseRemote()`. +- Replace `api.CloseWithContext(ctx)` with `api.CloseRemoteWithContext(ctx)`. +- Configure plugin loggers and `OnClose` hooks before calling `bot.AddPlugins(...)`. + +### Tests +- Updated tests for the new shutdown and logging behavior. + +### Notes +- Registering a plugin via `AddPlugins(...)` is a configuration commit point; the plugin should not be mutated through the original `*Plugin` afterward. +- If plugin loggers must receive a database writer, call `AddDatabaseLoggerWriter(...)` after registering plugins. + +## v1.0.0-rc.4 + +### Added +- `WithContext` variants across `tgapi` API and uploader methods so callers can pass cancellation and deadline contexts consistently. +- `UploaderCertificateType`, `UploadSetWebhookP`, `Uploader.SetWebhook(...)`, and `Uploader.SetWebhookWithContext(...)` for multipart webhook certificate uploads. +- Missing media thumbnail fields where applicable. + +### Changed +- GoDoc for context-aware methods was improved, and `See` references now point to method-specific Telegram Bot API anchors. +- `EditMessageTextP` now includes `entities` and `link_preview_options`. +- `EditMessageCaptionP` now includes `caption_entities` and `show_caption_above_media`. +- `StopPollP` now uses `reply_markup` and no longer carries `inline_message_id`. +- `SendStickerP` now includes reply and suggested-post related fields. +- `SendDocumentP` now includes `disable_content_type_detection`. +- `SendInvoiceP` no longer includes unsupported `business_connection_id`. +- `SetWebhookP` no longer carries `certificate`; GoDoc now points to uploader-based certificate upload. +- Existing non-context methods remain available, and the `Do(...)` call style is preserved. + +### Breaking Changes +- Users sending webhook certificates through JSON `SetWebhookP.Certificate` must migrate to `Uploader.SetWebhook(...)`. + +## v1.0.0-rc.3 + +### Fixed +- The update polling loop no longer logs or retries after `context.Canceled` during shutdown. +- Extra retry delay was removed from canceled polling requests so `RunWithContext` can exit immediately while stopping. + +### Changed +- Shutdown behavior remains explicit: callers are still responsible for invoking `Close()` after `RunWithContext` returns. + +## v1.0.0-rc.2 + +### Fixed +- Fixed a shutdown crash caused by `DatabaseWriter` calling `Close()` through an uninitialized embedded logger writer. +- Fixed bot shutdown hanging during Telegram long polling by making update polling use a cancelable context. +- Reduced the chance of container termination with exit code `137` during shutdown by allowing `getUpdates` to stop promptly on cancellation. + +### Changed +- Switched the project to use the local `laniakea` replacement for the shutdown fix. +- Documentation now clarifies that `RunWithContext` does not close resources automatically and callers must invoke `Close()` explicitly. +- `Updates` documentation now describes context-driven cancellation behavior. + +### Tests +- Added regression tests for database logger writer shutdown behavior. diff --git a/README.md b/README.md index 8d594fd..7fcec12 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ import ( // It receives two parameters: // - ctx: the message context (contains info about the message, sender, chat, etc.) // - db: your custom database context (here we use NoDB, a placeholder for no database) -func echo(ctx *laniakea.MsgContext, db *laniakea.NoDB) { +func echo(ctx *laniakea.MsgContext, db laniakea.NoDB) { // Answer the user with the text they sent, without any command prefix. // ctx.Text contains the user's message with the command part stripped off. ctx.Answer(ctx.Text) // User input WITHOUT command @@ -64,7 +64,10 @@ func main() { // 2. Initialize a new bot instance. // We use laniakea.NoDB as the database context type (no database needed for this example). - bot := laniakea.NewBot[laniakea.NoDB](opts) + bot, err := laniakea.NewBot[laniakea.NoDB](opts) + if err != nil { + log.Fatal(err) + } // Ensure bot resources are cleaned up on exit. defer bot.Close() @@ -78,7 +81,7 @@ func main() { // 5. Add another command using an anonymous function (closure). // This command simply replies "Pong" when the user sends "/ping". - p.AddCommand(p.NewCommand(func(ctx *laniakea.MsgContext, db *laniakea.NoDB) { + p.AddCommand(p.NewCommand(func(ctx *laniakea.MsgContext, db laniakea.NoDB) { ctx.Answer("Pong") }, "ping")) @@ -94,7 +97,9 @@ func main() { } // 8. Start the bot, listening for updates (long polling). - bot.Run() + if err := bot.Run(); err != nil { + log.Fatal(err) + } } ``` @@ -105,15 +110,16 @@ func main() { 4. `AddCommand`: Registers a command. The first argument is the handler function (func(*MsgContext, T)), the second is the command name (without the slash). 5. **Handler Functions**: Receive *MsgContext (message details, methods like Answer) and your custom database context T. 6. `ErrorTemplate`: Sets a template for error messages. The %s placeholder is replaced by the actual error. -7. `AutoGenerateCommands`: Adds built-in commands (/start, /help) and a command that lists all available commands. -8. `Run()`: Starts the bot's update polling loop. +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. A `Bot` instance is single-use. After `Run()` or `RunWithContext()` returns, create a new bot instance for the next session. ## 📖 Core Concepts ### Plugins Plugins are the main way to organize code. A plugin can have multiple commands and middlewares. ```go -plugin := laniakea.NewPlugin[MyDB]("admin") +plugin := laniakea.NewPlugin[*MyDB]("admin") plugin.AddCommand(plugin.NewCommand(banUser, "ban")) bot.AddPlugins(plugin) ``` @@ -153,14 +159,20 @@ Provides access to the incoming message and useful reply methods: This split keeps method intent explicit: JSON-only calls go through `API`, file uploads go through `Uploader`. +For advanced cases, `tgapi.NewRequest(...)` and `tgapi.NewUploaderRequest(...)` remain public as low-level escape hatches. They are intentionally less safe than method-specific helpers: callers must supply the correct Telegram method name and compatible request/response types themselves. + ### Database Context -The `T` in `NewBot[T]` is a powerful feature. You can pass any type (like a database connection pool), and it will be available in every command and middleware handler. +The `T` in `NewBot[T]` is a powerful feature. You can pass any type, but shared dependencies such as database pools should usually use a pointer type. ```go type MyDB struct { /* ... */ } db := &MyDB{...} -bot := laniakea.NewBot[*MyDB](opts, db) // Pass db instance +bot, err := laniakea.NewBot[*MyDB](opts) +if err != nil { + log.Fatal(err) +} +bot.DatabaseContext(db) ``` ## 🧩 Middleware @@ -177,11 +189,12 @@ func(ctx *MsgContext, db T) bool - If it returns false, the execution chain stops immediately (the command will not run). ### Adding Middleware -Use the Use method of a plugin to add one or more middleware functions. They are executed in the order they are added. +Use `AddMiddleware` on a plugin to add one or more shared middleware functions. They are executed in the order they are added. ```go -plugin := laniakea.NewPlugin[MyDB]("admin") -plugin.Use(loggingMiddleware, adminOnlyMiddleware) +plugin := laniakea.NewPlugin[*MyDB]("admin") +plugin.AddMiddleware(laniakea.NewMiddleware("logging", loggingMiddleware)) +plugin.AddMiddleware(laniakea.NewMiddleware("admin-only", adminOnlyMiddleware)) plugin.AddCommand(plugin.NewCommand(banUser, "ban")) ``` @@ -212,7 +225,15 @@ func adminOnlyMiddleware(ctx *laniakea.MsgContext, db *MyDB) bool { ## ⚙️ Advanced Configuration - **Inline Keyboards**: Build keyboards using `laniakea.NewInlineKeyboardJson`, `laniakea.NewInlineKeyboardBase64`, or `laniakea.NewInlineKeyboard`. - **Rate Limiting**: Pass a configured utils.RateLimiter via BotOpts to handle Telegram's rate limits gracefully. -- **Custom HTTP Client**: Provide your own http.Client in BotOpts for fine-tuned control. +- **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(...)` does 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. +- Non-command updates can be routed with `plugin.AddUpdateHandler(updateType, handler)`. +- `message`, `channel_post`, and `callback_query` stay on the command/payload flow. +- `tgapi.Update` exposes a derived `Type` field after JSON unmarshalling so handlers can inspect the effective update kind directly. ## 📝 License diff --git a/README_RU.md b/README_RU.md index abc0048..b7d3797 100644 --- a/README_RU.md +++ b/README_RU.md @@ -53,7 +53,7 @@ import ( // Она получает два параметра: // - ctx: контекст сообщения (содержит информацию о сообщении, отправителе, чате и т.д.) // - db: ваш пользовательский контекст базы данных (здесь мы используем NoDB — заглушку) -func echo(ctx *laniakea.MsgContext, db *laniakea.NoDB) { +func echo(ctx *laniakea.MsgContext, db laniakea.NoDB) { // Отвечаем пользователю текстом, который он прислал, без префикса команды. // ctx.Text содержит сообщение пользователя, из которого удалена часть с командой. ctx.Answer(ctx.Text) // Ввод пользователя БЕЗ команды @@ -65,7 +65,10 @@ func main() { // 2. Инициализируем новый экземпляр бота. // Используем laniakea.NoDB как тип контекста базы данных (база не нужна для примера). - bot := laniakea.NewBot[laniakea.NoDB](opts) + bot, err := laniakea.NewBot[laniakea.NoDB](opts) + if err != nil { + log.Fatal(err) + } // Гарантируем освобождение ресурсов бота при выходе. defer bot.Close() @@ -79,7 +82,7 @@ func main() { // 5. Добавляем ещё одну команду, используя анонимную функцию (замыкание). // Эта команда просто отвечает "Pong", когда пользователь отправляет "/ping". - p.AddCommand(p.NewCommand(func(ctx *laniakea.MsgContext, db *laniakea.NoDB) { + p.AddCommand(p.NewCommand(func(ctx *laniakea.MsgContext, db laniakea.NoDB) { ctx.Answer("Pong") }, "ping")) @@ -95,7 +98,9 @@ func main() { } // 8. Запускаем бота, начиная прослушивание обновлений (long polling). - bot.Run() + if err := bot.Run(); err != nil { + log.Fatal(err) + } } ``` @@ -106,15 +111,16 @@ func main() { 4. `AddCommand`: Регистрирует команду. Первый аргумент — функция-обработчик (func(*MsgContext, T)), второй — имя команды (без слеша). 5. **Функции-обработчики**: Получают *MsgContext (детали сообщения, методы типа Answer) и ваш контекст базы данных T. 6. `ErrorTemplate`: Устанавливает шаблон для сообщений об ошибках. Плейсхолдер %s заменяется на текст ошибки. -7. `AutoGenerateCommands`: Добавляет встроенные команды (/start, /help) и команду, показывающую список всех доступных команд. -8. `Run()`: Запускает цикл опроса обновлений бота. +7. `AutoGenerateCommands`: Регистрирует команды из плагинов в Telegram для поддерживаемых scope. +8. `Run()`: Запускает цикл опроса обновлений бота и возвращает ошибку, если старт или polling завершился неуспешно. +9. Экземпляр `Bot` одноразовый. После завершения `Run()` или `RunWithContext()` для следующего запуска создавайте новый бот. ## 📖 Основные концепции ### Плагины (Plugins) Плагины — основной способ организации кода. Плагин может содержать несколько команд и Middleware. ```go -plugin := laniakea.NewPlugin[MyDB]("admin") +plugin := laniakea.NewPlugin[*MyDB]("admin") plugin.AddCommand(plugin.NewCommand(banUser, "ban")) bot.AddPlugins(plugin) ``` @@ -138,21 +144,34 @@ func myHandler(ctx *laniakea.MsgContext, db *MyDB) { - `KeyboardMarkdown(text string, keyboard *InlineKeyboard) *AnswerMessage`: Отправляет сообщение, отформатированное MarkdownV2 (экранирование на вашей стороне), и Inline клавиатурой. - `AnswerPhoto(photoId, text string) *AnswerMessage`: Отправляет фотографию с подписью и parse_mode none. - `AnswerPhotoMarkdown(photoId, text string) *AnswerMessage`: Отправляет фотографию с подписью, отформатированной MarkdownV2 (экранирование на вашей стороне). -- `EditCallback(text string)`: Редактирует сообщение, форматируя его в MarkdownV2 (экранирование на вашей стороне), после нажатия Inline кнопки. -- `EditCallbackMarkdown(text string)`: Редактирует сообщение с parse_mode none после нажатия Inline кнопки. -- `SendChatAction(action string)`: Отправляет действие "печатает", "загружает фото" и т.д. -- Поля: `Text`, `Args`, `From`, `Chat`, `Msg` и другие. +- `EditCallback(text string)`: Редактирует сообщение с `parse_mode` none после нажатия inline-кнопки. +- `EditCallbackMarkdown(text string)`: Редактирует сообщение в формате MarkdownV2 (экранирование на вашей стороне) после нажатия inline-кнопки. +- `SendAction(action tgapi.ChatActionType)`: Отправляет действие "печатает", "загружает фото" и т.д. +- Поля: `Text`, `Args`, `From`, `FromID`, `Msg`, `InlineMsgId`, `CallbackQueryId` и другие. - И много других методов и полей! ### Контекст базы данных (Database Context) -Параметр типа `T` в `NewBot[T]` — мощная функция. Вы можете передать любой тип (например, пул соединений с БД), и он будет доступен в каждом обработчике команды и中间件. +Параметр типа `T` в `NewBot[T]` — мощная функция. Вы можете передать любой тип, но для разделяемых зависимостей вроде пула соединений с БД обычно стоит использовать pointer type. ```go type MyDB struct { /* ... */ } db := &MyDB{...} -bot := laniakea.NewBot[*MyDB](opts, db) // Передаём экземпляр db +bot, err := laniakea.NewBot[*MyDB](opts) +if err != nil { + log.Fatal(err) +} +bot.DatabaseContext(db) ``` +### tgapi: API и Uploader + +В `tgapi` есть два клиента: + +- `API` для JSON-запросов (`SendMessage`, `EditMessageText`, методы с `file_id`/URL). +- `Uploader` для multipart-загрузок (`SendPhoto`, `SendDocument`, `SendVideo` с бинарными файлами). + +Для продвинутых сценариев `tgapi.NewRequest(...)` и `tgapi.NewUploaderRequest(...)` остаются публичными low-level escape hatch API. Они менее безопасны, чем типизированные helper-методы: вызывающая сторона сама отвечает за корректное имя Telegram-метода и совместимые типы параметров/ответа. + ## 🧩 Промежуточные слои (Middleware) Middleware — это функции, которые выполняются перед обработчиком команды. Они идеально подходят для сквозных задач, таких как логирование, контроль доступа, ограничение скорости запросов или модификация контекста. @@ -167,11 +186,12 @@ func(ctx *MsgContext, db T) bool - Если возвращается false, цепочка выполнения немедленно прерывается (команда не запускается). ### Добавление middleware -Используйте метод Use плагина для добавления одной или нескольких функций middleware. Они выполняются в порядке добавления. +Используйте метод `AddMiddleware` плагина для добавления одной или нескольких функций middleware. Они выполняются в порядке добавления. ```go -plugin := laniakea.NewPlugin[MyDB]("admin") -plugin.Use(loggingMiddleware, adminOnlyMiddleware) +plugin := laniakea.NewPlugin[*MyDB]("admin") +plugin.AddMiddleware(laniakea.NewMiddleware("logging", loggingMiddleware)) +plugin.AddMiddleware(laniakea.NewMiddleware("admin-only", adminOnlyMiddleware)) plugin.AddCommand(plugin.NewCommand(banUser, "ban")) ``` @@ -200,9 +220,17 @@ func adminOnlyMiddleware(ctx *laniakea.MsgContext, db *MyDB) bool { - Middleware может изменять MsgContext (например, добавлять пользовательские поля) перед запуском команды. ## ⚙️ Расширенная настройка -**Инлайн-клавиатуры**: Создавайте клавиатуры с помощью laniakea.NewKeyboard(). -**Ограничение запросов**: Передайте настроенный utils.RateLimiter через BotOpts для корректной обработки лимитов Telegram. -**Пользовательский HTTP-клиент**: Предоставьте свой http.Client в BotOpts для точного контроля. +- **Инлайн-клавиатуры**: Создавайте клавиатуры с помощью `laniakea.NewInlineKeyboardJson`, `laniakea.NewInlineKeyboardBase64` или `laniakea.NewInlineKeyboard`. +- **Ограничение запросов**: Передайте настроенный `utils.RateLimiter` через `BotOpts` для корректной обработки лимитов Telegram. +- **Локализация**: `L10n` безопасен для конкурентного использования после подключения к боту. +- **Пользовательские update handlers**: Используйте `plugin.AddUpdateHandler(...)` для Telegram update types вне command/payload flow. +- **Жизненный цикл**: `RunWithContext(...)` не вызывает `Close()` автоматически. Завершайте бот явно и создавайте новый `Bot` для следующего запуска. + +## Обработка Telegram Updates +- Команды и payload-ы обрабатываются через плагины. +- Для некомандных update-ов можно зарегистрировать обработчик через `plugin.AddUpdateHandler(updateType, handler)`. +- `message`, `channel_post` и `callback_query` остаются в command/payload flow. +- После JSON-декодирования `tgapi.Update` заполняет поле `Type`, чтобы обработчики могли явно видеть итоговый вид update. ## 📝 Лицензия Этот проект лицензирован под GNU General Public License v3.0 - подробности см. в файле [LICENSE](LICENSE). diff --git a/SEMVER.md b/SEMVER.md new file mode 100644 index 0000000..4422ed4 --- /dev/null +++ b/SEMVER.md @@ -0,0 +1,45 @@ +# Semantic Versioning Policy + +This project follows Semantic Versioning with the rules below. + +## Public API Surface + +The public API consists of: +- exported identifiers in package `laniakea` +- exported identifiers in package `tgapi` +- documented behavior in `README.md`, `README_RU.md`, and package godoc + +Anything unexported is internal and may change without notice. + +## Breaking Changes + +A release requires a major version bump when it changes any of the following: +- exported function, method, type, field, constant, or variable names +- function or method signatures +- JSON field names or request/response wire compatibility in `tgapi` +- documented behavioral guarantees relied on by callers + +Examples: +- removing an exported alias +- changing callback payload encoding defaults +- changing handler dispatch semantics in a way that breaks existing bots + +## Minor Changes + +A release uses a minor version bump for backward-compatible additions: +- new exported types, methods, helpers, or update handlers +- support for new Telegram Bot API fields or methods +- optional configuration knobs that do not change existing defaults + +## Patch Changes + +A release uses a patch version bump for backward-compatible fixes: +- bug fixes +- test-only changes +- godoc and README clarifications +- internal refactors with no public behavior change + +## Pre-Releases + +`-rc.N` builds may still adjust API details before `v1.0.0`. +Once `v1.0.0` is released, breaking changes require a new major version. diff --git a/bot.go b/bot.go index 40dccae..651bd05 100644 --- a/bot.go +++ b/bot.go @@ -4,9 +4,11 @@ import ( "context" "errors" "fmt" + "reflect" "sort" "strings" "sync" + "time" "git.nix13.pw/scuroneko/extypes" "git.nix13.pw/scuroneko/laniakea/tgapi" @@ -15,13 +17,18 @@ import ( "github.com/alitto/pond/v2" ) -// DbContext is an interface representing the application's database context. -// It is injected into plugins and middleware via Bot.DatabaseContext(). +// DbContext is the generic dependency type injected into bots, plugins, and handlers. +// Use it for shared application state such as database handles or service containers. // // Example: // // type MyDB struct { ... } -// bot := NewBot[MyDB](opts).DatabaseContext(&myDB) +// myDB := &MyDB{} +// bot, err := NewBot[*MyDB](opts) +// if err != nil { +// return err +// } +// bot.DatabaseContext(myDB) // // Use NoDB if no database is needed. type DbContext any @@ -32,7 +39,7 @@ type NoDB struct{ DbContext } // DbLogger is a function type that returns a slog.LoggerWriter for database logging. // Used to inject database-specific log output (e.g., SQL queries, ORM events). -type DbLogger[T DbContext] func(db *T) slog.LoggerWriter +type DbLogger[T DbContext] func(db T) slog.LoggerWriter // BotPayloadType defines the serialization format for callback data payloads. type BotPayloadType string @@ -44,6 +51,20 @@ var ( BotPayloadJson BotPayloadType = "json" ) +var ( + // ErrNoPrefixes reports that the bot was started without any command prefixes. + 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 or RunWithContext was called more than once. + ErrBotAlreadyRun = errors.New("bot can only be run once") + + // ErrTokenRequired reports that BotOpts.Token was empty. + ErrTokenRequired = errors.New("token required") + // ErrOptsIsNil reports that NewBot was called with a nil BotOpts pointer. + ErrOptsIsNil = errors.New("opts is nil") +) + // Bot is the core Telegram bot instance. // // Manages: @@ -53,7 +74,8 @@ var ( // - Logging and rate limiting // - Localization and draft message support // -// All methods are safe for concurrent use. Direct field access is not recommended. +// Runtime accessors are safe for concurrent use. Configure the bot before Run. +// A Bot is single-use: after Run or RunWithContext returns, create a new Bot for the next session. type Bot[T DbContext] struct { token string debug bool @@ -73,9 +95,11 @@ type Bot[T DbContext] struct { api *tgapi.API // Telegram API client uploader *tgapi.Uploader // File uploader - dbContext *T // Injected database context - l10n *L10n // Localization manager - draftProvider *DraftProvider // Draft message builder + dbContext T // Injected database context + hasDBContext bool + warnedValueDB bool + l10n *L10n // Localization manager + draftProvider *DraftProvider // Draft message builder updateOffsetMu sync.Mutex updateOffset int // Last processed update ID @@ -83,6 +107,9 @@ type Bot[T DbContext] struct { updateQueue chan *tgapi.Update // Internal queue for processing updates runnerOnceWG sync.WaitGroup // Tracks one-time async runners runnerBgWG sync.WaitGroup // Tracks background async runners + runStateMu sync.Mutex + running bool + ran bool } // NewBot creates and initializes a new Bot instance using the provided BotOpts. @@ -93,13 +120,12 @@ type Bot[T DbContext] struct { // - Fetches bot username via GetMe() // - Sets up DraftProvider with random IDs // - Adds API and Uploader loggers to extraLoggers -// -// Panics if: -// - Token is empty -// - GetMe() fails (invalid token or network error) -func NewBot[T any](opts *BotOpts) *Bot[T] { +func NewBot[T any](opts *BotOpts) (*Bot[T], error) { + if opts == nil { + return nil, ErrOptsIsNil + } if opts.Token == "" { - panic("laniakea: BotOpts.Token is required") + return nil, ErrTokenRequired } updateQueue := make(chan *tgapi.Update, 512) @@ -163,7 +189,7 @@ func NewBot[T any](opts *BotOpts) *Bot[T] { u, err := api.GetMe() if err != nil { _ = bot.Close() - bot.logger.Fatal(err) + return nil, err } bot.username = Val(u.Username, "") if bot.username == "" { @@ -171,7 +197,7 @@ func NewBot[T any](opts *BotOpts) *Bot[T] { } bot.logger.Infoln(fmt.Sprintf("Authorized as %s (@%s)", u.FirstName, Val(u.Username, "unknown"))) - return bot + return bot, nil } // Close gracefully shuts down bot-owned resources. @@ -226,11 +252,7 @@ func (bot *Bot[T]) CloseRemote(ctx context.Context) error { return nil } -// initLoggers configures the main and optional request loggers. -// -// Uses DEBUG flag to set log level (DEBUG if true, FATAL otherwise). -// Writes to stdout in JSON format by default. -// If WriteToFile is true, writes to main.log and requests.log in LoggerBasePath. +// Internal logger setup for the bot and optional request logger. func (bot *Bot[T]) initLoggers(opts *BotOpts) { level := slog.FATAL if opts.Debug { @@ -285,8 +307,8 @@ func (bot *Bot[T]) GetUpdateTypes() []tgapi.UpdateType { func (bot *Bot[T]) GetLogger() *slog.Logger { return bot.logger } // GetDBContext returns the injected database context. -// Returns nil if not set via DatabaseContext(). -func (bot *Bot[T]) GetDBContext() *T { return bot.dbContext } +// If DatabaseContext was not called, it returns the zero value of T. +func (bot *Bot[T]) GetDBContext() T { return bot.dbContext } // GetLoggerLevel returns the effective log level derived from the bot's debug // flag. @@ -313,8 +335,16 @@ func (bot *Bot[T]) SetDraftProvider(p *DraftProvider) *Bot[T] { // DatabaseContext injects a database context into the bot. // This context is accessible to plugins and middleware via GetDBContext(). -func (bot *Bot[T]) DatabaseContext(ctx *T) *Bot[T] { +// For shared dependencies such as *sql.DB, prefer using a pointer type as T. +// Value-typed contexts are supported, but the bot warns once because handlers +// receive T by value. +func (bot *Bot[T]) DatabaseContext(ctx T) *Bot[T] { + if !bot.warnedValueDB && shouldWarnOnValueDBContext[T]() && bot.logger != nil { + bot.logger.Warnln("database context uses a value type; shared dependencies should usually use a pointer type as T") + bot.warnedValueDB = true + } bot.dbContext = ctx + bot.hasDBContext = true return bot } @@ -387,12 +417,20 @@ func (bot *Bot[T]) Debug(debug bool) *Bot[T] { func (bot *Bot[T]) AddPlugins(plugin ...*Plugin[T]) *Bot[T] { level := bot.GetLoggerLevel() for _, p := range plugin { + if p == nil { + if bot.logger != nil { + bot.logger.Warn("nil plugin skipped") + } + continue + } cloned := clonePlugin(p) if cloned.logger == nil { cloned.logger = utils.CreateLogger(cloned.name, level) } bot.plugins = append(bot.plugins, cloned) - bot.logger.Debugln(fmt.Sprintf("plugins with name \"%s\" registered", cloned.name)) + if bot.logger != nil { + bot.logger.Debugln(fmt.Sprintf("plugins with name \"%s\" registered", cloned.name)) + } } return bot } @@ -409,13 +447,14 @@ func (bot *Bot[T]) AddPlugins(plugin ...*Plugin[T]) *Bot[T] { // // Example: // -// bot.AddMiddleware(&authMiddleware, &rateLimitMiddleware) +// bot.AddMiddleware(authMiddleware, rateLimitMiddleware) // -// Panics if any middleware has a nil name. +// Middleware with an empty name are skipped with a warning. func (bot *Bot[T]) AddMiddleware(middleware ...Middleware[T]) *Bot[T] { for _, m := range middleware { if m.name == "" { - panic("laniakea: middleware must have a non-empty name") + bot.logger.Warnln("middleware must have a non-empty name") + continue } bot.middlewares = append(bot.middlewares, m) bot.logger.Debugln(fmt.Sprintf("middleware with name \"%s\" registered", m.name)) @@ -446,12 +485,13 @@ func (bot *Bot[T]) AddMiddleware(middleware ...Middleware[T]) *Bot[T] { // // Example: // -// bot.AddRunner(&cleanupRunner) +// bot.AddRunner(cleanupRunner) // -// Panics if runner has a nil name. +// Runners with an empty name are skipped with a warning. func (bot *Bot[T]) AddRunner(runner Runner[T]) *Bot[T] { if runner.name == "" { - panic("laniakea: runner must have a non-empty name") + bot.logger.Warnln("runner must have a non-empty name") + return bot } bot.runners = append(bot.runners, runner) bot.logger.Debugln(fmt.Sprintf("runner with name \"%s\" registered", runner.name)) @@ -498,6 +538,14 @@ func (bot *Bot[T]) AddL10n(l *L10n) *Bot[T] { // return db.QueryLogger() // }) func (bot *Bot[T]) AddDatabaseLoggerWriter(writer DbLogger[T]) *Bot[T] { + if !bot.hasDBContext { + bot.logger.Warnln("database context is not set; skipping database logger writer") + return bot + } + if isNilValue(bot.dbContext) { + bot.logger.Warnln("database context is nil; skipping database logger writer") + return bot + } w := writer(bot.dbContext) bot.logger.AddWriter(w) if bot.RequestLogger != nil { @@ -536,17 +584,21 @@ func (bot *Bot[T]) AddDatabaseLoggerWriter(writer DbLogger[T]) *Bot[T] { // go bot.RunWithContext(ctx) // // ... later ... // cancel() // triggers graceful shutdown -// _ = bot.Close(context.Background()) -func (bot *Bot[T]) RunWithContext(ctx context.Context) { +// _ = bot.Close() +// +// A Bot is single-use. After RunWithContext returns, later calls return ErrBotAlreadyRun. +func (bot *Bot[T]) RunWithContext(ctx context.Context) error { if len(bot.prefixes) == 0 { - bot.logger.Errorln("no prefixes defined") - return + return ErrNoPrefixes } if len(bot.plugins) == 0 { - bot.logger.Errorln("no plugins defined") - return + return ErrNoPlugins } + if err := bot.beginRun(); err != nil { + return err + } + defer bot.finishRun() bot.ExecRunners(ctx) @@ -560,6 +612,7 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) { } close(bot.updateQueue) }() + retryDelay := time.Duration(0) for { select { case <-ctx.Done(): @@ -571,8 +624,19 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) { return } bot.logger.Errorln("failed to fetch updates:", err) + retryDelay = nextPollRetryDelay(retryDelay) + timer := time.NewTimer(retryDelay) + select { + case <-ctx.Done(): + if !timer.Stop() { + <-timer.C + } + return + case <-timer.C: + } continue } + retryDelay = 0 for _, update := range updates { u := update // copy loop variable to avoid race condition @@ -597,6 +661,7 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) { pool.Stop() // Wait for all tasks to complete and stop the pool bot.runnerOnceWG.Wait() bot.runnerBgWG.Wait() + return nil } // Run starts the bot using a background context. @@ -605,8 +670,62 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) { // Use this for simple bots where graceful shutdown is not required. // // For production use, prefer RunWithContext to handle SIGINT/SIGTERM gracefully. -func (bot *Bot[T]) Run() { - bot.RunWithContext(context.Background()) +func (bot *Bot[T]) Run() error { + return bot.RunWithContext(context.Background()) +} + +func (bot *Bot[T]) beginRun() error { + bot.runStateMu.Lock() + defer bot.runStateMu.Unlock() + if bot.running || bot.ran { + return ErrBotAlreadyRun + } + bot.running = true + bot.ran = true + return nil +} + +func (bot *Bot[T]) finishRun() { + bot.runStateMu.Lock() + bot.running = false + bot.runStateMu.Unlock() +} + +func nextPollRetryDelay(prev time.Duration) time.Duration { + if prev <= 0 { + return time.Second + } + next := prev * 2 + if next > 30*time.Second { + return 30 * time.Second + } + return next +} + +func isNilValue[T any](v T) bool { + rv := reflect.ValueOf(v) + if !rv.IsValid() { + return true + } + switch rv.Kind() { + case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice: + return rv.IsNil() + default: + return false + } +} + +func shouldWarnOnValueDBContext[T any]() bool { + t := reflect.TypeFor[T]() + if t == reflect.TypeFor[NoDB]() { + return false + } + switch t.Kind() { + case reflect.Pointer, reflect.Interface, reflect.Map, reflect.Slice, reflect.Func, reflect.Chan: + return false + default: + return true + } } func clonePlugin[T DbContext](p *Plugin[T]) Plugin[T] { @@ -617,6 +736,7 @@ func clonePlugin[T DbContext](p *Plugin[T]) Plugin[T] { middlewares: append(extypes.Slice[Middleware[T]](nil), p.middlewares...), skipAutoCmd: p.skipAutoCmd, logger: p.logger, + handlers: make(map[tgapi.UpdateType]CommandExecutor[T]), onClose: p.onClose, } @@ -626,6 +746,9 @@ func clonePlugin[T DbContext](p *Plugin[T]) Plugin[T] { for name, command := range p.payloads { cloned.payloads[name] = cloneCommand(command) } + for t, handler := range p.handlers { + cloned.handlers[t] = handler + } return cloned } diff --git a/bot_opts.go b/bot_opts.go index ae04a19..0ec97d6 100644 --- a/bot_opts.go +++ b/bot_opts.go @@ -11,7 +11,7 @@ import ( // BotOpts holds configuration options for initializing a Bot. // // Values are loaded from environment variables via LoadOptsFromEnv(). -// Use NewOpts() to create a zero-value struct and set fields manually. +// Use &BotOpts{} to create a value and set fields manually. type BotOpts struct { // Token is the Telegram bot token (required). Token string @@ -56,7 +56,7 @@ type BotOpts struct { // Use this to prioritize responsiveness over reliability. DropRLOverflow bool - // MaxWorkers is the maximum number of concurrency running update handlers. + // MaxWorkers is the maximum number of update handlers that may run concurrently. MaxWorkers int } @@ -75,20 +75,30 @@ type BotOpts struct { // - API_URL: custom API endpoint // - RATE_LIMIT: max requests per second (default: 30) // - DROP_RL_OVERFLOW: "true" to drop updates on rate limit overflow +// - MAX_WORKERS: maximum number of concurrent update handlers (default: 32) // -// Returns a populated BotOpts. If TG_TOKEN is missing, behavior is undefined. +// Returns a populated BotOpts. +// NewBot validates required fields and returns ErrTokenRequired when TG_TOKEN is missing. func LoadOptsFromEnv() *BotOpts { rateLimit := 30 + maxWorkers := 32 + + stringUpdateTypes := splitEnvList(os.Getenv("UPDATE_TYPES")) + updateTypes := make([]tgapi.UpdateType, 0, len(stringUpdateTypes)) + for _, updateType := range stringUpdateTypes { + updateTypes = append(updateTypes, tgapi.UpdateType(updateType)) + } + if rl := os.Getenv("RATE_LIMIT"); rl != "" { if n, err := strconv.Atoi(rl); err == nil { rateLimit = n } } - stringUpdateTypes := splitEnvList(os.Getenv("UPDATE_TYPES")) - updateTypes := make([]tgapi.UpdateType, 0, len(stringUpdateTypes)) - for _, updateType := range stringUpdateTypes { - updateTypes = append(updateTypes, tgapi.UpdateType(updateType)) + if mw := os.Getenv("MAX_WORKERS"); mw != "" { + if n, err := strconv.Atoi(os.Getenv("MAX_WORKERS")); err == nil { + maxWorkers = n + } } return &BotOpts{ @@ -108,6 +118,8 @@ func LoadOptsFromEnv() *BotOpts { RateLimit: rateLimit, DropRLOverflow: os.Getenv("DROP_RL_OVERFLOW") == "true", + + MaxWorkers: maxWorkers, } } diff --git a/bot_test.go b/bot_test.go index 0d4d671..45db995 100644 --- a/bot_test.go +++ b/bot_test.go @@ -1,9 +1,12 @@ package laniakea import ( + "context" + "errors" "path/filepath" "reflect" "testing" + "time" "git.nix13.pw/scuroneko/laniakea/tgapi" "git.nix13.pw/scuroneko/slog" @@ -24,14 +27,14 @@ func TestAddPluginsSnapshotsConfiguration(t *testing.T) { bot := &Bot[NoDB]{logger: slog.CreateLogger()} plugin := NewPlugin[NoDB]("demo") - cmd := plugin.NewCommand(func(ctx *MsgContext, db *NoDB) {}, "start") - plugin.AddMiddleware(*NewMiddleware("base", func(ctx *MsgContext, db *NoDB) bool { return true })) + cmd := plugin.NewCommand(func(ctx *MsgContext, db NoDB) {}, "start") + plugin.AddMiddleware(NewMiddleware("base", func(ctx *MsgContext, db NoDB) bool { return true })) bot.AddPlugins(plugin) cmd.SetDescription("mutated after registration") - plugin.NewCommand(func(ctx *MsgContext, db *NoDB) {}, "late") - plugin.AddMiddleware(*NewMiddleware("late", func(ctx *MsgContext, db *NoDB) bool { return true })) + plugin.NewCommand(func(ctx *MsgContext, db NoDB) {}, "late") + plugin.AddMiddleware(NewMiddleware("late", func(ctx *MsgContext, db NoDB) bool { return true })) registered := bot.plugins[0] if _, exists := registered.commands["late"]; exists { @@ -45,6 +48,20 @@ func TestAddPluginsSnapshotsConfiguration(t *testing.T) { } } +func TestAddPluginsSkipsNilPlugin(t *testing.T) { + bot := &Bot[NoDB]{logger: slog.CreateLogger()} + plugin := NewPlugin[NoDB]("demo") + + bot.AddPlugins(nil, plugin) + + if len(bot.plugins) != 1 { + t.Fatalf("expected exactly one registered plugin, got %d", len(bot.plugins)) + } + if bot.plugins[0].name != "demo" { + t.Fatalf("unexpected plugin name: %q", bot.plugins[0].name) + } +} + func TestInitLoggersFallsBackToStdoutLoggerOnFileError(t *testing.T) { bot := &Bot[NoDB]{} @@ -68,3 +85,117 @@ func TestInitLoggersFallsBackToStdoutLoggerOnFileError(t *testing.T) { t.Fatalf("failed to close main logger: %v", err) } } + +func TestNextPollRetryDelay(t *testing.T) { + tests := []struct { + name string + prev time.Duration + want time.Duration + }{ + {name: "initial", prev: 0, want: time.Second}, + {name: "double", prev: 2 * time.Second, want: 4 * time.Second}, + {name: "cap", prev: 20 * time.Second, want: 30 * time.Second}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := nextPollRetryDelay(tt.prev); got != tt.want { + t.Fatalf("nextPollRetryDelay(%s) = %s, want %s", tt.prev, got, tt.want) + } + }) + } +} + +func TestAddDatabaseLoggerWriterSkipsWhenDBContextIsUnset(t *testing.T) { + bot := &Bot[NoDB]{logger: slog.CreateLogger()} + called := false + + bot.AddDatabaseLoggerWriter(func(db NoDB) slog.LoggerWriter { + called = true + return nil + }) + + if called { + t.Fatal("expected database logger writer to be skipped when db context is unset") + } +} + +func TestAddDatabaseLoggerWriterSkipsWhenDBContextIsNil(t *testing.T) { + type testDB struct{} + + bot := &Bot[*testDB]{logger: slog.CreateLogger()} + var db *testDB + bot.DatabaseContext(db) + + called := false + bot.AddDatabaseLoggerWriter(func(db *testDB) slog.LoggerWriter { + called = true + return nil + }) + + if called { + t.Fatal("expected database logger writer to be skipped when db context is nil") + } +} + +func TestShouldWarnOnValueDBContext(t *testing.T) { + type testDB struct{} + type dbIface interface{ Ping() error } + + tests := []struct { + name string + got bool + want bool + }{ + {name: "NoDB", got: shouldWarnOnValueDBContext[NoDB](), want: false}, + {name: "pointer", got: shouldWarnOnValueDBContext[*testDB](), want: false}, + {name: "interface", got: shouldWarnOnValueDBContext[dbIface](), want: false}, + {name: "map", got: shouldWarnOnValueDBContext[map[string]int](), want: false}, + {name: "struct", got: shouldWarnOnValueDBContext[testDB](), want: true}, + {name: "int", got: shouldWarnOnValueDBContext[int](), want: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.got != tt.want { + t.Fatalf("shouldWarnOnValueDBContext = %v, want %v", tt.got, tt.want) + } + }) + } +} + +func TestDatabaseContextMarksValueWarningOnce(t *testing.T) { + type testDB struct{} + + bot := &Bot[testDB]{logger: slog.CreateLogger()} + bot.DatabaseContext(testDB{}) + if !bot.warnedValueDB { + t.Fatal("expected value-typed database context to mark warning state") + } + + ptrBot := &Bot[*testDB]{logger: slog.CreateLogger()} + ptrBot.DatabaseContext(&testDB{}) + if ptrBot.warnedValueDB { + t.Fatal("did not expect pointer-typed database context to mark warning state") + } +} + +func TestRunWithContextRejectsSecondRun(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + bot := &Bot[NoDB]{ + logger: slog.CreateLogger(), + prefixes: []string{"/"}, + plugins: []Plugin[NoDB]{{name: "demo"}}, + updateQueue: make(chan *tgapi.Update, 1), + maxWorkers: 1, + } + + if err := bot.RunWithContext(ctx); err != nil { + t.Fatalf("first RunWithContext returned error: %v", err) + } + if err := bot.RunWithContext(ctx); !errors.Is(err, ErrBotAlreadyRun) { + t.Fatalf("expected ErrBotAlreadyRun on second run, got %v", err) + } +} diff --git a/cmd_generator.go b/cmd_generator.go index 4658e88..2bf524a 100644 --- a/cmd_generator.go +++ b/cmd_generator.go @@ -4,13 +4,14 @@ import ( "errors" "fmt" "regexp" + "sort" "strings" "git.nix13.pw/scuroneko/laniakea/tgapi" ) // CmdRegexp matches command names allowed for Telegram command registration. -var CmdRegexp = regexp.MustCompile("^[_a-z0-9]+$") +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. @@ -20,7 +21,7 @@ var CmdRegexp = regexp.MustCompile("^[_a-z0-9]+$") // bot initialization. var ErrTooManyCommands = errors.New("too many commands. max 100") -// generateBotCommand builds a BotCommand description with generated usage text. +// Internal helper to build a BotCommand description with generated usage text. func generateBotCommand[T any](cmd *Command[T]) tgapi.BotCommand { desc := "" if len(cmd.description) > 0 { @@ -44,13 +45,20 @@ func generateBotCommand[T any](cmd *Command[T]) tgapi.BotCommand { return tgapi.BotCommand{Command: cmd.command, Description: usage} } -// checkCmdRegex reports whether cmd matches CmdRegexp. +// Internal helper to validate Telegram command names. func checkCmdRegex(cmd string) bool { return CmdRegexp.MatchString(cmd) } -// gatherCommandsForPlugin collects non-skipped, valid commands from one plugin. +// Internal helper to collect non-skipped, valid commands from one plugin. func gatherCommandsForPlugin[T any](pl Plugin[T]) []tgapi.BotCommand { commands := make([]tgapi.BotCommand, 0) - for _, cmd := range pl.commands { + names := make([]string, 0, len(pl.commands)) + for name := range pl.commands { + names = append(names, name) + } + sort.Strings(names) + + for _, name := range names { + cmd := pl.commands[name] if cmd.skipAutoCmd { continue } @@ -62,9 +70,7 @@ func gatherCommandsForPlugin[T any](pl Plugin[T]) []tgapi.BotCommand { return commands } -// gatherCommands collects all commands from all plugins -// and converts them into tgapi.BotCommand objects. -// See gatherCommandsForPlugin. +// Internal helper to collect all auto-generated commands from registered plugins. func gatherCommands[T any](bot *Bot[T]) []tgapi.BotCommand { commands := make([]tgapi.BotCommand, 0) for _, pl := range bot.plugins { diff --git a/cmd_generator_test.go b/cmd_generator_test.go index c2130aa..468316f 100644 --- a/cmd_generator_test.go +++ b/cmd_generator_test.go @@ -4,6 +4,7 @@ import ( "errors" "io" "net/http" + "reflect" "strconv" "strings" "sync/atomic" @@ -43,7 +44,7 @@ func TestAutoGenerateCommandsChecksLimitBeforeDelete(t *testing.T) { }() plugin := NewPlugin[NoDB]("overflow") - exec := func(ctx *MsgContext, db *NoDB) {} + exec := func(ctx *MsgContext, db NoDB) {} for i := 0; i < 101; i++ { plugin.AddCommand(NewCommand(exec, "cmd"+strconv.Itoa(i))) } @@ -62,3 +63,23 @@ func TestAutoGenerateCommandsChecksLimitBeforeDelete(t *testing.T) { t.Fatalf("expected no HTTP calls before limit validation, got %d", calls.Load()) } } + +func TestGatherCommandsForPluginReturnsSortedCommands(t *testing.T) { + plugin := NewPlugin[NoDB]("sorted") + exec := func(ctx *MsgContext, db NoDB) {} + + plugin.AddCommand(NewCommand(exec, "zeta")) + plugin.AddCommand(NewCommand(exec, "alpha")) + plugin.AddCommand(NewCommand(exec, "mid")) + + commands := gatherCommandsForPlugin(*plugin) + got := make([]string, 0, len(commands)) + for _, cmd := range commands { + got = append(got, cmd.Command) + } + + want := []string{"alpha", "mid", "zeta"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("unexpected command order: got %v want %v", got, want) + } +} diff --git a/doc.go b/doc.go index a19a1ec..9a6dcee 100644 --- a/doc.go +++ b/doc.go @@ -1,55 +1,33 @@ /* Package laniakea provides a modular, extensible framework for building scalable Telegram bots. -It offers a fluent API for configuration and separates concerns through several core concepts: +Core concepts: - - Bot: The central instance managing API communication, update processing, logging, - rate limiting, and dependency injection. Created via NewBot[T]. - - - Plugins: Organize commands and payloads into reusable units. - A plugin can have multiple commands and shared middlewares. - - - Commands: Named bot commands with descriptions, argument validation, and - execution logic. Automatically registrable across different chat scopes. - - - Middleware: Functions that intercept and modify updates before they reach plugins. - Useful for authentication, logging, validation, etc. Return false to stop processing. - - - MsgContext: Provides access to the incoming update and convenient methods for - responding, editing, deleting, and translating messages. Includes built-in rate limiting - and error handling. ⚠️ MarkdownV2 methods require manual escaping via EscapeMarkdownV2(). - - - InlineKeyboard: A fluent builder for constructing inline keyboards with styled buttons, - icons, URLs, and structured callback data (JSON or Base64). - - - DraftProvider: Manages ephemeral, multi-step message drafts with automatic ID generation - (random or linear). Drafts can be built incrementally and flushed atomically. - - - L10n: Simple key-based localization system with fallback language support. - - - Runners: Background goroutines for periodic tasks or one‑off initialization, - with configurable timeouts and async execution. - - - RateLimiting & Logging: Built‑in rate limiter (respects Telegram's retry_after) - and structured logging (JSON stdout + optional file output) with request‑level tracing. - - - Dependency Injection: Pass any custom database context (e.g., *sql.DB) to all handlers - via the type parameter T in Bot[T]. + - Bot manages Telegram API access, update processing, logging, rate limiting, and dependency injection. + - Plugins group commands, payloads, and non-command update handlers behind shared middleware. + - MsgContext provides access to the current update and reply/edit/delete helpers. + - InlineKeyboard builds callback-driven keyboards and structured payloads. + - DraftProvider accumulates multi-step replies before sending them. + - L10n stores key-based translations with fallback behavior. + - Runners execute startup or background tasks alongside the polling loop. Example usage: - bot := laniakea.NewBot[mydb.DBContext](laniakea.LoadOptsFromEnv()). - DatabaseContext(&myDB). + bot, err := laniakea.NewBot[*mydb.DBContext](laniakea.LoadOptsFromEnv()) + if err != nil { + return err + } + bot.DatabaseContext(myDB). AddUpdateType(tgapi.UpdateTypeMessage). AddPrefixes("/", "!"). AddPlugins(&startPlugin, &helpPlugin). - AddMiddleware(&authMiddleware, &logMiddleware). - AddRunner(&cleanupRunner). + AddMiddleware(authMiddleware, logMiddleware). + AddRunner(cleanupRunner). AddL10n(l10n.New()) - bot.Run() + return bot.Run() -All public methods are safe for concurrent use unless stated otherwise. -Direct field access is not recommended; use provided accessors (e.g., GetDBContext, SetUpdateOffset). +Configure bots, plugins, and localization before starting Run or RunWithContext. +Runtime accessors are safe for concurrent use unless stated otherwise. */ package laniakea diff --git a/drafts.go b/drafts.go index 168adca..2de42de 100644 --- a/drafts.go +++ b/drafts.go @@ -12,7 +12,7 @@ import ( // ErrDraftChatIDZero is returned when a draft is used without setting a chat ID. var ErrDraftChatIDZero = errors.New("zero draft chat ID") -// draftIdGenerator defines an interface for generating unique draft IDs. +// Interface for generating unique draft IDs. type draftIdGenerator interface { // Next returns the next unique draft ID. Next() uint64 @@ -239,7 +239,7 @@ func (d *Draft) Flush() error { return err } -// push is the internal helper for Push(). It updates the server draft via SendMessageDraft. +// Internal helper for Push that updates the server-side draft. func (d *Draft) push(text string) error { if d.chatID == 0 { return ErrDraftChatIDZero diff --git a/handler.go b/handler.go index b605482..b328217 100644 --- a/handler.go +++ b/handler.go @@ -22,37 +22,47 @@ func (bot *Bot[T]) handle(u *tgapi.Update) { ctx := &MsgContext{ Update: *u, Api: bot.api, + Logger: bot.logger, errorTemplate: bot.errorTemplate, l10n: bot.l10n, draftProvider: bot.draftProvider, payloadType: bot.payloadType, } + bot.prepareUpdateCtx(u, ctx) + for _, middleware := range bot.middlewares { if !middleware.Execute(ctx, bot.dbContext) { return } } - if u.CallbackQuery != nil { - bot.handleCallback(u, ctx) - } else { + switch u.Type { + case tgapi.UpdateTypeMessage, tgapi.UpdateTypeChannelPost: bot.handleMessage(u, ctx) + case tgapi.UpdateTypeCallbackQuery: + bot.handleCallback(u, ctx) + default: + bot.handleUpdate(u, ctx) } } func (bot *Bot[T]) handleMessage(update *tgapi.Update, ctx *MsgContext) { - if update.Message == nil { - return - } - if update.Message.From == nil { + var msg *tgapi.Message + if update.Message != nil { + msg = update.Message + } else if update.ChannelPost != nil { + msg = update.ChannelPost + } else { return } var text string - if len(update.Message.Text) > 0 { - text = update.Message.Text + if len(msg.Text) > 0 { + text = msg.Text + } else if len(msg.Caption) > 0 { + text = msg.Caption } else { - text = update.Message.Caption + return } text = strings.TrimSpace(text) @@ -60,10 +70,9 @@ func (bot *Bot[T]) handleMessage(update *tgapi.Update, ctx *MsgContext) { if !hasPrefix { return } + ctx.Prefix = prefix - ctx.FromID = update.Message.From.ID - ctx.From = update.Message.From - ctx.Msg = update.Message + ctx.Update = *update // Убираем префикс text = strings.TrimSpace(text[len(prefix):]) @@ -94,9 +103,8 @@ func (bot *Bot[T]) handleMessage(update *tgapi.Update, ctx *MsgContext) { ctx.Text = args ctx.Args = strings.Fields(args) // Убирает лишние пробелы - ctx.Logger = plugin.logger - if ctx.Logger == nil { - ctx.Logger = bot.logger + if plugin.logger != nil { + ctx.Logger = plugin.logger } if !plugin.executeMiddlewares(ctx, bot.dbContext) { return @@ -114,16 +122,6 @@ func (bot *Bot[T]) handleCallback(update *tgapi.Update, ctx *MsgContext) { return } - ctx.FromID = update.CallbackQuery.From.ID - ctx.From = &update.CallbackQuery.From - if update.CallbackQuery.Message != nil { - ctx.Msg = update.CallbackQuery.Message - ctx.CallbackMsgId = update.CallbackQuery.Message.MessageID - } - if update.CallbackQuery.InlineMessageID != nil { - ctx.InlineMsgId = *update.CallbackQuery.InlineMessageID - } - ctx.CallbackQueryId = update.CallbackQuery.ID ctx.Args = data.Args for _, plugin := range bot.plugins { @@ -144,6 +142,134 @@ func (bot *Bot[T]) handleCallback(update *tgapi.Update, ctx *MsgContext) { } } +func (bot *Bot[T]) handleUpdate(u *tgapi.Update, ctx *MsgContext) { + for _, plugin := range bot.plugins { + handler, ok := plugin.handlers[u.Type] + if !ok { + continue + } + + pluginCtx := cloneMsgContext(ctx) + if plugin.logger != nil { + pluginCtx.Logger = plugin.logger + } + if !plugin.executeMiddlewares(pluginCtx, bot.dbContext) { + continue + } + handler(pluginCtx, bot.dbContext) + } +} + +func cloneMsgContext(src *MsgContext) *MsgContext { + cloned := *src + if src.Args != nil { + cloned.Args = append([]string(nil), src.Args...) + } + return &cloned +} + +func (bot *Bot[T]) prepareUpdateCtx(u *tgapi.Update, ctx *MsgContext) { + var from *tgapi.User + switch u.Type { + case tgapi.UpdateTypeMessage: + if u.Message != nil { + ctx.Msg = u.Message + } + case tgapi.UpdateTypeEditedMessage: + if u.EditedMessage != nil { + ctx.Msg = u.EditedMessage + } + case tgapi.UpdateTypeChannelPost: + if u.ChannelPost != nil { + ctx.Msg = u.ChannelPost + } + case tgapi.UpdateTypeEditedChannelPost: + if u.EditedChannelPost != nil { + ctx.Msg = u.EditedChannelPost + } + case tgapi.UpdateTypeBusinessMessage: + if u.BusinessMessage != nil { + ctx.Msg = u.BusinessMessage + } + case tgapi.UpdateTypeEditedBusinessMessage: + if u.EditedBusinessMessage != nil { + ctx.Msg = u.EditedBusinessMessage + } + case tgapi.UpdateTypeInlineQuery: + if u.InlineQuery != nil { + from = &u.InlineQuery.From + } + case tgapi.UpdateTypeChosenInlineResult: + if u.ChosenInlineResult != nil { + from = &u.ChosenInlineResult.From + } + case tgapi.UpdateTypeCallbackQuery: + if u.CallbackQuery != nil { + if u.CallbackQuery.Message != nil { + ctx.Msg = u.CallbackQuery.Message + ctx.CallbackMsgId = u.CallbackQuery.Message.MessageID + } + if u.CallbackQuery.InlineMessageID != nil { + ctx.InlineMsgId = *u.CallbackQuery.InlineMessageID + } + ctx.CallbackQueryId = u.CallbackQuery.ID + from = &u.CallbackQuery.From + } + case tgapi.UpdateTypeShippingQuery: + if u.ShippingQuery != nil { + from = &u.ShippingQuery.From + } + case tgapi.UpdateTypePreCheckoutQuery: + if u.PreCheckoutQuery != nil { + from = &u.PreCheckoutQuery.From + } + case tgapi.UpdateTypePurchasedPaidMedia: + if u.PurchasedPaidMedia != nil { + from = &u.PurchasedPaidMedia.From + } + case tgapi.UpdateTypeMyChatMember: + if u.MyChatMember != nil { + from = &u.MyChatMember.From + } + case tgapi.UpdateTypeChatMember: + if u.ChatMember != nil { + from = &u.ChatMember.From + } + case tgapi.UpdateTypeChatJoinRequest: + if u.ChatJoinRequest != nil { + from = &u.ChatJoinRequest.From + } + case tgapi.UpdateTypeBusinessConnection: + if u.BusinessConnection != nil { + from = &u.BusinessConnection.User + } + case tgapi.UpdateTypePollAnswer: + if u.PollAnswer != nil { + from = &u.PollAnswer.User + } + case tgapi.UpdateTypeMessageReaction: + if u.MessageReaction != nil { + from = u.MessageReaction.User + } + case tgapi.UpdateTypeChatBoost: + if u.ChatBoost != nil { + from = &u.ChatBoost.Boost.Source.User + } + case tgapi.UpdateTypeRemovedChatBoost: + if u.RemovedChatBoost != nil { + from = &u.RemovedChatBoost.Source.User + } + } + if ctx.Msg != nil && from == nil { + from = ctx.Msg.From + } + if from != nil { + ctx.From = from + ctx.FromID = from.ID + } + ctx.Update = *u +} + func (bot *Bot[T]) checkPrefixes(text string) (string, bool) { for _, prefix := range bot.prefixes { if prefix == "" { diff --git a/handler_test.go b/handler_test.go index 789dae5..8d91e6b 100644 --- a/handler_test.go +++ b/handler_test.go @@ -1,6 +1,11 @@ package laniakea -import "testing" +import ( + "testing" + + "git.nix13.pw/scuroneko/laniakea/tgapi" + "git.nix13.pw/scuroneko/slog" +) func TestCheckPrefixesSkipsEmptyPrefixes(t *testing.T) { bot := &Bot[NoDB]{prefixes: []string{"", "/"}} @@ -12,3 +17,222 @@ func TestCheckPrefixesSkipsEmptyPrefixes(t *testing.T) { t.Fatalf("unexpected prefix result: prefix=%q ok=%v", prefix, ok) } } + +func TestBotMiddlewareReceivesLogger(t *testing.T) { + logger := slog.CreateLogger() + called := false + + bot := &Bot[NoDB]{ + logger: logger, + middlewares: []Middleware[NoDB]{ + NewMiddleware("logger-check", func(ctx *MsgContext, db NoDB) bool { + called = true + if ctx.Logger != logger { + t.Fatalf("expected bot logger in middleware context, got %#v", ctx.Logger) + } + return true + }), + }, + } + + bot.handle(&tgapi.Update{ + UpdateID: 1, + Type: tgapi.UpdateTypePoll, + Poll: &tgapi.Poll{ + ID: "poll", + Question: "question", + }, + }) + + if !called { + t.Fatal("expected bot middleware to be called") + } +} + +func TestAddUpdateHandlerRejectsReservedUpdateTypes(t *testing.T) { + plugin := NewPlugin[NoDB]("test") + handler := func(ctx *MsgContext, db NoDB) {} + + for _, updateType := range []tgapi.UpdateType{ + tgapi.UpdateTypeMessage, + tgapi.UpdateTypeChannelPost, + tgapi.UpdateTypeCallbackQuery, + } { + func() { + defer func() { + if r := recover(); r != nil { + t.Fatalf("AddUpdateHandler(%q) panicked: %v", updateType, r) + } + }() + plugin.AddUpdateHandler(updateType, handler) + }() + + if _, ok := plugin.handlers[updateType]; ok { + t.Fatalf("reserved update type %q must not be registered", updateType) + } + } +} + +func TestHandleUpdateHandlersPopulateFromContext(t *testing.T) { + tests := []struct { + name string + update *tgapi.Update + wantID int64 + }{ + { + name: "inline query", + update: &tgapi.Update{ + UpdateID: 1, + Type: tgapi.UpdateTypeInlineQuery, + InlineQuery: &tgapi.InlineQuery{ + ID: "iq", + From: tgapi.User{ID: 41}, + Query: "ping", + }, + }, + wantID: 41, + }, + { + name: "chosen inline result", + update: &tgapi.Update{ + UpdateID: 2, + Type: tgapi.UpdateTypeChosenInlineResult, + ChosenInlineResult: &tgapi.ChosenInlineResult{ + ResultID: "res", + From: tgapi.User{ID: 77}, + Query: "pong", + }, + }, + wantID: 77, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + called := false + plugin := NewPlugin[NoDB]("test").AddUpdateHandler(tt.update.Type, func(ctx *MsgContext, db NoDB) { + called = true + if ctx.Update.UpdateID != tt.update.UpdateID { + t.Fatalf("unexpected update in context: got %d want %d", ctx.Update.UpdateID, tt.update.UpdateID) + } + if ctx.From == nil { + t.Fatal("expected ctx.From to be populated") + } + if ctx.FromID != tt.wantID { + t.Fatalf("unexpected FromID: got %d want %d", ctx.FromID, tt.wantID) + } + if ctx.From.ID != tt.wantID { + t.Fatalf("unexpected ctx.From.ID: got %d want %d", ctx.From.ID, tt.wantID) + } + if ctx.Msg != nil { + t.Fatalf("did not expect message context for %s", tt.name) + } + }) + + bot := &Bot[NoDB]{ + logger: slog.CreateLogger(), + plugins: []Plugin[NoDB]{clonePlugin(plugin)}, + } + + bot.handle(tt.update) + + if !called { + t.Fatalf("expected update handler for %s to be called", tt.name) + } + }) + } +} + +func TestHandleUpdateHandlersReceiveIsolatedContexts(t *testing.T) { + firstCalled := false + secondCalled := false + + first := NewPlugin[NoDB]("first").AddUpdateHandler(tgapi.UpdateTypeInlineQuery, func(ctx *MsgContext, db NoDB) { + firstCalled = true + if ctx.FromID != 41 { + t.Fatalf("unexpected FromID in first handler: got %d want 41", ctx.FromID) + } + ctx.From = nil + ctx.FromID = 999 + ctx.Text = "mutated" + ctx.Args = []string{"mutated"} + }) + second := NewPlugin[NoDB]("second").AddUpdateHandler(tgapi.UpdateTypeInlineQuery, func(ctx *MsgContext, db NoDB) { + secondCalled = true + if ctx.From == nil { + t.Fatal("expected ctx.From to remain populated for second handler") + } + if ctx.FromID != 41 { + t.Fatalf("unexpected FromID in second handler: got %d want 41", ctx.FromID) + } + if ctx.Text != "" { + t.Fatalf("unexpected leaked Text in second handler: %q", ctx.Text) + } + if len(ctx.Args) != 0 { + t.Fatalf("unexpected leaked Args in second handler: %v", ctx.Args) + } + }) + + bot := &Bot[NoDB]{ + logger: slog.CreateLogger(), + plugins: []Plugin[NoDB]{ + clonePlugin(first), + clonePlugin(second), + }, + } + + bot.handle(&tgapi.Update{ + UpdateID: 3, + Type: tgapi.UpdateTypeInlineQuery, + InlineQuery: &tgapi.InlineQuery{ + ID: "iq", + From: tgapi.User{ID: 41}, + Query: "ping", + }, + }) + + if !firstCalled || !secondCalled { + t.Fatalf("expected both handlers to be called, got first=%v second=%v", firstCalled, secondCalled) + } +} + +func TestHandleChannelPostCommandWithSenderChat(t *testing.T) { + called := false + plugin := NewPlugin[NoDB]("test") + plugin.NewCommand(func(ctx *MsgContext, db NoDB) { + called = true + if ctx.Msg == nil { + t.Fatal("expected message context") + } + if ctx.Msg.Chat == nil || ctx.Msg.Chat.ID != -1001 { + t.Fatalf("unexpected chat context: %#v", ctx.Msg.Chat) + } + if ctx.From != nil { + t.Fatalf("expected ctx.From to stay nil for sender_chat updates, got %#v", ctx.From) + } + if ctx.FromID != 0 { + t.Fatalf("expected zero FromID for sender_chat updates, got %d", ctx.FromID) + } + }, "ping") + + bot := &Bot[NoDB]{ + logger: slog.CreateLogger(), + prefixes: []string{"/"}, + plugins: []Plugin[NoDB]{clonePlugin(plugin)}, + } + + bot.handle(&tgapi.Update{ + UpdateID: 10, + Type: tgapi.UpdateTypeChannelPost, + ChannelPost: &tgapi.Message{ + MessageID: 55, + Text: "/ping", + SenderChat: &tgapi.Chat{ID: -1001, Type: string(tgapi.ChatTypeChannel)}, + Chat: &tgapi.Chat{ID: -1001, Type: string(tgapi.ChatTypeChannel)}, + }, + }) + + if !called { + t.Fatal("expected channel post command handler to be called") + } +} diff --git a/keyboard.go b/keyboard.go index 3ea98f6..1ce82ab 100644 --- a/keyboard.go +++ b/keyboard.go @@ -7,13 +7,12 @@ import ( "git.nix13.pw/scuroneko/laniakea/tgapi" ) -// ButtonStyleDanger, ButtonStyleSuccess, ButtonStylePrimary are predefined -// Telegram keyboard button styles for visual feedback. -// -// These values map directly to Telegram Bot API's InlineKeyboardButton style field. const ( - ButtonStyleDanger tgapi.KeyboardButtonStyle = "danger" + // ButtonStyleDanger marks a destructive inline keyboard action. + ButtonStyleDanger tgapi.KeyboardButtonStyle = "danger" + // ButtonStyleSuccess marks a confirmatory inline keyboard action. ButtonStyleSuccess tgapi.KeyboardButtonStyle = "success" + // ButtonStylePrimary marks a primary inline keyboard action. ButtonStylePrimary tgapi.KeyboardButtonStyle = "primary" ) @@ -83,8 +82,7 @@ func (b InlineKbButtonBuilder) SetCallbackDataBase64(cmd string, args ...any) In return b } -// build converts the builder state into a tgapi.InlineKeyboardButton. -// This method is typically called internally by InlineKeyboard.AddButton(). +// Internal helper that converts the builder state into a Telegram button. func (b InlineKbButtonBuilder) build() tgapi.InlineKeyboardButton { return tgapi.InlineKeyboardButton{ Text: b.text, @@ -146,8 +144,7 @@ func (in *InlineKeyboard) SetPayloadType(t BotPayloadType) *InlineKeyboard { return in } -// append adds a button to the current line. If the line is full, it auto-flushes. -// This is an internal helper used by other builder methods. +// Internal helper that appends a button and auto-flushes a full row. func (in *InlineKeyboard) append(button tgapi.InlineKeyboardButton) *InlineKeyboard { if in.CurrentLine.Len() == in.maxRow { in.AddLine() @@ -235,12 +232,12 @@ type CallbackData struct { // (int, string, bool, float64) but may not serialize complex structs meaningfully. // // Use this to build callback payloads for bot command routing. -func NewCallbackData(command string, args ...any) *CallbackData { +func NewCallbackData(command string, args ...any) CallbackData { stringArgs := make([]string, len(args)) for i, arg := range args { stringArgs[i] = fmt.Sprint(arg) } - return &CallbackData{ + return CallbackData{ Command: command, Args: stringArgs, } @@ -253,8 +250,8 @@ func NewCallbackData(command string, args ...any) *CallbackData { // // This fallback ensures the bot receives a valid JSON payload even if internal // errors occur — avoiding "invalid callback_data" errors from Telegram. -func (d *CallbackData) ToJson() string { - data, err := encodeJsonPayload(*d) +func (d CallbackData) ToJson() string { + data, err := encodeJsonPayload(d) if err != nil { // Fallback: return minimal valid JSON to avoid Telegram API rejection return `{"cmd":""}` @@ -264,8 +261,8 @@ func (d *CallbackData) ToJson() string { // ToBase64 serializes the CallbackData to a JSON string and then encodes it as Base64. // Returns an empty string if serialization or encoding fails. -func (d *CallbackData) ToBase64() string { - s, err := encodeBase64Payload(*d) +func (d CallbackData) ToBase64() string { + s, err := encodeBase64Payload(d) if err != nil { return `` } @@ -275,7 +272,7 @@ func (d *CallbackData) ToBase64() string { // Encode serializes the CallbackData according to the specified payload type. // Supported types: BotPayloadJson and BotPayloadBase64. // For unknown types, returns an empty string. -func (d *CallbackData) Encode(t BotPayloadType) string { +func (d CallbackData) Encode(t BotPayloadType) string { switch t { case BotPayloadBase64: return d.ToBase64() diff --git a/keyboard_test.go b/keyboard_test.go new file mode 100644 index 0000000..3c39a95 --- /dev/null +++ b/keyboard_test.go @@ -0,0 +1,44 @@ +package laniakea + +import ( + "strings" + "testing" +) + +func TestInlineKeyboardWrapsRowsAndEncodesJSONPayloads(t *testing.T) { + kb := NewInlineKeyboardJson(2). + AddCallbackButton("A", "cmd", 1). + AddCallbackButton("B", "cmd", 2). + AddCallbackButton("C", "cmd", 3) + + markup := kb.Get() + if got := len(markup.InlineKeyboard); got != 2 { + t.Fatalf("unexpected row count: %d", got) + } + if got := len(markup.InlineKeyboard[0]); got != 2 { + t.Fatalf("unexpected first row size: %d", got) + } + if got := len(markup.InlineKeyboard[1]); got != 1 { + t.Fatalf("unexpected second row size: %d", got) + } + if !strings.Contains(markup.InlineKeyboard[0][0].CallbackData, `"cmd":"cmd"`) { + t.Fatalf("expected JSON callback payload, got %q", markup.InlineKeyboard[0][0].CallbackData) + } +} + +func TestInlineKeyboardBuilderPreservesConfiguredButtonFields(t *testing.T) { + kb := NewInlineKeyboardBase64(3). + AddButton( + NewInlineKbButton("Docs"). + SetStyle(ButtonStylePrimary). + SetUrl("https://example.test"), + ) + + button := kb.Get().InlineKeyboard[0][0] + if button.Style != ButtonStylePrimary { + t.Fatalf("unexpected style: %q", button.Style) + } + if button.URL != "https://example.test" { + t.Fatalf("unexpected url: %q", button.URL) + } +} diff --git a/l10n.go b/l10n.go index 9f2268e..77c5092 100644 --- a/l10n.go +++ b/l10n.go @@ -1,21 +1,18 @@ package laniakea -// DictEntry represents a single localized entry with language-to-text mappings. -// Example: {"ru": "Привет", "en": "Hello"}. +import "sync" + +// DictEntry maps language codes to translated strings. type DictEntry map[string]string -// L10n is a localization manager that maps keys to language-specific strings. +// L10n stores translations with a configurable fallback language and is safe for concurrent use. type L10n struct { - entries map[string]DictEntry // Map of translation keys to language dictionaries - fallbackLang string // Language code to use when requested language is missing + mu sync.RWMutex + entries map[string]DictEntry + fallbackLang string } -// NewL10n creates a new L10n instance with the specified fallback language. -// The fallback language is used when a requested language is not available -// for a given key. -// -// Example: NewL10n("en") will return "Hello" for key "greeting" if "ru" is requested -// but no "ru" entry exists. +// NewL10n creates a localization store with the given fallback language. func NewL10n(fallbackLanguage string) *L10n { return &L10n{ entries: make(map[string]DictEntry), @@ -23,54 +20,52 @@ func NewL10n(fallbackLanguage string) *L10n { } } -// AddDictEntry adds a new translation entry for the given key. -// The value must be a DictEntry mapping language codes (e.g., "en", "ru") to their translated strings. -// -// If a key already exists, it is overwritten. -// -// Returns the L10n instance for method chaining. +// AddDictEntry stores translations for key. func (l *L10n) AddDictEntry(key string, value DictEntry) *L10n { - l.entries[key] = value + l.mu.Lock() + defer l.mu.Unlock() + if l.entries == nil { + l.entries = make(map[string]DictEntry) + } + l.entries[key] = cloneDictEntry(value) return l } // GetFallbackLanguage returns the currently configured fallback language code. func (l *L10n) GetFallbackLanguage() string { + l.mu.RLock() + defer l.mu.RUnlock() return l.fallbackLang } -// Translate retrieves the translation for the given key and language. -// -// Behavior: -// - If the key exists and the language has a translation → returns the translation -// - If the key exists but the language is missing → returns the fallback language's value -// - If the key does not exist → returns the key string itself (as fallback) -// -// Example: -// -// l.AddDictEntry("greeting", DictEntry{"en": "Hello", "ru": "Привет"}) -// l.Translate("en", "greeting") → "Hello" -// l.Translate("es", "greeting") → "Hello" (fallback to "en") -// l.Translate("en", "unknown") → "unknown" (key not found) -// -// This behavior ensures that missing translations do not break UI or logs — -// instead, the original key is displayed, making it easy to identify gaps. +// Translate returns the translation for key in lang, falling back to the configured language or the key itself. func (l *L10n) Translate(lang, key string) string { + l.mu.RLock() + defer l.mu.RUnlock() + entries, exists := l.entries[key] if !exists { - return key // Return key as fallback when translation is missing + return key } - // Try requested language if translation, ok := entries[lang]; ok { return translation } - // Fall back to configured fallback language if fallback, ok := entries[l.fallbackLang]; ok { return fallback } - // If fallback language is also missing, return the key return key } + +func cloneDictEntry(src DictEntry) DictEntry { + if src == nil { + return nil + } + cloned := make(DictEntry, len(src)) + for lang, text := range src { + cloned[lang] = text + } + return cloned +} diff --git a/l10n_test.go b/l10n_test.go new file mode 100644 index 0000000..b2fd1a6 --- /dev/null +++ b/l10n_test.go @@ -0,0 +1,77 @@ +package laniakea + +import ( + "fmt" + "sync" + "testing" +) + +func TestL10nTranslateUsesFallbackAndKey(t *testing.T) { + l10n := NewL10n("en"). + AddDictEntry("greeting", DictEntry{"en": "Hello", "ru": "Privet"}). + AddDictEntry("partial", DictEntry{"ru": "Tolko ru"}) + + tests := []struct { + name string + lang string + key string + want string + }{ + {name: "exact match", lang: "ru", key: "greeting", want: "Privet"}, + {name: "fallback language", lang: "es", key: "greeting", want: "Hello"}, + {name: "missing fallback returns key", lang: "en", key: "partial", want: "partial"}, + {name: "unknown key returns key", lang: "en", key: "unknown", want: "unknown"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := l10n.Translate(tt.lang, tt.key); got != tt.want { + t.Fatalf("unexpected translation: got %q want %q", got, tt.want) + } + }) + } +} + +func TestL10nAddDictEntryCopiesInput(t *testing.T) { + l10n := NewL10n("en") + entry := DictEntry{"en": "Hello"} + + l10n.AddDictEntry("greeting", entry) + entry["en"] = "Mutated" + + if got := l10n.Translate("en", "greeting"); got != "Hello" { + t.Fatalf("unexpected translation after external mutation: got %q", got) + } +} + +func TestL10nZeroValueIsUsable(t *testing.T) { + var l10n L10n + + l10n.AddDictEntry("greeting", DictEntry{"en": "Hello"}) + + if got := l10n.Translate("en", "greeting"); got != "Hello" { + t.Fatalf("unexpected translation from zero-value l10n: got %q", got) + } +} + +func TestL10nConcurrentAccess(t *testing.T) { + l10n := NewL10n("en") + l10n.AddDictEntry("base", DictEntry{"en": "Hello"}) + + var wg sync.WaitGroup + for i := 0; i < 8; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + for j := 0; j < 100; j++ { + l10n.AddDictEntry(fmt.Sprintf("key-%d-%d", i, j), DictEntry{"en": "value"}) + _ = l10n.Translate("en", "base") + } + }(i) + } + wg.Wait() + + if got := l10n.Translate("en", "base"); got != "Hello" { + t.Fatalf("unexpected translation after concurrent access: got %q", got) + } +} diff --git a/msg_context.go b/msg_context.go index 43e640c..d3e6cf3 100644 --- a/msg_context.go +++ b/msg_context.go @@ -46,8 +46,7 @@ type AnswerMessage struct { ctx *MsgContext // internal back-reference } -// edit is an internal helper to edit a message's text with optional keyboard and parse mode. -// Used by Edit, EditMarkdown, EditCallback, etc. +// Internal helper for text edits with optional keyboard and parse mode. func (ctx *MsgContext) edit(messageId int, text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage { params := tgapi.EditMessageTextP{ Text: text, @@ -94,8 +93,7 @@ func (m *AnswerMessage) EditMarkdown(text string) *AnswerMessage { return m.ctx.edit(m.MessageID, text, nil, tgapi.ParseMDV2) } -// editCallback is an internal helper to edit the message associated with a callback query. -// Supports both regular callback messages and inline callback messages. +// Internal helper for editing callback-linked messages. func (ctx *MsgContext) editCallback(text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage { if ctx.CallbackMsgId == 0 && ctx.InlineMsgId == "" { ctx.Logger.Errorln("Can't edit non-callback update message") @@ -128,8 +126,7 @@ func (ctx *MsgContext) EditCallbackfMarkdown(format string, keyboard *InlineKeyb return ctx.editCallback(fmt.Sprintf(format, args...), keyboard, tgapi.ParseMDV2) } -// editPhotoText edits the caption of a photo/video message. -// Returns nil when no valid edit target is available for the current context. +// Internal helper for media-caption edits. func (ctx *MsgContext) editPhotoText(messageId int, text string, kb *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage { params := tgapi.EditMessageCaptionP{ Caption: text, @@ -187,8 +184,7 @@ func (m *AnswerMessage) EditCaptionKeyboardMarkdown(text string, kb *InlineKeybo return m.ctx.editPhotoText(m.MessageID, text, kb, tgapi.ParseMDV2) } -// answer sends a new message with optional keyboard and parse mode. -// Uses API limiter to respect Telegram rate limits per chat. +// Internal helper for message replies with optional keyboard and parse mode. func (ctx *MsgContext) answer(text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage { if ctx.Msg == nil { ctx.Logger.Errorln("Can't answer message without a message") @@ -255,7 +251,7 @@ func (ctx *MsgContext) KeyboardMarkdown(text string, keyboard *InlineKeyboard) * return ctx.answer(text, keyboard, tgapi.ParseMDV2) } -// answerPhoto sends a photo with optional caption and keyboard. +// Internal helper for photo replies with optional caption and keyboard. func (ctx *MsgContext) answerPhoto(photoId, text string, kb *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage { if ctx.Msg == nil { ctx.Logger.Errorln("Can't answer message without a message") @@ -323,7 +319,7 @@ func (ctx *MsgContext) AnswerPhotofMarkdown(photoId, template string, args ...an return ctx.answerPhoto(photoId, fmt.Sprintf(template, args...), nil, tgapi.ParseMDV2) } -// delete removes a message by ID. +// Internal helper that deletes a message by ID. func (ctx *MsgContext) delete(messageId int) { if messageId == 0 { ctx.Logger.Errorln("Can't delete message: message ID zero") @@ -354,8 +350,7 @@ func (ctx *MsgContext) CallbackDelete() { ctx.delete(ctx.CallbackMsgId) } -// answerCallbackQuery sends a response to a callback query (optional text/alert/url). -// Does nothing if CallbackQueryId is empty. +// Internal helper that answers a callback query with optional text, alert, or URL. func (ctx *MsgContext) answerCallbackQuery(url, text string, showAlert bool) { if len(ctx.CallbackQueryId) == 0 { return @@ -399,10 +394,7 @@ func (ctx *MsgContext) SendAction(action tgapi.ChatActionType) { } } -// error sends an error message to the user and logs it. -// Uses errorTemplate to format the message. -// For callbacks: sends as callback answer (no alert). -// For regular messages: sends as plain text. +// Internal helper that formats, sends, and logs an error. func (ctx *MsgContext) error(err error) { text := fmt.Sprintf(ctx.errorTemplate, err.Error()) diff --git a/plugins.go b/plugins.go index 793ef23..627ba03 100644 --- a/plugins.go +++ b/plugins.go @@ -5,10 +5,12 @@ import ( "regexp" "git.nix13.pw/scuroneko/extypes" + "git.nix13.pw/scuroneko/laniakea/tgapi" + "git.nix13.pw/scuroneko/laniakea/utils" "git.nix13.pw/scuroneko/slog" ) -// CommandValueType defines the expected type of a command argument. +// CommandValueType defines the expected type of command argument. type CommandValueType string const ( @@ -50,12 +52,12 @@ type CommandArg struct { // 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. -func NewCommandArg(text string) *CommandArg { - return &CommandArg{CommandValueAnyType, text, CommandRegexString, false} +func NewCommandArg(text string) CommandArg { + return CommandArg{CommandValueAnyType, text, CommandRegexString, false} } // SetValueType sets expected value type and switches built-in validation regexp. -func (c *CommandArg) SetValueType(t CommandValueType) *CommandArg { +func (c CommandArg) SetValueType(t CommandValueType) CommandArg { regex := CommandRegexString switch t { case CommandValueIntType: @@ -72,14 +74,14 @@ func (c *CommandArg) SetValueType(t CommandValueType) *CommandArg { // SetRequired marks this argument as required. // Returns the receiver for method chaining. -func (c *CommandArg) SetRequired() *CommandArg { +func (c CommandArg) SetRequired() CommandArg { c.required = true return c } // CommandExecutor is the function type that executes a command. // It receives the message context and a database context (generic). -type CommandExecutor[T DbContext] func(ctx *MsgContext, dbContext *T) +type CommandExecutor[T DbContext] func(ctx *MsgContext, dbContext T) // Command represents a bot command with arguments, description, and executor. // Can be registered in a Plugin and optionally skipped from auto-generation. @@ -123,9 +125,7 @@ func (c *Command[T]) SkipCommandAutoGen() *Command[T] { return c } -// validateArgs checks if the provided arguments match the command's requirements. -// Returns ErrCmdArgCountMismatch if too few arguments are provided. -// Returns ErrCmdArgRegexpMismatch if any argument fails regex validation. +// Internal helper that validates provided command arguments. func (c *Command[T]) validateArgs(args []string) error { for i := range c.args.Len() { if i >= len(args) && c.args.Get(i).required { @@ -164,6 +164,8 @@ type Plugin[T DbContext] struct { skipAutoCmd bool // If true, all commands in this plugin are excluded from auto-help logger *slog.Logger + handlers map[tgapi.UpdateType]CommandExecutor[T] + onClose func() error } @@ -176,6 +178,7 @@ func NewPlugin[T DbContext](name string) *Plugin[T] { middlewares: make(extypes.Slice[Middleware[T]], 0), skipAutoCmd: false, logger: nil, + handlers: make(map[tgapi.UpdateType]CommandExecutor[T]), } } @@ -209,6 +212,24 @@ func (p *Plugin[T]) NewPayload(exec CommandExecutor[T], command string, args ... return cmd } +// AddUpdateHandler registers a handler for a non-command update type. +// Message, channel post, and callback query updates stay on the command/payload flow. +func (p *Plugin[T]) AddUpdateHandler(t tgapi.UpdateType, handler CommandExecutor[T]) *Plugin[T] { + switch t { + case tgapi.UpdateTypeMessage, tgapi.UpdateTypeChannelPost, tgapi.UpdateTypeCallbackQuery: + if p.logger == nil { + logger := utils.CreateLogger(p.name, utils.GetLoggerLevel()) + logger.Warnf("%s can't be registred through AddUpdateHandler. Use AddPayload/NewPayload or AddCommand/NewCommand", t) + _ = logger.Close() + return p + } + p.logger.Warnf("%s can't be registred through AddUpdateHandler. Use AddPayload/NewPayload or AddCommand/NewCommand", t) + return p + } + p.handlers[t] = handler + return p +} + // AddMiddleware adds a middleware to the plugin's global middleware chain. // Middlewares are executed before any command or payload. func (p *Plugin[T]) AddMiddleware(middleware Middleware[T]) *Plugin[T] { @@ -267,10 +288,8 @@ func (p *Plugin[T]) Close() error { return errors.Join(e...) } -// executeCmd finds and executes a command by its trigger string. -// Validates arguments and runs middlewares before executor. -// On error, sends an error message to the user via ctx.error(). -func (p *Plugin[T]) executeCmd(cmd string, ctx *MsgContext, dbContext *T) { +// Internal helper that validates and executes a command handler. +func (p *Plugin[T]) executeCmd(cmd string, ctx *MsgContext, db T) { command, exists := p.commands[cmd] if !exists { ctx.error(errors.New("command not found")) @@ -284,19 +303,17 @@ func (p *Plugin[T]) executeCmd(cmd string, ctx *MsgContext, dbContext *T) { // Run command-specific middlewares for _, m := range command.middlewares { - if !m.Execute(ctx, dbContext) { + if !m.Execute(ctx, db) { return } } // Execute command - command.exec(ctx, dbContext) + command.exec(ctx, db) } -// executePayload finds and executes a payload by its callback_data string. -// Validates arguments and runs middlewares before executor. -// On error, sends an error message to the user via ctx.error(). -func (p *Plugin[T]) executePayload(payload string, ctx *MsgContext, dbContext *T) { +// Internal helper that validates and executes a payload handler. +func (p *Plugin[T]) executePayload(payload string, ctx *MsgContext, db T) { command, exists := p.payloads[payload] if !exists { ctx.error(errors.New("payload not found")) @@ -310,18 +327,17 @@ func (p *Plugin[T]) executePayload(payload string, ctx *MsgContext, dbContext *T // Run command-specific middlewares for _, m := range command.middlewares { - if !m.Execute(ctx, dbContext) { + if !m.Execute(ctx, db) { return } } // Execute payload - command.exec(ctx, dbContext) + command.exec(ctx, db) } -// executeMiddlewares runs all plugin middlewares in order. -// Returns false if any middleware returns false (blocks execution). -func (p *Plugin[T]) executeMiddlewares(ctx *MsgContext, db *T) bool { +// Internal helper that runs plugin middlewares in order. +func (p *Plugin[T]) executeMiddlewares(ctx *MsgContext, db T) bool { for _, m := range p.middlewares { if !m.Execute(ctx, db) { return false @@ -333,7 +349,7 @@ func (p *Plugin[T]) executeMiddlewares(ctx *MsgContext, db *T) bool { // MiddlewareExecutor is the function type for middleware logic. // Returns true to continue execution, false to block it. // If async, return value is ignored. -type MiddlewareExecutor[T DbContext] func(ctx *MsgContext, db *T) bool +type MiddlewareExecutor[T DbContext] func(ctx *MsgContext, db T) bool // Middleware represents a reusable execution interceptor. // Can be synchronous (blocking) or asynchronous (non-blocking). @@ -345,19 +361,19 @@ type Middleware[T DbContext] struct { } // NewMiddleware creates a new synchronous middleware. -func NewMiddleware[T DbContext](name string, executor MiddlewareExecutor[T]) *Middleware[T] { - return &Middleware[T]{name, executor, 0, false} +func NewMiddleware[T DbContext](name string, executor MiddlewareExecutor[T]) Middleware[T] { + return Middleware[T]{name, executor, 0, false} } // SetOrder sets the execution order (currently ignored). -func (m *Middleware[T]) SetOrder(order int) *Middleware[T] { +func (m Middleware[T]) SetOrder(order int) Middleware[T] { m.order = order return m } // SetAsync marks the middleware to run asynchronously. // Execution continues regardless of its return value. -func (m *Middleware[T]) SetAsync(async bool) *Middleware[T] { +func (m Middleware[T]) SetAsync(async bool) Middleware[T] { m.async = async return m } @@ -365,7 +381,7 @@ func (m *Middleware[T]) SetAsync(async bool) *Middleware[T] { // Execute runs the middleware. // If async, runs in a goroutine and returns true immediately. // Otherwise, returns the result of the executor. -func (m *Middleware[T]) Execute(ctx *MsgContext, db *T) bool { +func (m Middleware[T]) Execute(ctx *MsgContext, db T) bool { if m.async { ctx := *ctx // copy context to avoid race condition go func(ctx MsgContext) { diff --git a/plugins_test.go b/plugins_test.go index 7c7507d..8b95147 100644 --- a/plugins_test.go +++ b/plugins_test.go @@ -6,7 +6,7 @@ import ( ) func TestValidateArgsRequiresFullMatch(t *testing.T) { - intCmd := NewCommand[NoDB](func(ctx *MsgContext, db *NoDB) {}, "int", *NewCommandArg("n").SetValueType(CommandValueIntType).SetRequired()) + intCmd := NewCommand[NoDB](func(ctx *MsgContext, db NoDB) {}, "int", NewCommandArg("n").SetValueType(CommandValueIntType).SetRequired()) if err := intCmd.validateArgs([]string{"123"}); err != nil { t.Fatalf("expected valid integer argument, got %v", err) } @@ -14,7 +14,7 @@ func TestValidateArgsRequiresFullMatch(t *testing.T) { t.Fatalf("expected ErrCmdArgRegexpMismatch for partial int match, got %v", err) } - boolCmd := NewCommand[NoDB](func(ctx *MsgContext, db *NoDB) {}, "bool", *NewCommandArg("flag").SetValueType(CommandValueBoolType).SetRequired()) + boolCmd := NewCommand[NoDB](func(ctx *MsgContext, db NoDB) {}, "bool", NewCommandArg("flag").SetValueType(CommandValueBoolType).SetRequired()) if err := boolCmd.validateArgs([]string{"false"}); err != nil { t.Fatalf("expected valid bool argument, got %v", err) } @@ -25,10 +25,10 @@ func TestValidateArgsRequiresFullMatch(t *testing.T) { func TestValidateArgsEnforcesRequiredArgIndex(t *testing.T) { cmd := NewCommand[NoDB]( - func(ctx *MsgContext, db *NoDB) {}, + func(ctx *MsgContext, db NoDB) {}, "mixed", - *NewCommandArg("optional"), - *NewCommandArg("required").SetRequired(), + NewCommandArg("optional"), + NewCommandArg("required").SetRequired(), ) if err := cmd.validateArgs([]string{"only-optional"}); !errors.Is(err, ErrCmdArgCountMismatch) { diff --git a/runners.go b/runners.go index d64d3ab..dd55246 100644 --- a/runners.go +++ b/runners.go @@ -33,8 +33,8 @@ type Runner[T DbContext] struct { // // Builder methods (Onetime, Async, Timeout) can be chained to customize behavior. // DO NOT call builder methods concurrently or after Execute(). -func NewRunner[T DbContext](name string, fn RunnerFn[T]) *Runner[T] { - return &Runner[T]{ +func NewRunner[T DbContext](name string, fn RunnerFn[T]) Runner[T] { + return Runner[T]{ name: name, fn: fn, async: true, // Default: run asynchronously @@ -45,7 +45,7 @@ func NewRunner[T DbContext](name string, fn RunnerFn[T]) *Runner[T] { // Onetime 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] { +func (r Runner[T]) Onetime(onetime bool) Runner[T] { r.onetime = onetime return r } @@ -55,7 +55,7 @@ func (r *Runner[T]) Onetime(onetime bool) *Runner[T] { // If false, the runner blocks the caller during execution. // // Note: If onetime=false and async=false, the runner will be skipped with a warning. -func (r *Runner[T]) Async(async bool) *Runner[T] { +func (r Runner[T]) Async(async bool) Runner[T] { r.async = async return r } @@ -69,7 +69,7 @@ func (r *Runner[T]) Async(async bool) *Runner[T] { // // 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] { +func (r Runner[T]) Timeout(timeout time.Duration) Runner[T] { r.timeout = timeout return r } diff --git a/runners_test.go b/runners_test.go new file mode 100644 index 0000000..7e17d77 --- /dev/null +++ b/runners_test.go @@ -0,0 +1,62 @@ +package laniakea + +import ( + "context" + "sync/atomic" + "testing" + "time" + + "git.nix13.pw/scuroneko/slog" +) + +func TestExecRunnersRunsOnetimeSyncRunner(t *testing.T) { + var calls atomic.Int32 + bot := &Bot[NoDB]{ + logger: slog.CreateLogger(), + runners: []Runner[NoDB]{ + NewRunner("sync-once", func(*Bot[NoDB]) error { + calls.Add(1) + return nil + }).Onetime(true).Async(false), + }, + } + + bot.ExecRunners(context.Background()) + + if got := calls.Load(); got != 1 { + t.Fatalf("unexpected sync runner call count: %d", got) + } +} + +func TestExecRunnersStopsBackgroundRunnerOnCancel(t *testing.T) { + var calls atomic.Int32 + triggered := make(chan struct{}, 1) + ctx, cancel := context.WithCancel(context.Background()) + + bot := &Bot[NoDB]{ + logger: slog.CreateLogger(), + runners: []Runner[NoDB]{ + NewRunner("background", func(*Bot[NoDB]) error { + if calls.Add(1) == 1 { + triggered <- struct{}{} + } + return nil + }).Timeout(5 * time.Millisecond), + }, + } + + bot.ExecRunners(ctx) + + select { + case <-triggered: + case <-time.After(time.Second): + t.Fatal("background runner did not execute") + } + + cancel() + bot.runnerBgWG.Wait() + + if calls.Load() == 0 { + t.Fatal("expected background runner to be called at least once") + } +} diff --git a/tgapi/api.go b/tgapi/api.go index c251218..7a22dcb 100644 --- a/tgapi/api.go +++ b/tgapi/api.go @@ -124,6 +124,9 @@ func NewAPI(opts *APIOpts) *API { // See https://core.telegram.org/bots/api func (api *API) Close() error { api.pool.stop() + if api.client != nil { + api.client.CloseIdleConnections() + } return api.logger.Close() } @@ -149,37 +152,29 @@ type ApiResponse[R any] struct { Parameters *ResponseParameters `json:"parameters,omitempty"` } -// TelegramRequest is an internal helper struct. -// DO NOT USE NewRequest or NewRequestWithChatID — they are unsafe and discouraged. -// Instead, use explicit methods like SendMessage, GetUpdates, etc. +// TelegramRequest is a low-level Telegram API request wrapper. // -// Why? Because using generics with arbitrary types P and R leads to: -// - No compile-time validation of parameters -// - No IDE autocompletion -// - Runtime panics on malformed JSON -// - Hard-to-debug errors -// -// Recommended: Define specific methods for each Telegram method (see below). +// Prefer method-specific helpers such as SendMessage or GetUpdates. TelegramRequest +// bypasses method-specific parameter types and convenience helpers, so callers are +// responsible for using the correct method name and compatible request and response types. +// In that sense it is an unsafe escape hatch compared with the typed API surface. type TelegramRequest[R, P any] struct { method string params P chatId int64 } -// NewRequest creates an untyped TelegramRequest for the given method and params with no chat ID. +// NewRequest creates a low-level TelegramRequest with no associated chat ID. func NewRequest[R, P any](method string, params P) TelegramRequest[R, P] { return TelegramRequest[R, P]{method, params, 0} } -// NewRequestWithChatID creates an untyped TelegramRequest with an associated chat ID. +// NewRequestWithChatID creates a low-level TelegramRequest with an associated chat ID. // The chat ID is used for per-chat rate limiting. func NewRequestWithChatID[R, P any](method string, params P, chatId int64) TelegramRequest[R, P] { return TelegramRequest[R, P]{method, params, chatId} } -// doRequest performs a single HTTP request to Telegram API. -// Handles rate limiting, retries on 429, and parses responses. -// Must be called within a worker pool context if using DoWithContext. func (r TelegramRequest[R, P]) doRequest(ctx context.Context, api *API) (R, error) { var zero R reqData, err := json.Marshal(r.params) @@ -296,15 +291,13 @@ func (r TelegramRequest[R, P]) Do(api *API) (R, error) { return r.DoWithContext(context.Background(), api) } -// readBody reads and limits response body to prevent memory exhaustion. -// Telegram responses are typically small (<1MB), but we cap at 10MB. +// Internal helper that reads and caps a Telegram response body. func readBody(body io.ReadCloser) ([]byte, error) { reader := io.LimitReader(body, 10<<20) // 10 MB return io.ReadAll(reader) } -// parseBody unmarshals a Telegram API response into a typed ApiResponse. -// Only returns an error on malformed JSON; non-OK responses are left for the caller to handle. +// Internal helper that parses a typed Telegram API response body. func parseBody[R any](data []byte) (ApiResponse[R], error) { var resp ApiResponse[R] err := json.Unmarshal(data, &resp) diff --git a/tgapi/api_test.go b/tgapi/api_test.go index 5dc4491..5873223 100644 --- a/tgapi/api_test.go +++ b/tgapi/api_test.go @@ -13,6 +13,15 @@ func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return fn(req) } +type closingTransport struct { + roundTripFunc + closed bool +} + +func (t *closingTransport) CloseIdleConnections() { + t.closed = true +} + func TestAPILeavesAcceptEncodingToHTTPTransport(t *testing.T) { var gotPath string var gotAcceptEncoding string @@ -54,3 +63,28 @@ func TestAPILeavesAcceptEncodingToHTTPTransport(t *testing.T) { t.Fatalf("expected empty Accept-Encoding header, got %q", gotAcceptEncoding) } } + +func TestAPICloseClosesIdleConnections(t *testing.T) { + transport := &closingTransport{ + roundTripFunc: func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"ok":true,"result":{"id":1,"is_bot":true,"first_name":"Test"}}`)), + }, nil + }, + } + + api := NewAPI( + NewAPIOpts("token"). + SetAPIUrl("https://example.test"). + SetHTTPClient(&http.Client{Transport: transport}), + ) + + if err := api.Close(); err != nil { + t.Fatalf("Close returned error: %v", err) + } + if !transport.closed { + t.Fatal("expected Close to close idle HTTP connections") + } +} diff --git a/tgapi/attachments_methods.go b/tgapi/attachments_methods.go index 078d1d0..790cf1a 100644 --- a/tgapi/attachments_methods.go +++ b/tgapi/attachments_methods.go @@ -241,7 +241,7 @@ type SendVoiceP struct { // SendVoice sends a voice note. // See https://core.telegram.org/bots/api#sendvoice -func (api *API) SendVoice(params *SendVoiceP) (Message, error) { +func (api *API) SendVoice(params SendVoiceP) (Message, error) { req := NewRequestWithChatID[Message]("sendVoice", params, params.ChatID) return req.Do(api) } @@ -249,7 +249,7 @@ func (api *API) SendVoice(params *SendVoiceP) (Message, error) { // SendVoiceWithContext is the context-aware variant of SendVoice. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#sendvoice -func (api *API) SendVoiceWithContext(ctx context.Context, params *SendVoiceP) (Message, error) { +func (api *API) SendVoiceWithContext(ctx context.Context, params SendVoiceP) (Message, error) { req := NewRequestWithChatID[Message]("sendVoice", params, params.ChatID) return req.DoWithContext(ctx, api) } diff --git a/tgapi/attachments_types.go b/tgapi/attachments_types.go index b0d2a89..c442a15 100644 --- a/tgapi/attachments_types.go +++ b/tgapi/attachments_types.go @@ -55,12 +55,12 @@ type InputPaidMedia struct { Type InputPaidMediaType `json:"type"` Media string `json:"media"` - Cover string `json:"cover"` - StartTimestamp int64 `json:"start_timestamp"` - Width int `json:"width"` - Height int `json:"height"` - Duration int `json:"duration"` - SupportsStreaming bool `json:"supports_streaming"` + Cover *string `json:"cover,omitempty"` + StartTimestamp *int64 `json:"start_timestamp,omitempty"` + Width *int `json:"width,omitempty"` + Height *int `json:"height,omitempty"` + Duration *int `json:"duration,omitempty"` + SupportsStreaming *bool `json:"supports_streaming,omitempty"` } // PhotoSize represents one size of a photo or a file/sticker thumbnail. diff --git a/tgapi/bot_methods.go b/tgapi/bot_methods.go index 8531613..a22afd2 100644 --- a/tgapi/bot_methods.go +++ b/tgapi/bot_methods.go @@ -267,21 +267,21 @@ func (api *API) SetChatMenuButtonWithContext(ctx context.Context, params SetChat // GetChatMenuButtonP holds parameters for the getChatMenuButton method. // See https://core.telegram.org/bots/api#getchatmenubutton type GetChatMenuButtonP struct { - ChatID int64 `json:"chat_id"` + ChatID int64 `json:"chat_id,omitempty"` } // GetChatMenuButton returns the current menu button for the given chat. // See https://core.telegram.org/bots/api#getchatmenubutton -func (api *API) GetChatMenuButton(params GetChatMenuButtonP) (BaseMenuButton, error) { - req := NewRequest[BaseMenuButton]("getChatMenuButton", params) +func (api *API) GetChatMenuButton(params GetChatMenuButtonP) (MenuButton, error) { + req := NewRequest[MenuButton]("getChatMenuButton", params) return req.Do(api) } // GetChatMenuButtonWithContext is the context-aware variant of GetChatMenuButton. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#getchatmenubutton -func (api *API) GetChatMenuButtonWithContext(ctx context.Context, params GetChatMenuButtonP) (BaseMenuButton, error) { - req := NewRequest[BaseMenuButton]("getChatMenuButton", params) +func (api *API) GetChatMenuButtonWithContext(ctx context.Context, params GetChatMenuButtonP) (MenuButton, error) { + req := NewRequest[MenuButton]("getChatMenuButton", params) return req.DoWithContext(ctx, api) } diff --git a/tgapi/bot_types.go b/tgapi/bot_types.go index de660ee..fefd834 100644 --- a/tgapi/bot_types.go +++ b/tgapi/bot_types.go @@ -54,7 +54,9 @@ type BotShortDescription struct { type InputProfilePhotoType string const ( - InputProfilePhotoStaticType InputProfilePhotoType = "static" + // InputProfilePhotoStaticType identifies a static profile photo input. + InputProfilePhotoStaticType InputProfilePhotoType = "static" + // InputProfilePhotoAnimatedType identifies an animated profile photo input. InputProfilePhotoAnimatedType InputProfilePhotoType = "animated" ) @@ -75,17 +77,20 @@ type InputProfilePhoto struct { type MenuButtonType string const ( + // MenuButtonCommandsType identifies a commands menu button. MenuButtonCommandsType MenuButtonType = "commands" - MenuButtonWebAppType MenuButtonType = "web_app" - MenuButtonDefaultType MenuButtonType = "default" + // MenuButtonWebAppType identifies a web app menu button. + MenuButtonWebAppType MenuButtonType = "web_app" + // MenuButtonDefaultType identifies Telegram's default menu button. + MenuButtonDefaultType MenuButtonType = "default" ) -// BaseMenuButton represents a menu button. +// MenuButton represents a menu button. // See https://core.telegram.org/bots/api#menubutton -type BaseMenuButton struct { +type MenuButton struct { Type MenuButtonType `json:"type"` // WebApp fields (for web_app button) - Text string `json:"text"` - WebApp WebAppInfo `json:"web_app"` + Text *string `json:"text"` + WebApp *WebAppInfo `json:"web_app"` } diff --git a/tgapi/business_types.go b/tgapi/business_types.go index 56a5f9f..e667413 100644 --- a/tgapi/business_types.go +++ b/tgapi/business_types.go @@ -72,7 +72,9 @@ type BusinessMessagesDeleted struct { type InputStoryContentType string const ( + // InputStoryContentPhotoType identifies photo story content. InputStoryContentPhotoType InputStoryContentType = "photo" + // InputStoryContentVideoType identifies video story content. InputStoryContentVideoType InputStoryContentType = "video" ) @@ -106,10 +108,15 @@ type StoryAreaPosition struct { type StoryAreaTypeType string const ( - StoryAreaTypeLocationType StoryAreaTypeType = "location" - StoryAreaTypeReactionType StoryAreaTypeType = "suggested_reaction" - StoryAreaTypeLinkType StoryAreaTypeType = "link" - StoryAreaTypeWeatherType StoryAreaTypeType = "weather" + // StoryAreaTypeLocationType identifies a location story area. + StoryAreaTypeLocationType StoryAreaTypeType = "location" + // StoryAreaTypeReactionType identifies a suggested reaction story area. + StoryAreaTypeReactionType StoryAreaTypeType = "suggested_reaction" + // StoryAreaTypeLinkType identifies a link story area. + StoryAreaTypeLinkType StoryAreaTypeType = "link" + // StoryAreaTypeWeatherType identifies a weather story area. + StoryAreaTypeWeatherType StoryAreaTypeType = "weather" + // StoryAreaTypeUniqueGiftType identifies a unique gift story area. StoryAreaTypeUniqueGiftType StoryAreaTypeType = "unique_gift" ) diff --git a/tgapi/chat_types.go b/tgapi/chat_types.go index a1d795b..2bdda56 100644 --- a/tgapi/chat_types.go +++ b/tgapi/chat_types.go @@ -17,10 +17,14 @@ type Chat struct { type ChatType string const ( - ChatTypePrivate ChatType = "private" - ChatTypeGroup ChatType = "group" + // ChatTypePrivate identifies a private chat. + ChatTypePrivate ChatType = "private" + // ChatTypeGroup identifies a basic group chat. + ChatTypeGroup ChatType = "group" + // ChatTypeSupergroup identifies a supergroup chat. ChatTypeSupergroup ChatType = "supergroup" - ChatTypeChannel ChatType = "channel" + // ChatTypeChannel identifies a channel chat. + ChatTypeChannel ChatType = "channel" ) // ChatFullInfo contains full information about a chat. @@ -143,12 +147,18 @@ type ChatInviteLink struct { type ChatMemberStatusType string const ( - ChatMemberStatusOwner ChatMemberStatusType = "owner" + // ChatMemberStatusOwner identifies a chat owner. + ChatMemberStatusOwner ChatMemberStatusType = "owner" + // ChatMemberStatusAdministrator identifies a chat administrator. ChatMemberStatusAdministrator ChatMemberStatusType = "administrator" - ChatMemberStatusMember ChatMemberStatusType = "member" - ChatMemberStatusRestricted ChatMemberStatusType = "restricted" - ChatMemberStatusLeft ChatMemberStatusType = "left" - ChatMemberStatusBanned ChatMemberStatusType = "kicked" + // ChatMemberStatusMember identifies a regular member. + ChatMemberStatusMember ChatMemberStatusType = "member" + // ChatMemberStatusRestricted identifies a restricted member. + ChatMemberStatusRestricted ChatMemberStatusType = "restricted" + // ChatMemberStatusLeft identifies a user who left the chat. + ChatMemberStatusLeft ChatMemberStatusType = "left" + // ChatMemberStatusBanned identifies a banned user. + ChatMemberStatusBanned ChatMemberStatusType = "kicked" ) // ChatMember contains information about one member of a chat. diff --git a/tgapi/errors.go b/tgapi/errors.go index a7e83da..6c142ae 100644 --- a/tgapi/errors.go +++ b/tgapi/errors.go @@ -2,7 +2,14 @@ package tgapi import "errors" +// ErrRateLimit reports that a request exceeded the configured rate limiter. var ErrRateLimit = errors.New("rate limit exceeded") + +// ErrPoolUnexpected reports an unexpected result type returned from the worker pool. var ErrPoolUnexpected = errors.New("unexpected response from pool") + +// ErrPoolQueueFull reports that the internal request queue is full. var ErrPoolQueueFull = errors.New("worker pool queue full") + +// ErrPoolStopped reports that a request was submitted after the worker pool stopped. var ErrPoolStopped = errors.New("worker pool stopped") diff --git a/tgapi/messages_types.go b/tgapi/messages_types.go index 6ef5fe9..237b156 100644 --- a/tgapi/messages_types.go +++ b/tgapi/messages_types.go @@ -45,9 +45,9 @@ type Message struct { Text string `json:"text"` - Photo extypes.Slice[*PhotoSize] `json:"photo,omitempty"` - Caption string `json:"caption,omitempty"` - CaptionEntities []MessageEntity `json:"caption_entities,omitempty"` + Photo extypes.Slice[PhotoSize] `json:"photo,omitempty"` + Caption string `json:"caption,omitempty"` + CaptionEntities []MessageEntity `json:"caption_entities,omitempty"` Date int `json:"date"` EditDate int `json:"edit_date"` @@ -77,26 +77,46 @@ type MaybeInaccessibleMessage interface{ Message | InaccessibleMessage } type MessageEntityType string const ( - MessageEntityMention MessageEntityType = "mention" - MessageEntityHashtag MessageEntityType = "hashtag" - MessageEntityCashtag MessageEntityType = "cashtag" - MessageEntityBotCommand MessageEntityType = "bot_command" - MessageEntityUrl MessageEntityType = "url" - MessageEntityEmail MessageEntityType = "email" - MessageEntityPhoneNumber MessageEntityType = "phone_number" - MessageEntityBold MessageEntityType = "bold" - MessageEntityItalic MessageEntityType = "italic" - MessageEntityUnderline MessageEntityType = "underline" - MessageEntityStrike MessageEntityType = "strikethrough" - MessageEntitySpoiler MessageEntityType = "spoiler" - MessageEntityBlockquote MessageEntityType = "blockquote" + // MessageEntityMention identifies an @mention entity. + MessageEntityMention MessageEntityType = "mention" + // MessageEntityHashtag identifies a hashtag entity. + MessageEntityHashtag MessageEntityType = "hashtag" + // MessageEntityCashtag identifies a cashtag entity. + MessageEntityCashtag MessageEntityType = "cashtag" + // MessageEntityBotCommand identifies a bot command entity. + MessageEntityBotCommand MessageEntityType = "bot_command" + // MessageEntityUrl identifies a URL entity. + MessageEntityUrl MessageEntityType = "url" + // MessageEntityEmail identifies an email entity. + MessageEntityEmail MessageEntityType = "email" + // MessageEntityPhoneNumber identifies a phone number entity. + MessageEntityPhoneNumber MessageEntityType = "phone_number" + // MessageEntityBold identifies bold text. + MessageEntityBold MessageEntityType = "bold" + // MessageEntityItalic identifies italic text. + MessageEntityItalic MessageEntityType = "italic" + // MessageEntityUnderline identifies underlined text. + MessageEntityUnderline MessageEntityType = "underline" + // MessageEntityStrike identifies strikethrough text. + MessageEntityStrike MessageEntityType = "strikethrough" + // MessageEntitySpoiler identifies spoiler text. + MessageEntitySpoiler MessageEntityType = "spoiler" + // MessageEntityBlockquote identifies a blockquote entity. + MessageEntityBlockquote MessageEntityType = "blockquote" + // MessageEntityExpandableBlockquote identifies an expandable blockquote entity. MessageEntityExpandableBlockquote MessageEntityType = "expandable_blockquote" - MessageEntityCode MessageEntityType = "code" - MessageEntityPre MessageEntityType = "pre" - MessageEntityTextLink MessageEntityType = "text_link" - MessageEntityTextMention MessageEntityType = "text_mention" - MessageEntityCustomEmoji MessageEntityType = "custom_emoji" - MessageEntityDateTime MessageEntityType = "date_time" + // MessageEntityCode identifies inline code. + MessageEntityCode MessageEntityType = "code" + // MessageEntityPre identifies a preformatted block. + MessageEntityPre MessageEntityType = "pre" + // MessageEntityTextLink identifies linked text. + MessageEntityTextLink MessageEntityType = "text_link" + // MessageEntityTextMention identifies a text mention. + MessageEntityTextMention MessageEntityType = "text_mention" + // MessageEntityCustomEmoji identifies a custom emoji entity. + MessageEntityCustomEmoji MessageEntityType = "custom_emoji" + // MessageEntityDateTime identifies a date-time entity. + MessageEntityDateTime MessageEntityType = "date_time" ) // MessageEntity represents one special entity in a text message. @@ -121,12 +141,12 @@ type ReplyParameters struct { MessageID int `json:"message_id"` ChatID int64 `json:"chat_id,omitempty"` - AllowSendingWithoutReply bool `json:"allow_sending_without_reply,omitempty"` - Quote string `json:"quote,omitempty"` - QuoteParsingMode string `json:"quote_parsing_mode,omitempty"` - QuoteEntities []*MessageEntity `json:"quote_entities,omitempty"` - QuotePosition int `json:"quote_position,omitempty"` - ChecklistTaskID int `json:"checklist_task_id,omitempty"` + AllowSendingWithoutReply bool `json:"allow_sending_without_reply,omitempty"` + Quote string `json:"quote,omitempty"` + QuoteParsingMode string `json:"quote_parsing_mode,omitempty"` + QuoteEntities []MessageEntity `json:"quote_entities,omitempty"` + QuotePosition int `json:"quote_position,omitempty"` + ChecklistTaskID int `json:"checklist_task_id,omitempty"` } // LinkPreviewOptions describes the options used for link preview generation. @@ -166,8 +186,11 @@ type InlineKeyboardMarkup struct { type KeyboardButtonStyle string const ( - KeyboardButtonStyleDanger KeyboardButtonStyle = "danger" + // KeyboardButtonStyleDanger marks a destructive keyboard button. + KeyboardButtonStyleDanger KeyboardButtonStyle = "danger" + // KeyboardButtonStyleSuccess marks a confirmatory keyboard button. KeyboardButtonStyleSuccess KeyboardButtonStyle = "success" + // KeyboardButtonStylePrimary marks a primary keyboard button. KeyboardButtonStylePrimary KeyboardButtonStyle = "primary" ) @@ -255,32 +278,34 @@ type CallbackQuery struct { // InputPollOption contains information about one answer option in a poll to be sent. // See https://core.telegram.org/bots/api#inputpolloption type InputPollOption struct { - Text string `json:"text"` - TextParseMode ParseMode `json:"text_parse_mode,omitempty"` - TextEntities []*MessageEntity `json:"text_entities,omitempty"` + Text string `json:"text"` + TextParseMode ParseMode `json:"text_parse_mode,omitempty"` + TextEntities []MessageEntity `json:"text_entities,omitempty"` } // PollType represents the type of a poll. type PollType string const ( + // PollTypeRegular identifies a regular poll. PollTypeRegular PollType = "regular" - PollTypeQuiz PollType = "quiz" + // PollTypeQuiz identifies a quiz poll. + PollTypeQuiz PollType = "quiz" ) // InputChecklistTask describes a task in a checklist. type InputChecklistTask struct { - ID int `json:"id"` - Text string `json:"text"` - ParseMode ParseMode `json:"parse_mode,omitempty"` - TextEntities []*MessageEntity `json:"text_entities,omitempty"` + ID int `json:"id"` + Text string `json:"text"` + ParseMode ParseMode `json:"parse_mode,omitempty"` + TextEntities []MessageEntity `json:"text_entities,omitempty"` } // InputChecklist represents a checklist to be sent. type InputChecklist struct { Title string `json:"title"` ParseMode ParseMode `json:"parse_mode,omitempty"` - TitleEntities []*MessageEntity `json:"title_entities,omitempty"` + TitleEntities []MessageEntity `json:"title_entities,omitempty"` Tasks []InputChecklistTask `json:"tasks"` OtherCanAddTasks bool `json:"other_can_add_tasks,omitempty"` OtherCanMarkTasksAsDone bool `json:"other_can_mark_tasks_as_done,omitempty"` @@ -290,14 +315,23 @@ type InputChecklist struct { type ChatActionType string const ( - ChatActionTyping ChatActionType = "typing" - ChatActionUploadPhoto ChatActionType = "upload_photo" - ChatActionUploadVideo ChatActionType = "upload_video" - ChatActionUploadVoice ChatActionType = "upload_voice" - ChatActionUploadDocument ChatActionType = "upload_document" - ChatActionChooseSticker ChatActionType = "choose_sticker" - ChatActionFindLocation ChatActionType = "find_location" + // ChatActionTyping tells Telegram the bot is typing. + ChatActionTyping ChatActionType = "typing" + // ChatActionUploadPhoto tells Telegram the bot is uploading a photo. + ChatActionUploadPhoto ChatActionType = "upload_photo" + // ChatActionUploadVideo tells Telegram the bot is uploading a video. + ChatActionUploadVideo ChatActionType = "upload_video" + // ChatActionUploadVoice tells Telegram the bot is uploading a voice message. + ChatActionUploadVoice ChatActionType = "upload_voice" + // ChatActionUploadDocument tells Telegram the bot is uploading a document. + ChatActionUploadDocument ChatActionType = "upload_document" + // ChatActionChooseSticker tells Telegram the bot is choosing a sticker. + ChatActionChooseSticker ChatActionType = "choose_sticker" + // ChatActionFindLocation tells Telegram the bot is finding a location. + ChatActionFindLocation ChatActionType = "find_location" + // ChatActionUploadVideoNote tells Telegram the bot is uploading a video note. ChatActionUploadVideoNote ChatActionType = "upload_video_note" + // ChatActionUploadVideoNone is a deprecated alias for ChatActionUploadVideoNote. ChatActionUploadVideoNone ChatActionType = ChatActionUploadVideoNote ) diff --git a/tgapi/methods.go b/tgapi/methods.go index 948575d..d9789ae 100644 --- a/tgapi/methods.go +++ b/tgapi/methods.go @@ -170,6 +170,7 @@ func (api *API) GetFileWithContext(ctx context.Context, params GetFileP) (File, // GetFileByLink downloads a file from Telegram's file server using the provided file link. // The link is usually obtained from File.FilePath. +// For large files, prefer OpenFileByLink or OpenFileByLinkWithContext to stream the response body. // See https://core.telegram.org/bots/api#file func (api *API) GetFileByLink(link string) ([]byte, error) { return api.getFileByLink(context.Background(), link) @@ -177,12 +178,38 @@ func (api *API) GetFileByLink(link string) ([]byte, error) { // GetFileByLinkWithContext is the context-aware variant of GetFileByLink. // It executes the same request but uses ctx for cancellation and deadlines. +// For large files, prefer OpenFileByLinkWithContext to stream the response body. // See https://core.telegram.org/bots/api#file func (api *API) GetFileByLinkWithContext(ctx context.Context, link string) ([]byte, error) { return api.getFileByLink(ctx, link) } +// OpenFileByLink opens a streaming response body for a file hosted on Telegram's file server. +// The caller must close the returned ReadCloser. +// See https://core.telegram.org/bots/api#file +func (api *API) OpenFileByLink(link string) (io.ReadCloser, error) { + return api.openFileByLink(context.Background(), link) +} + +// OpenFileByLinkWithContext is the context-aware variant of OpenFileByLink. +// The caller must close the returned ReadCloser. +// See https://core.telegram.org/bots/api#file +func (api *API) OpenFileByLinkWithContext(ctx context.Context, link string) (io.ReadCloser, error) { + return api.openFileByLink(ctx, link) +} + func (api *API) getFileByLink(ctx context.Context, link string) ([]byte, error) { + body, err := api.openFileByLink(ctx, link) + if err != nil { + return nil, err + } + defer func() { + _ = body.Close() + }() + return io.ReadAll(body) +} + +func (api *API) openFileByLink(ctx context.Context, link string) (io.ReadCloser, error) { methodPrefix := "" if api.useTestServer { methodPrefix = "/test" @@ -199,15 +226,15 @@ func (api *API) getFileByLink(ctx context.Context, link string) ([]byte, error) if err != nil { return nil, err } - defer func() { - _ = res.Body.Close() - }() if res.StatusCode < http.StatusOK || res.StatusCode >= http.StatusMultipleChoices { + defer func() { + _ = res.Body.Close() + }() body, readErr := io.ReadAll(io.LimitReader(res.Body, 4<<10)) if readErr != nil { return nil, fmt.Errorf("unexpected status %d", res.StatusCode) } return nil, fmt.Errorf("unexpected status %d: %s", res.StatusCode, string(body)) } - return io.ReadAll(res.Body) + return res.Body, nil } diff --git a/tgapi/methods_test.go b/tgapi/methods_test.go index 86f1aba..dce8c98 100644 --- a/tgapi/methods_test.go +++ b/tgapi/methods_test.go @@ -44,6 +44,44 @@ func TestGetFileByLinkUsesConfiguredAPIURL(t *testing.T) { } } +func TestOpenFileByLinkStreamsResponseBody(t *testing.T) { + api := NewAPI( + NewAPIOpts("token"). + SetAPIUrl("https://example.test"). + SetHTTPClient(&http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader("streamed payload")), + }, nil + }), + }), + ) + defer func() { + if err := api.Close(); err != nil { + t.Fatalf("Close returned error: %v", err) + } + }() + + body, err := api.OpenFileByLink("files/report.txt") + if err != nil { + t.Fatalf("OpenFileByLink returned error: %v", err) + } + defer func() { + if err := body.Close(); err != nil { + t.Fatalf("Close returned error: %v", err) + } + }() + + data, err := io.ReadAll(body) + if err != nil { + t.Fatalf("failed to read body: %v", err) + } + if string(data) != "streamed payload" { + t.Fatalf("unexpected payload: %q", string(data)) + } +} + func TestGetFileByLinkReturnsHTTPStatusError(t *testing.T) { client := &http.Client{ Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { diff --git a/tgapi/pool.go b/tgapi/pool.go index 8c15fa4..3078eb4 100644 --- a/tgapi/pool.go +++ b/tgapi/pool.go @@ -5,44 +5,35 @@ import ( "sync" ) -// workerPool — приватная структура, управляющая пулом воркеров. -// Внешний код не может создавать или напрямую взаимодействовать с этой структурой. -// Используется только через экспортируемые методы newWorkerPool, start, stop, submit. type workerPool struct { - taskCh chan requestEnvelope // канал для принятия задач (буферизованный) - queueSize int // максимальный размер очереди - workers int // количество воркеров (горутин) - wg sync.WaitGroup // синхронизирует завершение всех воркеров при остановке - quit chan struct{} // канал для сигнала остановки - stopOnce sync.Once // гарантирует идемпотентную остановку пула - started bool // флаг, указывающий, запущен ли пул - stopped bool // флаг, указывающий, что пул остановлен - startedMu sync.Mutex // мьютекс для безопасного доступа к started + taskCh chan requestEnvelope + queueSize int + workers int + wg sync.WaitGroup + quit chan struct{} + stopOnce sync.Once + started bool + stopped bool + startedMu sync.Mutex } -// requestEnvelope — приватная структура, инкапсулирующая задачу и канал для результата. -// Используется только внутри пакета для передачи задач воркерам. type requestEnvelope struct { - ctx context.Context // контекст конкретной задачи - doFunc func(context.Context) (any, error) // функция, выполняющая запрос - resultCh chan requestResult // канал, через который воркер вернёт результат + ctx context.Context + doFunc func(context.Context) (any, error) + resultCh chan requestResult } -// requestResult — приватная структура, представляющая результат выполнения задачи. -// Внешний код получает его через канал, но не знает структуры — только через <-chan requestResult. type requestResult struct { - value any // значение, возвращённое задачей - err error // ошибка, если возникла + value any + err error } -// newWorkerPool создаёт новый пул воркеров с заданным количеством горутин и размером очереди. -// Это единственный способ создать workerPool — внешний код не может создать его напрямую. func newWorkerPool(workers int, queueSize int) *workerPool { if workers <= 0 { - workers = 1 // защита от некорректных значений + workers = 1 } if queueSize <= 0 { - queueSize = 100 // разумный дефолт + queueSize = 100 } return &workerPool{ @@ -53,43 +44,32 @@ func newWorkerPool(workers int, queueSize int) *workerPool { } } -// start запускает воркеры (горутины), которые будут обрабатывать задачи из очереди. -// Метод идемпотентен: если пул уже запущен — ничего не делает. -// Должен вызываться перед первым вызовом submit. func (p *workerPool) start() { p.startedMu.Lock() defer p.startedMu.Unlock() if p.started { - return // уже запущен — ничего не делаем + return } p.started = true - // Запускаем воркеры — каждый будет обрабатывать задачи в бесконечном цикле for i := 0; i < p.workers; i++ { p.wg.Add(1) - go p.worker() // запускаем горутину + go p.worker() } } -// stop останавливает пул воркеров. -// Отправляет сигнал остановки через quit-канал и ждёт завершения всех активных задач. -// Безопасно вызывать многократно — после остановки повторные вызовы не имеют эффекта. func (p *workerPool) stop() { p.stopOnce.Do(func() { p.startedMu.Lock() p.stopped = true p.started = false - close(p.quit) // сигнал для всех воркеров — выйти из цикла + close(p.quit) p.startedMu.Unlock() - p.wg.Wait() // ждём, пока все воркеры завершатся + p.wg.Wait() }) } -// submit отправляет задачу в очередь и возвращает канал, через который будет получен результат. -// Если очередь переполнена — возвращает ErrPoolQueueFull. -// Канал результата имеет буфер 1, чтобы не блокировать воркера при записи. -// Контекст используется для отмены задачи, если клиент отменил запрос до отправки. func (p *workerPool) submit(ctx context.Context, do func(context.Context) (any, error)) (<-chan requestResult, error) { p.startedMu.Lock() if p.stopped || !p.started { @@ -97,55 +77,39 @@ func (p *workerPool) submit(ctx context.Context, do func(context.Context) (any, return nil, ErrPoolStopped } - // Проверяем, не превышена ли очередь if len(p.taskCh) >= p.queueSize { p.startedMu.Unlock() return nil, ErrPoolQueueFull } - // Создаём канал для результата — буферизованный, чтобы не блокировать воркера resultCh := make(chan requestResult, 1) - // Создаём обёртку задачи envelope := requestEnvelope{ ctx: ctx, doFunc: do, resultCh: resultCh, } - // Пытаемся отправить задачу в очередь select { case <-ctx.Done(): p.startedMu.Unlock() - // Клиент отменил операцию до отправки — возвращаем ошибку отмены return nil, ctx.Err() case p.taskCh <- envelope: p.startedMu.Unlock() - // Успешно отправлено — возвращаем канал для чтения результата return resultCh, nil default: p.startedMu.Unlock() - // Очередь переполнена — не должно происходить при проверке len(p.taskCh), но на всякий случай return nil, ErrPoolQueueFull } } -// worker — приватная горутина, выполняющая задачи из очереди. -// Каждый воркер работает в бесконечном цикле, пока не получит сигнал остановки. -// При получении задачи: -// - вызывает doFunc с контекстом -// - записывает результат в resultCh -// - закрывает канал, чтобы клиент мог прочитать и завершить -// -// После закрытия quit-канала — воркер завершает работу. func (p *workerPool) worker() { - defer p.wg.Done() // уменьшаем WaitGroup при завершении горутины + defer p.wg.Done() for { select { case <-p.quit: - // Получен сигнал остановки — дренируем очередь и выходим. - // После stop() новые задачи не принимаются. + // Drain queued work after stop. No new tasks are accepted. for { select { case envelope := <-p.taskCh: @@ -162,14 +126,10 @@ func (p *workerPool) worker() { } func (p *workerPool) executeEnvelope(envelope requestEnvelope) { - // Выполняем задачу с переданным контекстом (клиентский или общий) value, err := envelope.doFunc(envelope.ctx) - - // Записываем результат в канал — не блокируем, т.к. буфер 1 envelope.resultCh <- requestResult{ value: value, err: err, } - // Закрываем канал — клиент знает, что результат пришёл и больше не будет close(envelope.resultCh) } diff --git a/tgapi/pool_test.go b/tgapi/pool_test.go new file mode 100644 index 0000000..130a8f5 --- /dev/null +++ b/tgapi/pool_test.go @@ -0,0 +1,62 @@ +package tgapi + +import ( + "context" + "errors" + "testing" +) + +func TestWorkerPoolSubmitAfterStop(t *testing.T) { + pool := newWorkerPool(1, 1) + pool.start() + pool.stop() + + if _, err := pool.submit(context.Background(), func(context.Context) (any, error) { + return nil, nil + }); !errors.Is(err, ErrPoolStopped) { + t.Fatalf("expected ErrPoolStopped, got %v", err) + } +} + +func TestWorkerPoolQueueFull(t *testing.T) { + pool := newWorkerPool(1, 1) + pool.start() + defer pool.stop() + + started := make(chan struct{}) + release := make(chan struct{}) + + firstResult, err := pool.submit(context.Background(), func(context.Context) (any, error) { + close(started) + <-release + return "first", nil + }) + if err != nil { + t.Fatalf("first submit returned error: %v", err) + } + <-started + + secondResult, err := pool.submit(context.Background(), func(context.Context) (any, error) { + return "second", nil + }) + if err != nil { + t.Fatalf("second submit returned error: %v", err) + } + + if _, err := pool.submit(context.Background(), func(context.Context) (any, error) { + return "third", nil + }); !errors.Is(err, ErrPoolQueueFull) { + t.Fatalf("expected ErrPoolQueueFull, got %v", err) + } + + close(release) + + first := <-firstResult + if first.err != nil || first.value != "first" { + t.Fatalf("unexpected first result: %+v", first) + } + second := <-secondResult + if second.err != nil || second.value != "second" { + t.Fatalf("unexpected second result: %+v", second) + } +} diff --git a/tgapi/types.go b/tgapi/types.go index d96632b..ba3bd22 100644 --- a/tgapi/types.go +++ b/tgapi/types.go @@ -6,6 +6,9 @@ import "encoding/json" type UpdateType string const ( + // UpdateTypeUnknown marks an update whose payload does not match a known Telegram update kind. + UpdateTypeUnknown UpdateType = "unknown" + // UpdateTypeMessage is a regular message update. UpdateTypeMessage UpdateType = "message" // UpdateTypeEditedMessage is an edited message update. @@ -27,8 +30,6 @@ const ( UpdateTypeEditedBusinessMessage UpdateType = "edited_business_message" // UpdateTypeDeletedBusinessMessages is a deleted business messages update. UpdateTypeDeletedBusinessMessages UpdateType = "deleted_business_messages" - // UpdateTypeDeletedBusinessMessage is kept as a backward-compatible alias. - UpdateTypeDeletedBusinessMessage UpdateType = UpdateTypeDeletedBusinessMessages // UpdateTypeInlineQuery is an inline query update. UpdateTypeInlineQuery UpdateType = "inline_query" @@ -61,6 +62,8 @@ const ( // Update represents an incoming update from Telegram. // See https://core.telegram.org/bots/api#update type Update struct { + Type UpdateType `json:"-"` + UpdateID int `json:"update_id"` Message *Message `json:"message,omitempty"` EditedMessage *Message `json:"edited_message,omitempty"` @@ -71,7 +74,6 @@ type Update struct { BusinessMessage *Message `json:"business_message,omitempty"` EditedBusinessMessage *Message `json:"edited_business_message,omitempty"` DeletedBusinessMessages *BusinessMessagesDeleted `json:"deleted_business_messages,omitempty"` - DeletedBusinessMessage *BusinessMessagesDeleted `json:"-"` MessageReaction *MessageReactionUpdated `json:"message_reaction,omitempty"` MessageReactionCount *MessageReactionCountUpdated `json:"message_reaction_count,omitempty"` @@ -91,33 +93,72 @@ type Update struct { RemovedChatBoost *ChatBoostRemoved `json:"removed_chat_boost,omitempty"` } -func (u *Update) syncDeletedBusinessMessages() { - if u.DeletedBusinessMessages != nil { - u.DeletedBusinessMessage = u.DeletedBusinessMessages - return - } - if u.DeletedBusinessMessage != nil { - u.DeletedBusinessMessages = u.DeletedBusinessMessage - } -} - -// UnmarshalJSON keeps the deprecated DeletedBusinessMessage alias in sync. +// UnmarshalJSON decodes an update and derives its Type from the populated payload field. func (u *Update) UnmarshalJSON(data []byte) error { - type alias Update - var aux alias + type Alias Update + + var aux Alias if err := json.Unmarshal(data, &aux); err != nil { return err } - *u = Update(aux) - u.syncDeletedBusinessMessages() - return nil -} -// MarshalJSON emits the canonical deleted_business_messages field. -func (u Update) MarshalJSON() ([]byte, error) { - u.syncDeletedBusinessMessages() - type alias Update - return json.Marshal(alias(u)) + *u = Update(aux) + + switch { + case u.Message != nil: + u.Type = UpdateTypeMessage + case u.EditedMessage != nil: + u.Type = UpdateTypeEditedMessage + case u.ChannelPost != nil: + u.Type = UpdateTypeChannelPost + case u.EditedChannelPost != nil: + u.Type = UpdateTypeEditedChannelPost + + case u.BusinessConnection != nil: + u.Type = UpdateTypeBusinessConnection + case u.BusinessMessage != nil: + u.Type = UpdateTypeBusinessMessage + case u.EditedBusinessMessage != nil: + u.Type = UpdateTypeEditedBusinessMessage + case u.DeletedBusinessMessages != nil: + u.Type = UpdateTypeDeletedBusinessMessages + case u.MessageReaction != nil: + u.Type = UpdateTypeMessageReaction + case u.MessageReactionCount != nil: + u.Type = UpdateTypeMessageReactionCount + + case u.InlineQuery != nil: + u.Type = UpdateTypeInlineQuery + case u.ChosenInlineResult != nil: + u.Type = UpdateTypeChosenInlineResult + case u.CallbackQuery != nil: + u.Type = UpdateTypeCallbackQuery + case u.ShippingQuery != nil: + u.Type = UpdateTypeShippingQuery + case u.PreCheckoutQuery != nil: + u.Type = UpdateTypePreCheckoutQuery + case u.PurchasedPaidMedia != nil: + u.Type = UpdateTypePurchasedPaidMedia + + case u.Poll != nil: + u.Type = UpdateTypePoll + case u.PollAnswer != nil: + u.Type = UpdateTypePollAnswer + case u.MyChatMember != nil: + u.Type = UpdateTypeMyChatMember + case u.ChatMember != nil: + u.Type = UpdateTypeChatMember + case u.ChatJoinRequest != nil: + u.Type = UpdateTypeChatJoinRequest + case u.ChatBoost != nil: + u.Type = UpdateTypeChatBoost + case u.RemovedChatBoost != nil: + u.Type = UpdateTypeRemovedChatBoost + default: + u.Type = UpdateTypeUnknown + } + + return nil } // InlineQuery represents an incoming inline query. @@ -351,19 +392,19 @@ type GiftBackground struct { // Gift represents a gift that can be sent. type Gift struct { - ID string `json:"id"` - Sticker Sticker `json:"sticker"` - StarCount int `json:"star_count"` - UpdateStarCount *int `json:"update_star_count,omitempty"` - IsPremium *bool `json:"is_premium,omitempty"` - HasColors *bool `json:"has_colors,omitempty"` - TotalCount *int `json:"total_count,omitempty"` - RemainingCount *int `json:"remaining_count,omitempty"` - PersonalTotalCount *int `json:"personal_total_count,omitempty"` - PersonalRemainingCount *int `json:"personal_remaining_count,omitempty"` - Background GiftBackground `json:"background,omitempty"` - UniqueGiftVariantColor *int `json:"unique_gift_variant_color,omitempty"` - PublisherChat *Chat `json:"publisher_chat,omitempty"` + ID string `json:"id"` + Sticker Sticker `json:"sticker"` + StarCount int `json:"star_count"` + UpdateStarCount *int `json:"update_star_count,omitempty"` + IsPremium *bool `json:"is_premium,omitempty"` + HasColors *bool `json:"has_colors,omitempty"` + TotalCount *int `json:"total_count,omitempty"` + RemainingCount *int `json:"remaining_count,omitempty"` + PersonalTotalCount *int `json:"personal_total_count,omitempty"` + PersonalRemainingCount *int `json:"personal_remaining_count,omitempty"` + Background *GiftBackground `json:"background,omitempty"` + UniqueGiftVariantColor *int `json:"unique_gift_variant_color,omitempty"` + PublisherChat *Chat `json:"publisher_chat,omitempty"` } // Gifts represents a list of gifts. @@ -375,8 +416,10 @@ type Gifts struct { type OwnedGiftType string const ( + // OwnedGiftRegularType identifies a regular owned gift. OwnedGiftRegularType OwnedGiftType = "regular" - OwnedGiftUniqueType OwnedGiftType = "unique" + // OwnedGiftUniqueType identifies a unique owned gift. + OwnedGiftUniqueType OwnedGiftType = "unique" ) // OwnedGift represents a gift owned by a user or chat. @@ -388,7 +431,7 @@ type OwnedGift struct { // Fields specific to "regular" type Gift Gift `json:"gift"` - SenderUser User `json:"sender_user,omitempty"` + SenderUser *User `json:"sender_user,omitempty"` Text string `json:"text,omitempty"` Entities []MessageEntity `json:"entities,omitempty"` IsPrivate *bool `json:"is_private,omitempty"` diff --git a/tgapi/types_test.go b/tgapi/types_test.go index 5b18efc..bbdada5 100644 --- a/tgapi/types_test.go +++ b/tgapi/types_test.go @@ -6,41 +6,66 @@ import ( "testing" ) -func TestUpdateDeletedBusinessMessagesUnmarshalSetsAlias(t *testing.T) { - var update Update - err := json.Unmarshal([]byte(`{ - "update_id": 1, - "deleted_business_messages": { - "business_connection_id": "conn", - "chat": {"id": 42, "type": "private"}, - "message_ids": [3, 5] - } - }`), &update) - if err != nil { - t.Fatalf("Unmarshal returned error: %v", err) +func TestUpdateUnmarshalSetsType(t *testing.T) { + tests := []struct { + name string + body string + want UpdateType + }{ + { + name: "deleted business messages", + body: `{ + "update_id": 1, + "deleted_business_messages": { + "business_connection_id": "conn", + "chat": {"id": 42, "type": "private"}, + "message_ids": [3, 5] + } + }`, + want: UpdateTypeDeletedBusinessMessages, + }, + { + name: "callback query", + body: `{ + "update_id": 2, + "callback_query": { + "id": "cb", + "from": {"id": 1, "is_bot": false, "first_name": "Test"}, + "chat_instance": "instance", + "data": "payload" + } + }`, + want: UpdateTypeCallbackQuery, + }, + { + name: "unknown", + body: `{"update_id":3}`, + want: UpdateTypeUnknown, + }, } - if update.DeletedBusinessMessages == nil { - t.Fatal("expected DeletedBusinessMessages to be populated") - } - if update.DeletedBusinessMessage == nil { - t.Fatal("expected deprecated DeletedBusinessMessage alias to be populated") - } - if update.DeletedBusinessMessages != update.DeletedBusinessMessage { - t.Fatal("expected deleted business message fields to share the same payload") - } - if got := update.DeletedBusinessMessages.MessageIDs; len(got) != 2 || got[0] != 3 || got[1] != 5 { - t.Fatalf("unexpected message ids: %v", got) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var update Update + if err := json.Unmarshal([]byte(tt.body), &update); err != nil { + t.Fatalf("Unmarshal returned error: %v", err) + } + if update.Type != tt.want { + t.Fatalf("unexpected update type: got %q want %q", update.Type, tt.want) + } + }) } } -func TestUpdateMarshalUsesCanonicalDeletedBusinessMessagesField(t *testing.T) { +func TestUpdateMarshalOmitsSyntheticTypeField(t *testing.T) { update := Update{ UpdateID: 1, - DeletedBusinessMessage: &BusinessMessagesDeleted{ - BusinessConnectionID: "conn", - Chat: Chat{ID: 42, Type: string(ChatTypePrivate)}, - MessageIDs: []int{7}, + Type: UpdateTypeCallbackQuery, + CallbackQuery: &CallbackQuery{ + ID: "cb", + From: User{ID: 1, FirstName: "Test"}, + ChatInstance: "instance", + Data: "payload", }, } @@ -50,11 +75,8 @@ func TestUpdateMarshalUsesCanonicalDeletedBusinessMessagesField(t *testing.T) { } got := string(data) - if !strings.Contains(got, `"deleted_business_messages"`) { - t.Fatalf("expected canonical deleted_business_messages field, got %s", got) - } - if strings.Contains(got, `"deleted_business_message"`) { - t.Fatalf("unexpected singular deleted_business_message field, got %s", got) + if strings.Contains(got, `"type"`) { + t.Fatalf("unexpected synthetic type field, got %s", got) } } @@ -66,4 +88,7 @@ func TestUpdateShippingQueryIsNilWhenAbsent(t *testing.T) { if update.ShippingQuery != nil { t.Fatalf("expected ShippingQuery to be nil, got %+v", update.ShippingQuery) } + if update.Type != UpdateTypeUnknown { + t.Fatalf("expected UpdateTypeUnknown, got %q", update.Type) + } } diff --git a/tgapi/uploader_api.go b/tgapi/uploader_api.go index 7e87723..9692179 100644 --- a/tgapi/uploader_api.go +++ b/tgapi/uploader_api.go @@ -82,8 +82,12 @@ func (u *Uploader) Close() error { return u.logger.Close() } // See https://core.telegram.org/bots/api func (u *Uploader) GetLogger() *slog.Logger { return u.logger } -// UploaderRequest is a multipart file upload request to the Telegram API. -// Use NewUploaderRequest or NewUploaderRequestWithChatID to construct one. +// UploaderRequest is a low-level multipart upload request wrapper. +// +// Prefer method-specific helpers such as SendPhoto or SetWebhook. UploaderRequest +// is intended for advanced use cases where callers manage the method name, files, +// and request/response types themselves. In that sense it is an unsafe escape +// hatch compared with the typed uploader API. type UploaderRequest[R, P any] struct { method string files []UploaderFile @@ -91,16 +95,17 @@ type UploaderRequest[R, P any] struct { chatId int64 } -// NewUploaderRequest creates a new multipart upload request with no associated chat ID. +// NewUploaderRequest creates a low-level multipart upload request with no associated chat ID. func NewUploaderRequest[R, P any](method string, params P, files ...UploaderFile) UploaderRequest[R, P] { return UploaderRequest[R, P]{method: method, files: files, params: params, chatId: 0} } -// NewUploaderRequestWithChatID creates a new multipart upload request with an associated chat ID. +// NewUploaderRequestWithChatID creates a low-level multipart upload request with an associated chat ID. // The chat ID is used for per-chat rate limiting. func NewUploaderRequestWithChatID[R, P any](method string, params P, chatId int64, files ...UploaderFile) UploaderRequest[R, P] { return UploaderRequest[R, P]{method: method, files: files, params: params, chatId: chatId} } + func (r UploaderRequest[R, P]) doRequest(ctx context.Context, up *Uploader) (R, error) { var zero R @@ -205,8 +210,7 @@ func (r UploaderRequest[R, P]) Do(up *Uploader) (R, error) { return r.DoWithContext(context.Background(), up) } -// prepareMultipart builds a multipart form body from the given files and params. -// Params are encoded via utils.Encode. The writer boundary is finalized before returning. +// Internal helper that builds a finalized multipart body from files and params. func prepareMultipart[P any](files []UploaderFile, params P) (*bytes.Buffer, string, error) { buf := bytes.NewBuffer(nil) w := multipart.NewWriter(buf) @@ -239,8 +243,7 @@ func prepareMultipart[P any](files []UploaderFile, params P) (*bytes.Buffer, str return buf, w.FormDataContentType(), nil } -// uploaderTypeByExt infers the Telegram upload field name from a file extension. -// Falls back to UploaderDocumentType for unrecognized extensions. +// Internal helper that infers an upload field name from a file extension. func uploaderTypeByExt(filename string) UploaderFileType { ext := strings.ToLower(filepath.Ext(filename)) switch ext { diff --git a/utils.go b/utils.go index 7127692..7e94ce2 100644 --- a/utils.go +++ b/utils.go @@ -53,11 +53,15 @@ func EscapePunctuation(s string) string { return s } -// Version constants mirror values from the internal utils/version package. const ( + // VersionString re-exports the module version string. VersionString = utils.VersionString - VersionMajor = utils.VersionMajor - VersionMinor = utils.VersionMinor - VersionPatch = utils.VersionPatch - VersionBeta = utils.VersionBeta + // VersionMajor re-exports the module major version. + VersionMajor = utils.VersionMajor + // VersionMinor re-exports the module minor version. + VersionMinor = utils.VersionMinor + // VersionPatch re-exports the module patch version. + VersionPatch = utils.VersionPatch + // VersionBeta re-exports the module prerelease counter. + VersionBeta = utils.VersionBeta ) diff --git a/utils/limiter.go b/utils/limiter.go index 67e05cd..ed72897 100644 --- a/utils/limiter.go +++ b/utils/limiter.go @@ -9,6 +9,7 @@ import ( "golang.org/x/time/rate" ) +// ErrDropOverflow is returned when drop mode rejects a rate-limited request. var ErrDropOverflow = errors.New("drop overflow limit") // RateLimiter implements per-chat and global rate limiting with optional blocking. @@ -102,7 +103,7 @@ func (rl *RateLimiter) Wait(ctx context.Context, chatID int64) error { return chatLimiter.Wait(ctx) } -// getGlobalLimiter returns the global limiter safely under read lock. +// Internal helper that returns the global limiter under read lock. func (rl *RateLimiter) getGlobalLimiter() *rate.Limiter { rl.globalMu.RLock() defer rl.globalMu.RUnlock() @@ -190,8 +191,7 @@ func (rl *RateLimiter) Check(ctx context.Context, dropOverflow bool, chatID int6 return nil } -// waitForGlobalUnlock blocks until global cooldown expires or context is done. -// Does not check token bucket — only cooldown. +// Internal helper that waits for the global cooldown to expire. func (rl *RateLimiter) waitForGlobalUnlock(ctx context.Context) error { rl.globalMu.RLock() until := rl.globalLockUntil @@ -209,8 +209,7 @@ func (rl *RateLimiter) waitForGlobalUnlock(ctx context.Context) error { } } -// waitForChatUnlock blocks until the specified chat's cooldown expires or context is done. -// Does not check token bucket — only cooldown. +// Internal helper that waits for a chat-specific cooldown to expire. func (rl *RateLimiter) waitForChatUnlock(ctx context.Context, chatID int64) error { rl.chatMu.RLock() until, ok := rl.chatLocks[chatID] @@ -228,8 +227,7 @@ func (rl *RateLimiter) waitForChatUnlock(ctx context.Context, chatID int64) erro } } -// getChatLimiter returns the rate limiter for the given chat, creating it if needed. -// Uses 1 request per second with burst of 1 — conservative for per-user limits. +// Internal helper that returns or creates a per-chat limiter. func (rl *RateLimiter) getChatLimiter(chatID int64) *rate.Limiter { rl.chatMu.Lock() defer rl.chatMu.Unlock() diff --git a/utils/multipart.go b/utils/multipart.go index 4ee0bfe..9e02476 100644 --- a/utils/multipart.go +++ b/utils/multipart.go @@ -3,7 +3,6 @@ package utils import ( "encoding/json" "fmt" - "io" "mime/multipart" "reflect" "slices" @@ -110,6 +109,6 @@ func writeMultipartValue(w *multipart.Writer, fieldName string, value []byte) er if err != nil { return err } - _, err = io.Copy(fw, strings.NewReader(string(value))) + _, err = fw.Write(value) return err } diff --git a/utils/version.go b/utils/version.go index ac6675f..f2e6a5f 100644 --- a/utils/version.go +++ b/utils/version.go @@ -1,9 +1,14 @@ package utils const ( - VersionString = "1.0.0-rc.9" - VersionMajor = 1 - VersionMinor = 0 - VersionPatch = 0 - VersionBeta = 9 + // VersionString is the module version string. + VersionString = "1.0.0-rc.10" + // VersionMajor is the module major version. + VersionMajor = 1 + // VersionMinor is the module minor version. + VersionMinor = 0 + // VersionPatch is the module patch version. + VersionPatch = 0 + // VersionBeta is the prerelease counter for the current version. + VersionBeta = 10 )