diff --git a/CHANGELOG.md b/CHANGELOG.md index e24150d..f78f80c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,21 @@ ### Changed - Added missing godoc for the exported observer `Event` marker interface. +- Webhook execution now shares the bot's queued update-dispatch path with polling, including worker-pool delivery, runner startup, single-use run semantics, and default fallback to bot-level update type filters when webhook-specific filters are not set. +- Webhook godoc and the English and Russian READMEs now describe the bot-level webhook runtime, its single-use lifecycle, and the main `RunWebHookWithContext(...)` entry points more explicitly. +- `Bot.Close()` once again releases only local resources and no longer deletes remote webhook registrations implicitly; explicit remote webhook teardown remains opt-in through `CloseWebHook()`. +- Polling and webhook docs now explicitly state that a deployment must delete its webhook before switching from webhook delivery to long polling. +- Webhook startup now validates path shape and TLS file count before remote webhook setup, and the shared webhook mux now serves both HTTP and TLS runtime paths consistently. +- Webhook-related `tgapi` request params now use `int8` for `max_connections`, matching Telegram's `1..100` range and the higher-level webhook options API. +- Webhook startup now also requires a non-empty `SecretToken` when the optional `/status` endpoint is enabled, preventing anonymous exposure of webhook operational metadata. +- Webhook debug logging now records update metadata instead of dumping raw request bodies. +- Package docs, README guidance, and core wiki pages now align with the current public API and runtime model, including `NoData`, `SetAppData(...)`, `SetL10n(...)`, `AddAppDataLoggerWriter(...)`, shared runner startup semantics, and the webhook runtime entry points. + +### Tests +- Added regression coverage for webhook queue delivery, webhook runtime single-use behavior, runner startup in webhook mode, and default webhook `allowed_updates` inheritance from bot-level update type configuration. +- Added regression coverage proving `Bot.Close()` does not make remote webhook delete requests. +- Added webhook regression coverage for path validation, TLS file-count validation, oversized-body rejection, status-endpoint secret checks, and invalid TLS startup arguments. +- Added webhook regression coverage proving `/status` cannot be enabled without a non-empty `SecretToken`. ## v1.0.0-rc.13 diff --git a/README.md b/README.md index d8cabd4..69b3a1c 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ A lightweight, easy-to-use, and performant Telegram Bot API wrapper for Go. It s * **Built-in Rate Limiting:** Protect your bot from hitting Telegram API limits (supports `retry_after` handling). * **Context-Aware:** Pass custom application data or state contexts to your handlers. * **Configurable API:** Mix `Set...` and `Add...` helpers to configure bots clearly (for example, `bot.SetErrorTemplate(...).AddPlugins(...)`). +* **Polling and Webhook Runtime:** Run bots through long polling with `Run()` / `RunWithContext(...)` or through a bot-owned webhook server with `RunWebHookWithContext(...)`. --- @@ -116,7 +117,26 @@ func main() { 6. `SetErrorTemplate`: Sets a template for error messages. The %s placeholder is replaced by the actual error. 7. `AutoGenerateCommands`: Registers plugin-defined commands with Telegram across the supported scopes. 8. `Run()`: Starts the bot's update polling loop and returns an error if startup or polling fails. -9. A `Bot` instance is single-use. After `Run()` or `RunWithContext()` returns, create a new bot instance for the next session. +9. `RunWebHookWithContext(...)`: Starts the bot-owned webhook runtime when Telegram should deliver updates over HTTP instead of long polling. +10. A `Bot` instance is single-use. After `Run()`, `RunWithContext()`, or `RunWebHookWithContext()` returns, create a new bot instance for the next session. + +## Webhook Runtime + +Laniakea also supports a bot-owned webhook runtime through `RunWebHookWithContext(...)` and `RunWebHook(...)`. + +Use it when: +- Telegram should push updates to your HTTP endpoint instead of your bot polling for them. +- You want webhook-delivered updates to reuse the same internal queue, worker pool, runners, and single-use lifecycle as polling. +- You want Laniakea to register the webhook and own the local HTTP server. + +Production notes: +- Set `BotWebHookOpts.SecretToken` for request authentication. +- `BotWebHookOpts.SecretToken` is required when `BotWebHookOpts.UseStatusPath` is enabled. +- Keep `BotWebHookOpts.Path` specific instead of serving webhook traffic on `/`. +- If you switch an existing deployment from webhook mode to long polling, delete the webhook first with `CloseWebHook()` or `tgapi.DeleteWebhook(...)`. Telegram keeps webhook delivery active until it is removed. +- Use `RunWebHookWithContext(...)` with a cancelable context, then call `Close()` after runtime shutdown. + +See the full guide in the wiki: [Webhook Runtime](https://git.scuroneko.dev/ScuroNeko/Laniakea/wiki/Webhook-Runtime) ## 📖 Core Concepts ### Plugins @@ -271,7 +291,7 @@ func adminOnlyMiddleware(ctx *laniakea.MsgContext, db *MyDB) bool { - **Rate Limiting**: Pass a configured utils.RateLimiter via BotOpts to handle Telegram's rate limits gracefully. - **Localization**: `L10n` is safe for concurrent use once attached to the bot. - **Custom Update Handlers**: Use `plugin.AddUpdateHandler(...)` for Telegram update types that are not part of the command/payload flow. -- **Lifecycle**: `RunWithContext(...)` does not call `Close()` for you. Shut the bot down explicitly, and create a fresh `Bot` for the next run. +- **Lifecycle**: `RunWithContext(...)` and `RunWebHookWithContext(...)` do not call `Close()` for you. Shut the bot down explicitly, and create a fresh `Bot` for the next run. ## Telegram Update Handling - Commands and payloads are handled through plugins. diff --git a/README_RU.md b/README_RU.md index 5a76dd5..16852df 100644 --- a/README_RU.md +++ b/README_RU.md @@ -24,6 +24,7 @@ * **Встроенный ограничитель запросов (Rate Limiter):** Защитите бота от превышения лимитов Telegram API (с обработкой `retry_after`). * **Контекст данных:** Передавайте общие данные приложения или state в обработчики. * **Настраиваемый API:** Комбинируйте `Set...` и `Add...` helper-методы для понятной конфигурации, например `bot.SetErrorTemplate(...).AddPlugins(...)`. +* **Polling и Webhook Runtime:** Запускайте бота через long polling с `Run()` / `RunWithContext(...)` или через webhook server, которым владеет сам бот, с `RunWebHookWithContext(...)`. --- @@ -117,7 +118,26 @@ func main() { 6. `SetErrorTemplate`: Устанавливает шаблон для сообщений об ошибках. Плейсхолдер %s заменяется на текст ошибки. 7. `AutoGenerateCommands`: Регистрирует команды из плагинов в Telegram для поддерживаемых scope. 8. `Run()`: Запускает цикл опроса обновлений бота и возвращает ошибку, если старт или polling завершился неуспешно. -9. Экземпляр `Bot` одноразовый. После завершения `Run()` или `RunWithContext()` для следующего запуска создавайте новый бот. +9. `RunWebHookWithContext(...)`: Запускает bot-owned webhook runtime, когда Telegram должен доставлять update по HTTP вместо long polling. +10. Экземпляр `Bot` одноразовый. После завершения `Run()`, `RunWithContext()` или `RunWebHookWithContext()` для следующего запуска создавайте новый бот. + +## Webhook Runtime + +Laniakea также поддерживает bot-owned webhook runtime через `RunWebHookWithContext(...)` и `RunWebHook(...)`. + +Используй его, когда: +- Telegram должен сам отправлять update на твой HTTP endpoint вместо polling. +- Ты хочешь, чтобы webhook-update проходили через ту же внутреннюю очередь, тот же worker pool, тех же runners и тот же single-use lifecycle, что и polling. +- Ты хочешь, чтобы Laniakea сама регистрировала webhook и владела локальным HTTP server. + +Практические замечания: +- Задавай `BotWebHookOpts.SecretToken` для аутентификации запросов. +- Непустой `BotWebHookOpts.SecretToken` обязателен, если включён `BotWebHookOpts.UseStatusPath`. +- Используй явный `BotWebHookOpts.Path`, а не `/`. +- Если ты переводишь уже существующий deployment с webhook-режима на long polling, сначала удали webhook через `CloseWebHook()` или `tgapi.DeleteWebhook(...)`. Пока webhook не удалён, Telegram продолжает доставку через него. +- Запускай `RunWebHookWithContext(...)` с cancelable context и после остановки runtime всё равно вызывай `Close()`. + +Полное руководство есть в wiki: [Webhook Runtime](https://git.scuroneko.dev/ScuroNeko/Laniakea/wiki/Webhook-Runtime-RU) ## 📖 Основные концепции ### Плагины (Plugins) @@ -268,7 +288,7 @@ func adminOnlyMiddleware(ctx *laniakea.MsgContext, db *MyDB) bool { - **Ограничение запросов**: Передайте настроенный `utils.RateLimiter` через `BotOpts` для корректной обработки лимитов Telegram. - **Локализация**: `L10n` безопасен для конкурентного использования после подключения к боту. - **Пользовательские update handlers**: Используйте `plugin.AddUpdateHandler(...)` для Telegram update types вне command/payload flow. -- **Жизненный цикл**: `RunWithContext(...)` не вызывает `Close()` автоматически. Завершайте бот явно и создавайте новый `Bot` для следующего запуска. +- **Жизненный цикл**: `RunWithContext(...)` и `RunWebHookWithContext(...)` не вызывают `Close()` автоматически. Завершайте бот явно и создавайте новый `Bot` для следующего запуска. ## Обработка Telegram Updates - Команды и payload-ы обрабатываются через плагины. diff --git a/TODO.md b/TODO.md index 44cd369..d5bc7d9 100644 --- a/TODO.md +++ b/TODO.md @@ -12,11 +12,12 @@ Russian page: Current priority split: -- `Partial`: webhook runtime model. +- `Partial`: none. - `Ideas`: service layer and dependency graph model, plugin composition contract. Completed former high-priority items: +- `[v1.0.0-rc.14] Webhook runtime model.` - `[v1.0.0-rc.13] Observability model`: added first-class `Observer` events for update, command, payload, scene, policy, runner, polling, and centralized error flows, with safe event dispatch and regression coverage for the new runtime hooks. - `[v1.0.0-rc.13] Authorization and policy model`: added first-class `Policy[T]`, middleware integration through `RequirePolicy(...)`, plugin and bot policy registration helpers, built-in Telegram-aware policies, and composable `AllPolicies(...)`, `AnyPolicy(...)`, and `NotPolicy(...)` helpers with regression coverage. - `[v1.0.0-rc.13] Update schema contract`: documented and tested the normalized `MsgContext` update-routing contract, including routing categories and per-update field guarantees. diff --git a/bot.go b/bot.go index becf416..86d3a00 100644 --- a/bot.go +++ b/bot.go @@ -4,9 +4,6 @@ import ( "context" "errors" "fmt" - "maps" - "reflect" - "strings" "sync" "time" @@ -14,7 +11,6 @@ import ( "git.scuroneko.dev/scuroneko/laniakea/tgapi" "git.scuroneko.dev/scuroneko/laniakea/utils" "git.scuroneko.dev/scuroneko/slog" - "github.com/alitto/pond/v2" ) // AppData is the generic shared application data type injected into bots, @@ -63,7 +59,7 @@ var ( ErrNoPrefixes = errors.New("no prefixes defined") // ErrNoPlugins reports that the bot was started without any registered plugins. ErrNoPlugins = errors.New("no plugins defined") - // ErrBotAlreadyRun reports that Run or RunWithContext was called more than once. + // ErrBotAlreadyRun reports that Run, RunWithContext, or RunWebHookWithContext was called more than once. ErrBotAlreadyRun = errors.New("bot can only be run once") // ErrTokenRequired reports that BotOpts.Token was empty. @@ -81,8 +77,10 @@ var ( // - Logging and rate limiting // - Localization and draft message support // -// 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. +// Runtime accessors are safe for concurrent use. Configure the bot before Run, +// RunWithContext, or RunWebHookWithContext. +// A Bot is single-use: after Run, RunWithContext, or RunWebHookWithContext returns, +// create a new Bot for the next session. type Bot[T AppData] struct { token string debug bool @@ -94,6 +92,7 @@ type Bot[T AppData] struct { logger *slog.Logger // Main bot logger (JSON stdout + optional file) RequestLogger *slog.Logger // Optional request-level API logging + webHookLogger *slog.Logger // Webhook logger. Available only after Bot.RunWebHookWithContext. extraLoggers extypes.Slice[*slog.Logger] // API, Uploader, and custom loggers plugins []Plugin[T] // Command/event handlers @@ -233,39 +232,59 @@ func NewBot[T any](opts *BotOpts) (*Bot[T], error) { // // Close shuts down, in order: // - Registered plugins via Plugin.Close +// - Webhook logger (if initialized) // - Uploader (waits for pending uploads) // - API client internals // - RequestLogger (if enabled) // - Main logger // -// RunWithContext does not call Close automatically. The caller is responsible -// for invoking Close after RunWithContext returns to release these resources. +// RunWithContext and RunWebHookWithContext do not call Close automatically. +// The caller is responsible for invoking Close after runtime returns to release +// these resources. // // Close returns a joined error containing all shutdown failures, if any. func (bot *Bot[T]) Close() error { var e []error + logCloseErr := func(err error) { + if err == nil { + return + } + if bot.logger != nil { + bot.logger.Errorln(err) + } + e = append(e, err) + } for _, p := range bot.plugins { if err := p.Close(); err != nil { e = append(e, err) } } - if err := bot.uploader.Close(); err != nil { - bot.logger.Errorln(err) - e = append(e, err) + if bot.webHookLogger != nil { + if err := bot.webHookLogger.Close(); err != nil { + logCloseErr(err) + } + bot.webHookLogger = nil } - if err := bot.api.Close(); err != nil { - bot.logger.Errorln(err) - e = append(e, err) + if bot.uploader != nil { + if err := bot.uploader.Close(); err != nil { + logCloseErr(err) + } + } + if bot.api != nil { + if err := bot.api.Close(); err != nil { + logCloseErr(err) + } } if bot.RequestLogger != nil { if err := bot.RequestLogger.Close(); err != nil { - bot.logger.Errorln(err) - e = append(e, err) + logCloseErr(err) } } - if err := bot.logger.Close(); err != nil { - e = append(e, err) + if bot.logger != nil { + if err := bot.logger.Close(); err != nil { + e = append(e, err) + } } return errors.Join(e...) } @@ -327,6 +346,10 @@ func (bot *Bot[T]) L10n(lang, key string) string { // - Finishes processing currently queued updates // - Waits for registered runners to exit // +// If you are switching an existing deployment from webhook delivery to polling, +// delete the current webhook first with CloseWebHook or tgapi.DeleteWebhook. +// Telegram keeps webhook delivery active until the webhook is removed. +// // RunWithContext does not close API, uploader, or logger resources on return. // The caller must invoke Close after RunWithContext finishes. // @@ -346,8 +369,6 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) error { bot.ExecRunners(ctx) - bot.logger.Infoln("Bot running. Press CTRL+C to exit.") - // Start update polling in a goroutine go func() { defer func() { @@ -398,10 +419,7 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) error { retryCount = 0 for _, update := range updates { - u := update // copy loop variable to avoid race condition - select { - case bot.updateQueue <- &u: - case <-ctx.Done(): + if err := bot.enqueueUpdate(ctx, update); err != nil { return } } @@ -409,15 +427,10 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) error { } }() + bot.logger.Infoln("Bot running. Press CTRL+C to exit.") // Start worker pool for concurrent update handling - pool := pond.NewPool(bot.maxWorkers) - for update := range bot.updateQueue { - u := update // capture loop variable - pool.Submit(func() { - bot.handle(ctx, u) - }) - } - pool.Stop() // Wait for all tasks to complete and stop the pool + bot.startUpdateWorkers(ctx) + bot.runnerOnceWG.Wait() bot.runnerBgWG.Wait() return nil @@ -432,145 +445,3 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) error { func (bot *Bot[T]) Run() error { return bot.RunWithContext(context.Background()) } - -func (bot *Bot[T]) initLoggers(opts *BotOpts) { - level := slog.FATAL - if opts.Debug { - level = slog.DEBUG - } - - bot.logger = utils.CreateLogger("BOT", level) - if opts.WriteToFile { - path := fmt.Sprintf("%s/main.log", strings.TrimRight(opts.LoggerBasePath, "/")) - logger, err := utils.CreateFileLogger("BOT", level, path) - if err != nil { - bot.logger.Errorln(err) - } else { - bot.logger = logger - } - } - - if opts.UseRequestLogger { - bot.RequestLogger = utils.CreateLogger("REQUESTS", level) - if opts.WriteToFile { - path := fmt.Sprintf("%s/requests.log", strings.TrimRight(opts.LoggerBasePath, "/")) - logger, err := utils.CreateFileLogger("REQUESTS", level, path) - if err != nil { - bot.logger.Errorln(err) - } else { - bot.RequestLogger = logger - } - } - } -} - -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 shouldWarnOnValueAppData[T any]() bool { - t := reflect.TypeFor[T]() - if t == reflect.TypeFor[NoData]() { - 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 AppData](p *Plugin[T]) Plugin[T] { - cloned := Plugin[T]{ - name: p.name, - commands: make(map[string]*Command[T], len(p.commands)), - payloads: make(map[string]*Command[T], len(p.payloads)), - scenes: make(map[string]*Scene[T], len(p.scenes)), - 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, - } - - for name, command := range p.commands { - cloned.commands[name] = cloneCommand(command) - } - for name, command := range p.payloads { - cloned.payloads[name] = cloneCommand(command) - } - for name, scene := range p.scenes { - cloned.scenes[name] = cloneScene(scene) - } - maps.Copy(cloned.handlers, p.handlers) - - return cloned -} - -func cloneCommand[T AppData](command *Command[T]) *Command[T] { - if command == nil { - return nil - } - - cloned := *command - cloned.args = append(extypes.Slice[CommandArg](nil), command.args...) - cloned.middlewares = append(extypes.Slice[Middleware[T]](nil), command.middlewares...) - return &cloned -} - -func cloneScene[T AppData](scene *Scene[T]) *Scene[T] { - if scene == nil { - return nil - } - - cloned := *scene - cloned.steps = make(map[string]SceneHandler[T], len(scene.steps)) - cloned.commands = make(map[string]SceneHandler[T], len(scene.commands)) - - for name, handler := range scene.steps { - cloned.steps[name] = handler - } - for name, handler := range scene.commands { - cloned.commands[name] = handler - } - - return &cloned -} diff --git a/bot_config.go b/bot_config.go index 2be3f4c..851915c 100644 --- a/bot_config.go +++ b/bot_config.go @@ -9,7 +9,7 @@ import ( ) // AddPrefixes adds one or more command prefixes (e.g., "/", "!"). -// Must have at least one prefix before Run(). +// The bot must have at least one prefix before any runtime entry point starts. func (bot *Bot[T]) AddPrefixes(prefixes ...string) *Bot[T] { if !bot.configMutable("AddPrefixes") { return bot diff --git a/bot_register.go b/bot_register.go index 6a7a4ee..eb97890 100644 --- a/bot_register.go +++ b/bot_register.go @@ -93,7 +93,8 @@ func (bot *Bot[T]) UsePolicy(name string, policy Policy[T]) *Bot[T] { // - Metrics collection or health checks // - Scheduled tasks (e.g., daily announcements) // -// Runners are started immediately after Bot.Run() is called. +// Runners start from the bot runtime entry points, immediately after +// RunWithContext or RunWebHookWithContext begins. // // Example: // diff --git a/bot_test.go b/bot_test.go index e85c156..e37848c 100644 --- a/bot_test.go +++ b/bot_test.go @@ -278,6 +278,41 @@ func TestRunWithContextRejectsSecondRun(t *testing.T) { } } +func TestCloseDoesNotDeleteWebhook(t *testing.T) { + requests := 0 + client := &http.Client{ + Transport: pollingRoundTripFunc(func(req *http.Request) (*http.Response, error) { + requests++ + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"ok":true,"result":true}`)), + }, nil + }), + } + + api := tgapi.NewAPI( + tgapi.NewAPIOpts("token"). + SetAPIUrl("http://example.invalid"). + SetHTTPClient(client), + ) + uploader := tgapi.NewUploader(api) + + bot := &Bot[NoData]{ + logger: slog.CreateLogger(), + webHookLogger: slog.CreateLogger(), + api: api, + uploader: uploader, + } + + if err := bot.Close(); err != nil { + t.Fatalf("Close returned error: %v", err) + } + if requests != 0 { + t.Fatalf("Close performed unexpected remote requests: got %d want 0", requests) + } +} + func TestRunWithContextEmitsPollingRetryAndErrorEvents(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() diff --git a/bot_utils.go b/bot_utils.go new file mode 100644 index 0000000..c6fa9b4 --- /dev/null +++ b/bot_utils.go @@ -0,0 +1,178 @@ +package laniakea + +import ( + "context" + "fmt" + "maps" + "reflect" + "strings" + "time" + + "git.scuroneko.dev/scuroneko/extypes" + "git.scuroneko.dev/scuroneko/laniakea/tgapi" + "git.scuroneko.dev/scuroneko/laniakea/utils" + "git.scuroneko.dev/scuroneko/slog" + "github.com/alitto/pond/v2" +) + +func (bot *Bot[T]) enqueueUpdate(ctx context.Context, update tgapi.Update) error { + select { + case <-ctx.Done(): + return ctx.Err() + case bot.updateQueue <- new(update): + return nil + } +} + +func (bot *Bot[T]) startUpdateWorkers(ctx context.Context) { + pool := pond.NewPool(bot.maxWorkers) + for update := range bot.updateQueue { + u := update // capture loop variable + pool.Submit(func() { + bot.handle(ctx, u) + }) + } + pool.Stop() // Wait for all tasks to complete and stop the pool +} + +func (bot *Bot[T]) initLoggers(opts *BotOpts) { + level := slog.FATAL + if opts.Debug { + level = slog.DEBUG + } + + bot.logger = utils.CreateLogger("BOT", level) + if opts.WriteToFile { + path := fmt.Sprintf("%s/main.log", strings.TrimRight(opts.LoggerBasePath, "/")) + logger, err := utils.CreateFileLogger("BOT", level, path) + if err != nil { + bot.logger.Errorln(err) + } else { + bot.logger = logger + } + } + + if opts.UseRequestLogger { + bot.RequestLogger = utils.CreateLogger("REQUESTS", level) + if opts.WriteToFile { + path := fmt.Sprintf("%s/requests.log", strings.TrimRight(opts.LoggerBasePath, "/")) + logger, err := utils.CreateFileLogger("REQUESTS", level, path) + if err != nil { + bot.logger.Errorln(err) + } else { + bot.RequestLogger = logger + } + } + } +} + +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 shouldWarnOnValueAppData[T any]() bool { + t := reflect.TypeFor[T]() + if t == reflect.TypeFor[NoData]() { + 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 AppData](p *Plugin[T]) Plugin[T] { + cloned := Plugin[T]{ + name: p.name, + commands: make(map[string]*Command[T], len(p.commands)), + payloads: make(map[string]*Command[T], len(p.payloads)), + scenes: make(map[string]*Scene[T], len(p.scenes)), + 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, + } + + for name, command := range p.commands { + cloned.commands[name] = cloneCommand(command) + } + for name, command := range p.payloads { + cloned.payloads[name] = cloneCommand(command) + } + for name, scene := range p.scenes { + cloned.scenes[name] = cloneScene(scene) + } + maps.Copy(cloned.handlers, p.handlers) + + return cloned +} + +func cloneCommand[T AppData](command *Command[T]) *Command[T] { + if command == nil { + return nil + } + + cloned := *command + cloned.args = append(extypes.Slice[CommandArg](nil), command.args...) + cloned.middlewares = append(extypes.Slice[Middleware[T]](nil), command.middlewares...) + return &cloned +} + +func cloneScene[T AppData](scene *Scene[T]) *Scene[T] { + if scene == nil { + return nil + } + + cloned := *scene + cloned.steps = make(map[string]SceneHandler[T], len(scene.steps)) + cloned.commands = make(map[string]SceneHandler[T], len(scene.commands)) + + for name, handler := range scene.steps { + cloned.steps[name] = handler + } + for name, handler := range scene.commands { + cloned.commands[name] = handler + } + + return &cloned +} diff --git a/bot_webhook.go b/bot_webhook.go new file mode 100644 index 0000000..752e9df --- /dev/null +++ b/bot_webhook.go @@ -0,0 +1,461 @@ +package laniakea + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" + + "git.scuroneko.dev/scuroneko/laniakea/tgapi" + "git.scuroneko.dev/scuroneko/laniakea/utils" +) + +// BotWebHookOpts configures Telegram webhook registration and the local HTTP server. +type BotWebHookOpts struct { + Path string + LocalPort int + UseStatusPath bool + + URL string + Certificate []byte + IPAddress string + MaxConnections int8 + AllowedUpdates []tgapi.UpdateType + DropPendingUpdates bool + SecretToken string +} + +// NewBotWebHookOpts returns webhook options with the default path, local port, and max connections. +func NewBotWebHookOpts() *BotWebHookOpts { + return &BotWebHookOpts{ + Path: "/", + LocalPort: 8080, + MaxConnections: 40, + } +} + +// SetPath sets the local HTTP path that receives Telegram webhook requests. +func (opts *BotWebHookOpts) SetPath(path string) *BotWebHookOpts { + opts.Path = path + return opts +} + +// SetLocalPort sets the local HTTP port used by the webhook server. +func (opts *BotWebHookOpts) SetLocalPort(port int) *BotWebHookOpts { + opts.LocalPort = port + return opts +} + +// SetUseStatusPath enables or disables the optional /status endpoint. +// A non-empty SecretToken is required when this endpoint is enabled. +func (opts *BotWebHookOpts) SetUseStatusPath(use bool) *BotWebHookOpts { + opts.UseStatusPath = use + return opts +} + +// SetURL sets the public base URL Telegram should call for incoming updates. +func (opts *BotWebHookOpts) SetURL(url string) *BotWebHookOpts { + opts.URL = url + return opts +} + +// SetCertificate sets the self-signed webhook certificate bytes to upload. +func (opts *BotWebHookOpts) SetCertificate(certificate []byte) *BotWebHookOpts { + opts.Certificate = certificate + return opts +} + +// MustLoadCertificate loads a webhook certificate from disk and panics on failure. +func (opts *BotWebHookOpts) MustLoadCertificate(filename string) *BotWebHookOpts { + f, err := os.Open(filename) + if err != nil { + panic(err) + } + defer func() { + _ = f.Close() + }() + opts.Certificate, err = io.ReadAll(f) + if err != nil { + panic(err) + } + return opts +} + +// SetIPAddress sets the fixed IP address Telegram should use for webhook delivery. +func (opts *BotWebHookOpts) SetIPAddress(ip string) *BotWebHookOpts { + opts.IPAddress = ip + return opts +} + +// SetMaxConnections sets Telegram's maximum number of simultaneous webhook connections. +func (opts *BotWebHookOpts) SetMaxConnections(max int8) *BotWebHookOpts { + opts.MaxConnections = max + return opts +} + +// SetAllowedUpdates sets the Telegram update types that should be delivered to the webhook. +func (opts *BotWebHookOpts) SetAllowedUpdates(updates ...tgapi.UpdateType) *BotWebHookOpts { + opts.AllowedUpdates = append([]tgapi.UpdateType(nil), updates...) + return opts +} + +// SetDropPendingUpdates configures whether Telegram should drop pending updates while setting the webhook. +func (opts *BotWebHookOpts) SetDropPendingUpdates(drop bool) *BotWebHookOpts { + opts.DropPendingUpdates = drop + return opts +} + +// SetSecretToken sets the secret token expected in Telegram webhook requests. +// The same token is also required to access /status when that endpoint is enabled. +func (opts *BotWebHookOpts) SetSecretToken(secretToken string) *BotWebHookOpts { + opts.SecretToken = secretToken + return opts +} + +// RunWebHookWithContext registers the webhook and serves incoming updates until ctx is canceled. +// +// The bot uses the same update queue, worker pool, runner startup, and single-use lifecycle +// guarantees as RunWithContext. When opts.AllowedUpdates is empty, the bot-level update types +// configured through SetUpdateTypes/AddUpdateType are used. When UseStatusPath is enabled, +// SecretToken must be non-empty so the operational endpoint is not left public. +// +// When two TLS files are provided, the method serves HTTPS using the existing key-then-cert +// argument order. +func (bot *Bot[T]) RunWebHookWithContext(ctx context.Context, opts *BotWebHookOpts, tlsFiles ...string) error { + if opts == nil { + return errors.New("nil BotWebHookOpts") + } + if len(bot.prefixes) == 0 { + return ErrNoPrefixes + } + if len(bot.plugins) == 0 { + return ErrNoPlugins + } + if opts.URL == "" { + return errors.New("empty BotWebHookOpts.URL") + } + if opts.MaxConnections > 100 || opts.MaxConnections <= 0 { + return errors.New("BotWebHookOpts.MaxConnections must between 1 and 100") + } + if err := validateWebhookPath(opts.Path, opts.UseStatusPath); err != nil { + return err + } + if opts.UseStatusPath && opts.SecretToken == "" { + return errors.New("BotWebHookOpts.SecretToken required when status path is enabled") + } + if err := validateWebhookTLSFiles(tlsFiles); err != nil { + return err + } + + bot.webHookLogger = utils.CreateLogger("WEBHOOK", bot.GetLoggerLevel()) + if opts.SecretToken == "" { + bot.webHookLogger.Warnln("Bot webhook secret token empty. It's VERY recommended to set secret.") + } + + if opts.Certificate != nil && bot.uploader == nil { + return errors.New("bot uploader nil, but certificate set") + } + + return bot.runWebhookRuntime(ctx, func(runCtx context.Context) error { + i, err := bot.api.GetWebhookInfoWithContext(runCtx) + if err != nil { + return err + } + if i.URL == "" { + bot.webHookLogger.Warnln("API returned webhook info with empty URL. There may be a long-poll") + } else { + _, err = bot.api.DeleteWebhookWithContext(runCtx, tgapi.DeleteWebhookP{}) + if err != nil { + return err + } + bot.webHookLogger.Infof("Bot webhook deleted: %s", i.URL) + } + + allowedUpdates := bot.webhookAllowedUpdates(opts) + + var ok bool + if opts.Certificate != nil { + ok, err = bot.uploader.SetWebhookWithContext(runCtx, tgapi.UploadSetWebhookP{ + URL: fmt.Sprintf("%s%s", opts.URL, opts.Path), + IPAddress: opts.IPAddress, + MaxConnections: opts.MaxConnections, + AllowedUpdates: allowedUpdates, + DropPendingUpdates: opts.DropPendingUpdates, + SecretToken: opts.SecretToken, + }, tgapi.NewUploaderFile("certificate", opts.Certificate)) + } else { + ok, err = bot.api.SetWebhookWithContext(runCtx, tgapi.SetWebhookP{ + URL: fmt.Sprintf("%s%s", opts.URL, opts.Path), + IPAddress: opts.IPAddress, + MaxConnections: opts.MaxConnections, + AllowedUpdates: allowedUpdates, + DropPendingUpdates: opts.DropPendingUpdates, + SecretToken: opts.SecretToken, + }) + } + if err != nil { + return err + } + if !ok { + return errors.New("failed to set webhook") + } + + if len(tlsFiles) == 2 { + return bot.runWebHookTLS(runCtx, opts, tlsFiles[0], tlsFiles[1]) + } + + return bot.runWebHook(runCtx, opts) + }) +} + +// RunWebHook starts the webhook runtime with a background context. +// +// It is shorthand for RunWebHookWithContext(context.Background(), opts, tlsFiles...). +func (bot *Bot[T]) RunWebHook(opts *BotWebHookOpts, tlsFiles ...string) error { + return bot.RunWebHookWithContext(context.Background(), opts, tlsFiles...) +} + +// CloseWebHook removes the current Telegram webhook registration. +// +// It is separate from Close, which only releases local resources. +// Call it before switching a deployment from webhook delivery to polling. +func (bot *Bot[T]) CloseWebHook() error { + var e []error + if bot.api == nil { + e = append(e, errors.New("bot api nil")) + } else { + if _, err := bot.api.DeleteWebhook(tgapi.DeleteWebhookP{}); err != nil { + if bot.webHookLogger != nil { + bot.webHookLogger.Errorf("Failed to close webhook: %s", err.Error()) + } else if bot.logger != nil { + bot.logger.Errorf("Failed to close webhook: %s", err.Error()) + } + e = append(e, err) + } + } + if bot.webHookLogger != nil { + if err := bot.webHookLogger.Close(); err != nil { + e = append(e, err) + } + bot.webHookLogger = nil + } + return errors.Join(e...) +} + +func (bot *Bot[T]) webhookAllowedUpdates(opts *BotWebHookOpts) []tgapi.UpdateType { + if len(opts.AllowedUpdates) > 0 { + return append([]tgapi.UpdateType(nil), opts.AllowedUpdates...) + } + return bot.GetUpdateTypes() +} + +func (bot *Bot[T]) runWebhookRuntime(ctx context.Context, run func(context.Context) error) error { + if err := bot.beginRun(); err != nil { + return err + } + defer bot.finishRun() + + runCtx, cancel := context.WithCancel(ctx) + defer cancel() + + bot.ExecRunners(runCtx) + + workersDone := make(chan struct{}) + go func() { + bot.startUpdateWorkers(runCtx) + close(workersDone) + }() + + runErr := run(runCtx) + cancel() + close(bot.updateQueue) + <-workersDone + bot.runnerOnceWG.Wait() + bot.runnerBgWG.Wait() + + return runErr +} + +func updateHandler[T any](ctx context.Context, bot *Bot[T], secret string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + defer func() { + _ = r.Body.Close() + }() + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + if secret != "" && r.Header.Get("X-Telegram-Bot-Api-Secret-Token") != secret { + w.WriteHeader(http.StatusForbidden) + return + } + + const maxWebhookBodySize = 256 << 10 // 256 KiB + r.Body = http.MaxBytesReader(w, r.Body, maxWebhookBodySize) + + data, err := io.ReadAll(r.Body) + if err != nil { + if _, ok := errors.AsType[*http.MaxBytesError](err); ok { + w.WriteHeader(http.StatusRequestEntityTooLarge) + return + } + w.WriteHeader(http.StatusBadRequest) + return + } + if len(data) == 0 { + w.WriteHeader(http.StatusBadRequest) + return + } + var up tgapi.Update + if err := json.Unmarshal(data, &up); err != nil { + w.WriteHeader(http.StatusBadRequest) + bot.webHookLogger.Errorln(err) + return + } + bot.webHookLogger.Debugf("UPDATE id=%d type=%s size=%d from=%s", up.UpdateID, up.Type, len(data), r.RemoteAddr) + if err := bot.enqueueUpdate(ctx, up); err != nil { + bot.webHookLogger.Errorln(err) + w.WriteHeader(http.StatusServiceUnavailable) + return + } + w.WriteHeader(http.StatusOK) + } +} + +func statusHandler[T any](bot *Bot[T], opts *BotWebHookOpts) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + auth := "" + if r.Header.Get("Authorization") != "" { + auth = r.Header.Get("Authorization") + } else if r.Header.Get("X-Telegram-Bot-Api-Secret-Token") != "" { + auth = r.Header.Get("X-Telegram-Bot-Api-Secret-Token") + } + if auth != opts.SecretToken { + w.WriteHeader(http.StatusNotFound) + return + } + + i, err := bot.api.GetWebhookInfoWithContext(r.Context()) + if err != nil { + bot.webHookLogger.Errorln(err) + w.WriteHeader(http.StatusInternalServerError) + return + } + data, err := json.MarshalIndent(i, "", " ") + if err != nil { + bot.webHookLogger.Errorln(err) + w.WriteHeader(http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + if _, err := fmt.Fprint(w, string(data)); err != nil { + bot.webHookLogger.Errorln(err) + } + } +} + +func (bot *Bot[T]) newWebHookMux(ctx context.Context, opts *BotWebHookOpts) *http.ServeMux { + r := http.NewServeMux() + if opts.UseStatusPath { + r.HandleFunc("/status", statusHandler[T](bot, opts)) + } + r.HandleFunc(opts.Path, updateHandler[T](ctx, bot, opts.SecretToken)) + return r +} +func (bot *Bot[T]) runWebHook(ctx context.Context, opts *BotWebHookOpts) error { + srv := &http.Server{ + Addr: fmt.Sprintf(":%d", opts.LocalPort), + Handler: bot.newWebHookMux(ctx, opts), + } + errCh := make(chan error, 1) + + go func() { + err := srv.ListenAndServe() + if err != nil && !errors.Is(err, http.ErrServerClosed) { + errCh <- err + return + } + errCh <- nil + }() + + bot.webHookLogger.Infoln(fmt.Sprintf("Bot WebHook started at %s; waiting for updates at %s", srv.Addr, opts.URL)) + + select { + case <-ctx.Done(): + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + if err := srv.Shutdown(shutdownCtx); err != nil { + return err + } + + return <-errCh + + case err := <-errCh: + return err + } +} +func (bot *Bot[T]) runWebHookTLS(ctx context.Context, opts *BotWebHookOpts, key, cert string) error { + srv := &http.Server{ + Addr: fmt.Sprintf(":%d", opts.LocalPort), + Handler: bot.newWebHookMux(ctx, opts), + } + errCh := make(chan error, 1) + + go func() { + err := srv.ListenAndServeTLS(cert, key) + if err != nil && !errors.Is(err, http.ErrServerClosed) { + errCh <- err + return + } + errCh <- nil + }() + + bot.webHookLogger.Infoln(fmt.Sprintf("Bot webhook started with TLS(%s, %s) at %s; waiting for updates at %s", key, cert, srv.Addr, opts.URL)) + + select { + case <-ctx.Done(): + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + if err := srv.Shutdown(shutdownCtx); err != nil { + return err + } + + return <-errCh + + case err := <-errCh: + return err + } +} +func validateWebhookPath(path string, useStatusPath bool) error { + if path == "" { + return errors.New("empty BotWebHookOpts.Path") + } + if !strings.HasPrefix(path, "/") { + return errors.New("BotWebHookOpts.Path must start with '/'") + } + if strings.Contains(path, "?") || strings.Contains(path, "#") { + return errors.New("BotWebHookOpts.Path must not contain query or fragment") + } + if useStatusPath && path == "/status" { + return errors.New("BotWebHookOpts.Path must not be '/status' when status path is enabled") + } + return nil +} + +func validateWebhookTLSFiles(tlsFiles []string) error { + switch len(tlsFiles) { + case 0, 2: + return nil + case 1: + return errors.New("you must specify both private and public keys") + default: + return errors.New("too many files; you must specify only private and public keys") + } +} diff --git a/bot_webhook_test.go b/bot_webhook_test.go new file mode 100644 index 0000000..e58d06c --- /dev/null +++ b/bot_webhook_test.go @@ -0,0 +1,293 @@ +package laniakea + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + + "git.scuroneko.dev/scuroneko/laniakea/tgapi" + "git.scuroneko.dev/scuroneko/slog" +) + +func TestEnqueueUpdateCopiesValue(t *testing.T) { + bot := &Bot[NoData]{ + updateQueue: make(chan *tgapi.Update, 1), + } + + update := tgapi.Update{UpdateID: 42} + if err := bot.enqueueUpdate(context.Background(), update); err != nil { + t.Fatalf("enqueueUpdate returned error: %v", err) + } + + update.UpdateID = 99 + + got := <-bot.updateQueue + if got.UpdateID != 42 { + t.Fatalf("enqueueUpdate did not copy the update value: got %d want %d", got.UpdateID, 42) + } +} + +func TestUpdateHandlerEnqueuesUpdate(t *testing.T) { + bot := &Bot[NoData]{ + updateQueue: make(chan *tgapi.Update, 1), + webHookLogger: slog.CreateLogger(), + } + t.Cleanup(func() { + _ = bot.webHookLogger.Close() + }) + + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"update_id":7,"message":{"message_id":1,"date":1,"chat":{"id":1,"type":"private"},"text":"/start"}}`)) + req.Header.Set("X-Telegram-Bot-Api-Secret-Token", "secret") + rec := httptest.NewRecorder() + + updateHandler(context.Background(), bot, "secret").ServeHTTP(rec, req) + + if rec.Result().StatusCode != http.StatusOK { + t.Fatalf("unexpected status: got %d want %d", rec.Result().StatusCode, http.StatusOK) + } + + select { + case got := <-bot.updateQueue: + if got.UpdateID != 7 { + t.Fatalf("unexpected update id in queue: got %d want %d", got.UpdateID, 7) + } + if got.Type != tgapi.UpdateTypeMessage { + t.Fatalf("unexpected update type in queue: got %q want %q", got.Type, tgapi.UpdateTypeMessage) + } + default: + t.Fatal("expected webhook handler to enqueue an update") + } +} + +func TestRunWebhookRuntimeRejectsSecondRun(t *testing.T) { + bot := &Bot[NoData]{ + logger: slog.CreateLogger(), + updateQueue: make(chan *tgapi.Update, 1), + maxWorkers: 1, + } + t.Cleanup(func() { + _ = bot.logger.Close() + }) + + if err := bot.runWebhookRuntime(context.Background(), func(context.Context) error { return nil }); err != nil { + t.Fatalf("first runWebhookRuntime returned error: %v", err) + } + if err := bot.runWebhookRuntime(context.Background(), func(context.Context) error { return nil }); !errors.Is(err, ErrBotAlreadyRun) { + t.Fatalf("expected ErrBotAlreadyRun on second run, got %v", err) + } +} + +func TestRunWebhookRuntimeExecutesRunners(t *testing.T) { + var calls atomic.Int32 + + bot := &Bot[NoData]{ + logger: slog.CreateLogger(), + updateQueue: make(chan *tgapi.Update, 1), + maxWorkers: 1, + runners: []Runner[NoData]{ + NewRunner("runner", func(bot *Bot[NoData]) error { + calls.Add(1) + return nil + }).Onetime(true).Async(false), + }, + } + t.Cleanup(func() { + _ = bot.logger.Close() + }) + + if err := bot.runWebhookRuntime(context.Background(), func(context.Context) error { return nil }); err != nil { + t.Fatalf("runWebhookRuntime returned error: %v", err) + } + if got := calls.Load(); got != 1 { + t.Fatalf("expected runner to execute once, got %d", got) + } +} + +func TestWebhookAllowedUpdatesUsesBotUpdateTypesByDefault(t *testing.T) { + bot := &Bot[NoData]{ + updateTypes: []tgapi.UpdateType{tgapi.UpdateTypeMessage, tgapi.UpdateTypeCallbackQuery}, + } + opts := NewBotWebHookOpts() + + got := bot.webhookAllowedUpdates(opts) + if len(got) != 2 { + t.Fatalf("unexpected allowed updates length: got %d want %d", len(got), 2) + } + if got[0] != tgapi.UpdateTypeMessage || got[1] != tgapi.UpdateTypeCallbackQuery { + t.Fatalf("unexpected allowed updates: %v", got) + } + + got[0] = tgapi.UpdateTypePoll + if bot.updateTypes[0] != tgapi.UpdateTypeMessage { + t.Fatalf("webhookAllowedUpdates exposed internal slice: got %v", bot.updateTypes) + } +} + +func TestValidateWebhookPath(t *testing.T) { + tests := []struct { + name string + path string + useStatusPath bool + wantErr bool + }{ + {name: "root", path: "/", wantErr: false}, + {name: "custom path", path: "/telegram", wantErr: false}, + {name: "empty", path: "", wantErr: true}, + {name: "missing slash", path: "telegram", wantErr: true}, + {name: "query", path: "/telegram?x=1", wantErr: true}, + {name: "fragment", path: "/telegram#main", wantErr: true}, + {name: "status collision", path: "/status", useStatusPath: true, wantErr: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := validateWebhookPath(tc.path, tc.useStatusPath) + if tc.wantErr && err == nil { + t.Fatal("expected error, got nil") + } + if !tc.wantErr && err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + } +} + +func TestValidateWebhookTLSFiles(t *testing.T) { + tests := []struct { + name string + files []string + wantErr bool + }{ + {name: "no tls", files: nil, wantErr: false}, + {name: "two files", files: []string{"key.pem", "cert.pem"}, wantErr: false}, + {name: "one file", files: []string{"cert.pem"}, wantErr: true}, + {name: "three files", files: []string{"a", "b", "c"}, wantErr: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := validateWebhookTLSFiles(tc.files) + if tc.wantErr && err == nil { + t.Fatal("expected error, got nil") + } + if !tc.wantErr && err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + } +} + +func TestUpdateHandlerRejectsOversizedBody(t *testing.T) { + bot := &Bot[NoData]{ + updateQueue: make(chan *tgapi.Update, 1), + webHookLogger: slog.CreateLogger(), + } + t.Cleanup(func() { + _ = bot.webHookLogger.Close() + }) + + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(strings.Repeat("a", (256<<10)+1))) + rec := httptest.NewRecorder() + + updateHandler(context.Background(), bot, "").ServeHTTP(rec, req) + + if rec.Result().StatusCode != http.StatusRequestEntityTooLarge { + t.Fatalf("unexpected status: got %d want %d", rec.Result().StatusCode, http.StatusRequestEntityTooLarge) + } +} + +func TestStatusHandlerRequiresMatchingSecret(t *testing.T) { + client := &http.Client{ + Transport: pollingRoundTripFunc(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":{"url":"https://bot.example.com/telegram"}}`)), + }, nil + }), + } + api := tgapi.NewAPI( + tgapi.NewAPIOpts("token"). + SetAPIUrl("http://example.invalid"). + SetHTTPClient(client), + ) + defer func() { + _ = api.Close() + }() + + bot := &Bot[NoData]{ + api: api, + webHookLogger: slog.CreateLogger(), + } + t.Cleanup(func() { + _ = bot.webHookLogger.Close() + }) + + handler := statusHandler(bot, &BotWebHookOpts{SecretToken: "secret"}) + + tests := []struct { + name string + headerName string + headerVal string + wantStatus int + }{ + {name: "missing auth", wantStatus: http.StatusNotFound}, + {name: "wrong auth", headerName: "Authorization", headerVal: "wrong", wantStatus: http.StatusNotFound}, + {name: "matching telegram header", headerName: "X-Telegram-Bot-Api-Secret-Token", headerVal: "secret", wantStatus: http.StatusOK}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/status", nil) + if tc.headerName != "" { + req.Header.Set(tc.headerName, tc.headerVal) + } + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Result().StatusCode != tc.wantStatus { + t.Fatalf("unexpected status: got %d want %d", rec.Result().StatusCode, tc.wantStatus) + } + }) + } +} + +func TestRunWebHookWithContextRejectsInvalidTLSFilesBeforeRemoteSetup(t *testing.T) { + bot := &Bot[NoData]{ + prefixes: []string{"/"}, + plugins: []Plugin[NoData]{{name: "demo"}}, + } + opts := NewBotWebHookOpts().SetURL("https://bot.example.com") + + err := bot.RunWebHookWithContext(context.Background(), opts, "cert.pem") + if err == nil { + t.Fatal("expected tls validation error, got nil") + } + if !strings.Contains(err.Error(), "both private and public keys") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestRunWebHookWithContextRequiresSecretWhenStatusPathEnabled(t *testing.T) { + bot := &Bot[NoData]{ + prefixes: []string{"/"}, + plugins: []Plugin[NoData]{{name: "demo"}}, + } + opts := NewBotWebHookOpts(). + SetURL("https://bot.example.com"). + SetUseStatusPath(true) + + err := bot.RunWebHookWithContext(context.Background(), opts) + if err == nil { + t.Fatal("expected status-path secret validation error, got nil") + } + if !strings.Contains(err.Error(), "SecretToken required") { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/doc.go b/doc.go index 6cb8433..3be732d 100644 --- a/doc.go +++ b/doc.go @@ -27,7 +27,7 @@ Example usage: return bot.Run() -Configure bots, plugins, and localization before starting Run or RunWithContext. +Configure bots, plugins, and localization before starting Run, RunWithContext, or RunWebHookWithContext. Runtime accessors are safe for concurrent use unless stated otherwise. */ package laniakea diff --git a/keyboard.go b/keyboard.go index fc5635e..e251ddc 100644 --- a/keyboard.go +++ b/keyboard.go @@ -147,6 +147,8 @@ func (in *InlineKeyboard) SetPayloadType(t BotPayloadType) *InlineKeyboard { // GetPayloadType returns the keyboard-local callback payload encoding type. func (in *InlineKeyboard) GetPayloadType() BotPayloadType { return in.payloadType } +// SetMaxRow sets the maximum number of buttons appended to a row before the +// keyboard automatically starts a new line. func (in *InlineKeyboard) SetMaxRow(maxRow int) *InlineKeyboard { in.maxRow = maxRow return in diff --git a/runners.go b/runners.go index 7d50ce5..7aa457c 100644 --- a/runners.go +++ b/runners.go @@ -88,7 +88,8 @@ func (r Runner[T]) Timeout(timeout time.Duration) Runner[T] { // // Background runners listen for ctx.Done() and gracefully shut down when the context is canceled. // -// This method is typically called once during bot startup in RunWithContext. +// This method is typically called once during bot startup from RunWithContext or +// RunWebHookWithContext. func (bot *Bot[T]) ExecRunners(ctx context.Context) { bot.logger.Infoln("Executing runners...") for _, runner := range bot.runners { diff --git a/tgapi/methods.go b/tgapi/methods.go index a13f328..a4d5eb9 100644 --- a/tgapi/methods.go +++ b/tgapi/methods.go @@ -86,7 +86,7 @@ func (api *API) GetUpdatesWithContext(ctx context.Context, params UpdateParams) type SetWebhookP struct { URL string `json:"url"` IPAddress string `json:"ip_address,omitempty"` - MaxConnections int `json:"max_connections,omitempty"` + MaxConnections int8 `json:"max_connections,omitempty"` AllowedUpdates []UpdateType `json:"allowed_updates,omitempty"` DropPendingUpdates bool `json:"drop_pending_updates,omitempty"` SecretToken string `json:"secret_token,omitempty"` diff --git a/tgapi/uploader_methods.go b/tgapi/uploader_methods.go index ba78abb..d88a534 100644 --- a/tgapi/uploader_methods.go +++ b/tgapi/uploader_methods.go @@ -333,7 +333,7 @@ func (u *Uploader) SetChatPhotoWithContext(ctx context.Context, params UploadCha type UploadSetWebhookP struct { URL string `json:"url"` IPAddress string `json:"ip_address,omitempty"` - MaxConnections int `json:"max_connections,omitempty"` + MaxConnections int8 `json:"max_connections,omitempty"` AllowedUpdates []UpdateType `json:"allowed_updates,omitempty"` DropPendingUpdates bool `json:"drop_pending_updates,omitempty"` SecretToken string `json:"secret_token,omitempty"`