Add shared webhook runtime support
Share queue and runner lifecycle between polling and webhook Keep Close() local-only and harden webhook startup validation Document runtime transitions and add webhook regression tests
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
+22
-2
@@ -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-ы обрабатываются через плагины.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+2
-1
@@ -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:
|
||||
//
|
||||
|
||||
+35
@@ -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()
|
||||
|
||||
+178
@@ -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
|
||||
}
|
||||
+461
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+2
-1
@@ -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 {
|
||||
|
||||
+1
-1
@@ -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"`
|
||||
|
||||
@@ -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"`
|
||||
|
||||
Reference in New Issue
Block a user