rename app data API

Align docs, examples, and tests with AppData and NoData

Update handler, scene, runner, and plugin generics to the new naming

Ignore .codex and record the cleanup in the changelog
This commit is contained in:
2026-03-30 16:43:59 +03:00
parent 66eb72cb3c
commit e2444752c2
16 changed files with 287 additions and 274 deletions
+1
View File
@@ -3,3 +3,4 @@
.vscode/ .vscode/
test/ test/
.codex/ .codex/
.codex
+1
View File
@@ -9,6 +9,7 @@
- Bot configuration mutators now treat the bot as configuration-frozen after the first run begins and ignore late mutation attempts for bot-level config such as prefixes, payload defaults, plugins, middleware, runners, localization, scene session wiring, and database context injection. - Bot configuration mutators now treat the bot as configuration-frozen after the first run begins and ignore late mutation attempts for bot-level config such as prefixes, payload defaults, plugins, middleware, runners, localization, scene session wiring, and database context injection.
- `MsgContext` godoc and field comments now describe the normalized update contract more explicitly, including when `Msg`, `From`, callback target fields, `Text`, and `Args` are expected to be populated. - `MsgContext` godoc and field comments now describe the normalized update contract more explicitly, including when `Msg`, `From`, callback target fields, `Text`, and `Args` are expected to be populated.
- `MsgContext.Error(...)` and returned handler errors now suppress the automatic user reply when the error is explicitly marked with `AsInternalError(...)`, while keeping the previous user-visible default for unclassified errors. - `MsgContext.Error(...)` and returned handler errors now suppress the automatic user reply when the error is explicitly marked with `AsInternalError(...)`, while keeping the previous user-visible default for unclassified errors.
- Godoc, README examples, and regression-test naming now consistently describe the shared generic dependency model as app data, including `NoData` and `SetAppData(...)`.
### Tests ### Tests
- Added regression coverage for the bot configuration freeze model, including ignored post-run mutations for core bot configuration methods and late registration paths. - Added regression coverage for the bot configuration freeze model, including ignored post-run mutations for core bot configuration methods and late registration paths.
+16 -16
View File
@@ -21,8 +21,8 @@ A lightweight, easy-to-use, and performant Telegram Bot API wrapper for Go. It s
* **Middleware Support:** Run code before or after commands (e.g., logging, access control). * **Middleware Support:** Run code before or after commands (e.g., logging, access control).
* **Automatic Command Generation:** Generate help and command lists automatically. * **Automatic Command Generation:** Generate help and command lists automatically.
* **Built-in Rate Limiting:** Protect your bot from hitting Telegram API limits (supports `retry_after` handling). * **Built-in Rate Limiting:** Protect your bot from hitting Telegram API limits (supports `retry_after` handling).
* **Context-Aware:** Pass custom database or state contexts to your handlers. * **Context-Aware:** Pass custom application data or state contexts to your handlers.
* **Fluent Interface:** Chain methods for clean configuration (e.g., `bot.ErrorTemplate(...).AddPlugins(...)`). * **Configurable API:** Mix `Set...` and `Add...` helpers to configure bots clearly (for example, `bot.SetErrorTemplate(...).AddPlugins(...)`).
--- ---
@@ -53,8 +53,8 @@ import (
// echo is a command handler function. // echo is a command handler function.
// It receives two parameters: // It receives two parameters:
// - ctx: the message context (contains info about the message, sender, chat, etc.) // - ctx: the message context (contains info about the message, sender, chat, etc.)
// - db: your custom database context (here we use NoDB, a placeholder for no database) // - data: your shared application data (here we use NoData, a placeholder for no shared data)
func echo(ctx *laniakea.MsgContext, db laniakea.NoDB) error { func echo(ctx *laniakea.MsgContext, data laniakea.NoData) error {
// Answer the user with the text they sent, without any command prefix. // Answer the user with the text they sent, without any command prefix.
// ctx.Text contains the user's message with the command part stripped off. // ctx.Text contains the user's message with the command part stripped off.
ctx.Answer(ctx.Text) // User input WITHOUT command ctx.Answer(ctx.Text) // User input WITHOUT command
@@ -66,8 +66,8 @@ func main() {
opts := &laniakea.BotOpts{Token: "TOKEN"} opts := &laniakea.BotOpts{Token: "TOKEN"}
// 2. Initialize a new bot instance. // 2. Initialize a new bot instance.
// We use laniakea.NoDB as the database context type (no database needed for this example). // We use laniakea.NoData as the application data type (no shared data needed for this example).
bot, err := laniakea.NewBot[laniakea.NoDB](opts) bot, err := laniakea.NewBot[laniakea.NoData](opts)
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
@@ -76,7 +76,7 @@ func main() {
// 3. Create a new plugin named "ping". // 3. Create a new plugin named "ping".
// Plugins help group related commands and middlewares. // Plugins help group related commands and middlewares.
p := laniakea.NewPlugin[laniakea.NoDB]("ping") p := laniakea.NewPlugin[laniakea.NoData]("ping")
// 4. Add a command to the plugin. // 4. Add a command to the plugin.
// p.NewCommand(echo, "echo") creates a command that triggers the 'echo' function on the "/echo" command. // p.NewCommand(echo, "echo") creates a command that triggers the 'echo' function on the "/echo" command.
@@ -84,15 +84,15 @@ func main() {
// 5. Add another command using an anonymous function (closure). // 5. Add another command using an anonymous function (closure).
// This command simply replies "Pong" when the user sends "/ping". // This command simply replies "Pong" when the user sends "/ping".
p.AddCommand(p.NewCommand(func(ctx *laniakea.MsgContext, db laniakea.NoDB) error { p.AddCommand(p.NewCommand(func(ctx *laniakea.MsgContext, data laniakea.NoData) error {
ctx.Answer("Pong") ctx.Answer("Pong")
return nil return nil
}, "ping")) }, "ping"))
// 6. Configure the bot with a custom error template and add the plugin. // 6. Configure the bot with a custom error template and add the plugin.
// ErrorTemplate sets a format string for errors (where %s will be replaced by the actual error). // SetErrorTemplate sets a format string for errors (where %s will be replaced by the actual error).
// AddPlugins(p) registers our "ping" plugin with the bot. // AddPlugins(p) registers our "ping" plugin with the bot.
bot = bot.ErrorTemplate("Error\n\n%s").AddPlugins(p) bot = bot.SetErrorTemplate("Error\n\n%s").AddPlugins(p)
// 7. Automatically generate commands like /start, /help, and a list of all registered commands. // 7. Automatically generate commands like /start, /help, and a list of all registered commands.
// This is optional but very useful for most bots. // This is optional but very useful for most bots.
@@ -109,11 +109,11 @@ func main() {
### How It Works ### How It Works
1. `BotOpts`: Holds configuration like the API token. 1. `BotOpts`: Holds configuration like the API token.
2. `NewBot[T]`: Creates a bot instance. The type parameter T allows you to pass a custom database context (e.g., *sql.DB) that will be available in all handlers. Use laniakea.NoDB if you don't need it. 2. `NewBot[T]`: Creates a bot instance. The type parameter T allows you to pass custom shared application data (for example, *sql.DB or a service container) that will be available in all handlers. Use laniakea.NoData if you don't need it.
3. `NewPlugin`: Creates a logical group for commands and middlewares. 3. `NewPlugin`: Creates a logical group for commands and middlewares.
4. `AddCommand`: Registers a command. The first argument is the handler function (`func(*MsgContext, T) error`), the second is the command name (without the slash). 4. `AddCommand`: Registers a command. The first argument is the handler function (`func(*MsgContext, T) error`), the second is the command name (without the slash).
5. **Handler Functions**: Receive *MsgContext (message details, methods like Answer) and your custom database context T, and return an error for centralized error handling. 5. **Handler Functions**: Receive *MsgContext (message details, methods like Answer) and your custom application data T, and return an error for centralized error handling.
6. `ErrorTemplate`: Sets a template for error messages. The %s placeholder is replaced by the actual error. 6. `SetErrorTemplate`: Sets a template for error messages. The %s placeholder is replaced by the actual error.
7. `AutoGenerateCommands`: Registers plugin-defined commands with Telegram across the supported scopes. 7. `AutoGenerateCommands`: Registers plugin-defined commands with Telegram across the supported scopes.
8. `Run()`: Starts the bot's update polling loop and returns an error if startup or polling fails. 8. `Run()`: Starts the bot's update polling loop and returns an error if startup or polling fails.
9. A `Bot` instance is single-use. After `Run()` or `RunWithContext()` returns, create a new bot instance for the next session. 9. A `Bot` instance is single-use. After `Run()` or `RunWithContext()` returns, create a new bot instance for the next session.
@@ -168,9 +168,9 @@ This split keeps method intent explicit: JSON-only calls go through `API`, file
For advanced cases, `tgapi.NewRequest(...)` and `tgapi.NewUploaderRequest(...)` remain public as low-level escape hatches. They are intentionally less safe than method-specific helpers: callers must supply the correct Telegram method name and compatible request/response types themselves. For advanced cases, `tgapi.NewRequest(...)` and `tgapi.NewUploaderRequest(...)` remain public as low-level escape hatches. They are intentionally less safe than method-specific helpers: callers must supply the correct Telegram method name and compatible request/response types themselves.
### Database Context ### App Data
The `T` in `NewBot[T]` is a powerful feature. You can pass any type, but shared dependencies such as database pools should usually use a pointer type. The `T` in `NewBot[T]` is a powerful feature. You can pass any type, but shared dependencies such as database pools, service containers, or API clients should usually use a pointer type.
```go ```go
type MyDB struct { /* ... */ } type MyDB struct { /* ... */ }
@@ -179,7 +179,7 @@ bot, err := laniakea.NewBot[*MyDB](opts)
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
bot.DatabaseContext(db) bot.SetAppData(db)
``` ```
### Scenes and Sessions ### Scenes and Sessions
+16 -16
View File
@@ -22,8 +22,8 @@
* **Поддержка промежуточных слоёв (Middleware):** Выполняйте код до или после команд (например, логирование, проверка доступа). * **Поддержка промежуточных слоёв (Middleware):** Выполняйте код до или после команд (например, логирование, проверка доступа).
* **Автоматическая генерация команд:** Генерируйте справку и списки команд автоматически. * **Автоматическая генерация команд:** Генерируйте справку и списки команд автоматически.
* **Встроенный ограничитель запросов (Rate Limiter):** Защитите бота от превышения лимитов Telegram API (с обработкой `retry_after`). * **Встроенный ограничитель запросов (Rate Limiter):** Защитите бота от превышения лимитов Telegram API (с обработкой `retry_after`).
* **Контекст данных:** Передавайте свой контекст базы данных или состояния в обработчики. * **Контекст данных:** Передавайте общие данные приложения или state в обработчики.
* **Текучий интерфейс (Fluent Interface):** Стройте цепочки методов для чистой конфигурации (например, `bot.ErrorTemplate(...).AddPlugins(...)`). * **Настраиваемый API:** Комбинируйте `Set...` и `Add...` helper-методы для понятной конфигурации, например `bot.SetErrorTemplate(...).AddPlugins(...)`.
--- ---
@@ -54,8 +54,8 @@ import (
// echo — это функция-обработчик команды. // echo — это функция-обработчик команды.
// Она получает два параметра: // Она получает два параметра:
// - ctx: контекст сообщения (содержит информацию о сообщении, отправителе, чате и т.д.) // - ctx: контекст сообщения (содержит информацию о сообщении, отправителе, чате и т.д.)
// - db: ваш пользовательский контекст базы данных (здесь мы используем NoDB — заглушку) // - data: ваши общие данные приложения (здесь мы используем NoData — заглушку без общих зависимостей)
func echo(ctx *laniakea.MsgContext, db laniakea.NoDB) error { func echo(ctx *laniakea.MsgContext, data laniakea.NoData) error {
// Отвечаем пользователю текстом, который он прислал, без префикса команды. // Отвечаем пользователю текстом, который он прислал, без префикса команды.
// ctx.Text содержит сообщение пользователя, из которого удалена часть с командой. // ctx.Text содержит сообщение пользователя, из которого удалена часть с командой.
ctx.Answer(ctx.Text) // Ввод пользователя БЕЗ команды ctx.Answer(ctx.Text) // Ввод пользователя БЕЗ команды
@@ -67,8 +67,8 @@ func main() {
opts := &laniakea.BotOpts{Token: "TOKEN"} opts := &laniakea.BotOpts{Token: "TOKEN"}
// 2. Инициализируем новый экземпляр бота. // 2. Инициализируем новый экземпляр бота.
// Используем laniakea.NoDB как тип контекста базы данных (база не нужна для примера). // Используем laniakea.NoData как тип данных приложения (общие зависимости не нужны для примера).
bot, err := laniakea.NewBot[laniakea.NoDB](opts) bot, err := laniakea.NewBot[laniakea.NoData](opts)
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
@@ -77,7 +77,7 @@ func main() {
// 3. Создаём новый плагин с именем "ping". // 3. Создаём новый плагин с именем "ping".
// Плагины помогают группировать связанные команды и промежуточные обработчики. // Плагины помогают группировать связанные команды и промежуточные обработчики.
p := laniakea.NewPlugin[laniakea.NoDB]("ping") p := laniakea.NewPlugin[laniakea.NoData]("ping")
// 4. Добавляем команду в плагин. // 4. Добавляем команду в плагин.
// p.NewCommand(echo, "echo") создаёт команду, которая вызывает функцию 'echo' по команде "/echo". // p.NewCommand(echo, "echo") создаёт команду, которая вызывает функцию 'echo' по команде "/echo".
@@ -85,15 +85,15 @@ func main() {
// 5. Добавляем ещё одну команду, используя анонимную функцию (замыкание). // 5. Добавляем ещё одну команду, используя анонимную функцию (замыкание).
// Эта команда просто отвечает "Pong", когда пользователь отправляет "/ping". // Эта команда просто отвечает "Pong", когда пользователь отправляет "/ping".
p.AddCommand(p.NewCommand(func(ctx *laniakea.MsgContext, db laniakea.NoDB) error { p.AddCommand(p.NewCommand(func(ctx *laniakea.MsgContext, data laniakea.NoData) error {
ctx.Answer("Pong") ctx.Answer("Pong")
return nil return nil
}, "ping")) }, "ping"))
// 6. Настраиваем бота: задаём шаблон ошибки и добавляем плагин. // 6. Настраиваем бота: задаём шаблон ошибки и добавляем плагин.
// ErrorTemplate устанавливает формат для сообщений об ошибках (где %s будет заменён на текст ошибки). // SetErrorTemplate устанавливает формат для сообщений об ошибках (где %s будет заменён на текст ошибки).
// AddPlugins(p) регистрирует наш плагин "ping" в боте. // AddPlugins(p) регистрирует наш плагин "ping" в боте.
bot = bot.ErrorTemplate("Ошибка\n\n%s").AddPlugins(p) bot = bot.SetErrorTemplate("Ошибка\n\n%s").AddPlugins(p)
// 7. Автоматически генерируем команды, такие как /start, /help и список всех зарегистрированных команд. // 7. Автоматически генерируем команды, такие как /start, /help и список всех зарегистрированных команд.
// Это необязательно, но очень полезно для большинства ботов. // Это необязательно, но очень полезно для большинства ботов.
@@ -110,11 +110,11 @@ func main() {
### Как это работает ### Как это работает
1. `BotOpts`: Содержит конфигурацию, например, токен API. 1. `BotOpts`: Содержит конфигурацию, например, токен API.
2. `NewBot[T]`: Создаёт экземпляр бота. Параметр типа T позволяет передать пользовательский контекст базы данных (например, *sql.DB), который будет доступен во всех обработчиках. Используйте laniakea.NoDB, если он не нужен. 2. `NewBot[T]`: Создаёт экземпляр бота. Параметр типа T позволяет передать общие данные приложения (например, *sql.DB или контейнер сервисов), которые будут доступны во всех обработчиках. Используйте laniakea.NoData, если они не нужны.
3. `NewPlugin`: Создаёт логическую группу для команд и Middleware. 3. `NewPlugin`: Создаёт логическую группу для команд и Middleware.
4. `AddCommand`: Регистрирует команду. Первый аргумент — функция-обработчик (`func(*MsgContext, T) error`), второй — имя команды (без слеша). 4. `AddCommand`: Регистрирует команду. Первый аргумент — функция-обработчик (`func(*MsgContext, T) error`), второй — имя команды (без слеша).
5. **Функции-обработчики**: Получают *MsgContext (детали сообщения, методы типа Answer) и ваш контекст базы данных T, а ошибку возвращают для централизованной обработки. 5. **Функции-обработчики**: Получают *MsgContext (детали сообщения, методы типа Answer) и ваши данные приложения типа T, а ошибку возвращают для централизованной обработки.
6. `ErrorTemplate`: Устанавливает шаблон для сообщений об ошибках. Плейсхолдер %s заменяется на текст ошибки. 6. `SetErrorTemplate`: Устанавливает шаблон для сообщений об ошибках. Плейсхолдер %s заменяется на текст ошибки.
7. `AutoGenerateCommands`: Регистрирует команды из плагинов в Telegram для поддерживаемых scope. 7. `AutoGenerateCommands`: Регистрирует команды из плагинов в Telegram для поддерживаемых scope.
8. `Run()`: Запускает цикл опроса обновлений бота и возвращает ошибку, если старт или polling завершился неуспешно. 8. `Run()`: Запускает цикл опроса обновлений бота и возвращает ошибку, если старт или polling завершился неуспешно.
9. Экземпляр `Bot` одноразовый. После завершения `Run()` или `RunWithContext()` для следующего запуска создавайте новый бот. 9. Экземпляр `Bot` одноразовый. После завершения `Run()` или `RunWithContext()` для следующего запуска создавайте новый бот.
@@ -157,8 +157,8 @@ func myHandler(ctx *laniakea.MsgContext, db *MyDB) error {
- Поля: `Text`, `Args`, `From`, `FromID`, `Msg`, `InlineMsgId`, `CallbackQueryId` и другие. - Поля: `Text`, `Args`, `From`, `FromID`, `Msg`, `InlineMsgId`, `CallbackQueryId` и другие.
- И много других методов и полей! - И много других методов и полей!
### Контекст базы данных (Database Context) ### App Data
Параметр типа `T` в `NewBot[T]` — мощная функция. Вы можете передать любой тип, но для разделяемых зависимостей вроде пула соединений с БД обычно стоит использовать pointer type. Параметр типа `T` в `NewBot[T]` — мощная возможность. Вы можете передать любой тип, но для разделяемых зависимостей вроде пула соединений с БД, контейнера сервисов или API-клиента обычно стоит использовать pointer type.
```go ```go
type MyDB struct { /* ... */ } type MyDB struct { /* ... */ }
@@ -167,7 +167,7 @@ bot, err := laniakea.NewBot[*MyDB](opts)
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
bot.DatabaseContext(db) bot.SetAppData(db)
``` ```
### Сцены и сессии (Scenes and Sessions) ### Сцены и сессии (Scenes and Sessions)
+68 -57
View File
@@ -19,8 +19,11 @@ import (
"github.com/alitto/pond/v2" "github.com/alitto/pond/v2"
) )
// DbContext is the generic dependency type injected into bots, plugins, and handlers. // AppData is the generic shared application data type injected into bots,
// Use it for shared application state such as database handles or service containers. // plugins, and handlers.
//
// Use it for long-lived shared dependencies such as database handles, service
// containers, API clients, or immutable configuration snapshots.
// //
// Example: // Example:
// //
@@ -30,18 +33,22 @@ import (
// if err != nil { // if err != nil {
// return err // return err
// } // }
// bot.DatabaseContext(myDB) // bot.SetAppData(myDB)
// //
// Use NoDB if no database is needed. // Use NoData if no shared application data is needed.
type DbContext any type AppData any
// NoDB is a placeholder type for bots that do not use a database. // NoData is a placeholder type for bots that do not use shared application
// Use Bot[NoDB] to indicate no dependency injection is required. // data.
type NoDB struct{ DbContext } //
// Use Bot[NoData] to indicate no shared dependency injection is required.
type NoData struct{ AppData }
// DbLogger is a function type that returns a slog.LoggerWriter for database logging. // AppDataLogger builds a slog.LoggerWriter from injected application data.
// Used to inject database-specific log output (e.g., SQL queries, ORM events). //
type DbLogger[T DbContext] func(db T) slog.LoggerWriter // Use it when shared application data exposes a log sink or adapter that should
// receive framework logs.
type AppDataLogger[T AppData] func(data T) slog.LoggerWriter
// BotPayloadType defines the serialization format for callback data payloads. // BotPayloadType defines the serialization format for callback data payloads.
type BotPayloadType string type BotPayloadType string
@@ -78,7 +85,7 @@ var (
// //
// Runtime accessors are safe for concurrent use. Configure the bot before Run. // 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. // A Bot is single-use: after Run or RunWithContext returns, create a new Bot for the next session.
type Bot[T DbContext] struct { type Bot[T AppData] struct {
token string token string
debug bool debug bool
errorTemplate string errorTemplate string
@@ -98,11 +105,12 @@ type Bot[T DbContext] struct {
api *tgapi.API // Telegram API client api *tgapi.API // Telegram API client
uploader *tgapi.Uploader // File uploader uploader *tgapi.Uploader // File uploader
dbContext T // Injected database context l10n *L10n // Localization manager
hasDBContext bool draftProvider *DraftProvider // Draft message builder
warnedValueDB bool
l10n *L10n // Localization manager appData T // Injected application data
draftProvider *DraftProvider // Draft message builder hasAppData bool
warnedValueData bool
sessionStore SessionStore // Session store for scene management sessionStore SessionStore // Session store for scene management
sceneScopePriority []SceneScope sceneScopePriority []SceneScope
@@ -328,9 +336,9 @@ func (bot *Bot[T]) GetUpdateTypes() []tgapi.UpdateType {
// GetLogger returns the main bot logger. // GetLogger returns the main bot logger.
func (bot *Bot[T]) GetLogger() *slog.Logger { return bot.logger } func (bot *Bot[T]) GetLogger() *slog.Logger { return bot.logger }
// GetDBContext returns the injected database context. // GetAppData returns the injected application data.
// If DatabaseContext was not called, it returns the zero value of T. // If SetAppData was not called, it returns the zero value of T.
func (bot *Bot[T]) GetDBContext() T { return bot.dbContext } func (bot *Bot[T]) GetAppData() T { return bot.appData }
// GetLoggerLevel returns the effective log level derived from the bot's debug // GetLoggerLevel returns the effective log level derived from the bot's debug
// flag. // flag.
@@ -406,27 +414,30 @@ func (bot *Bot[T]) SetSceneScopePriority(priority []SceneScope) *Bot[T] {
return bot return bot
} }
// DatabaseContext injects a database context into the bot. // SetAppData injects shared application data into the bot.
// This context is accessible to plugins and middleware via GetDBContext(). //
// The data is accessible to commands, payload handlers, middleware, scenes,
// and runners through the generic type parameter T.
//
// For shared dependencies such as *sql.DB, prefer using a pointer type as T. // For shared dependencies such as *sql.DB, prefer using a pointer type as T.
// Value-typed contexts are supported, but the bot warns once because handlers // Value-typed application data is supported, but the bot warns once because
// receive T by value. // handlers receive T by value.
func (bot *Bot[T]) DatabaseContext(ctx T) *Bot[T] { func (bot *Bot[T]) SetAppData(ctx T) *Bot[T] {
if !bot.configMutable("DatabaseContext") { if !bot.configMutable("SetAppData") {
return bot return bot
} }
if !bot.warnedValueDB && shouldWarnOnValueDBContext[T]() && bot.logger != nil { if !bot.warnedValueData && shouldWarnOnValueAppData[T]() && bot.logger != nil {
bot.logger.Warnln("database context uses a value type; shared dependencies should usually use a pointer type as T") bot.logger.Warnln("app data uses a value type; shared dependencies should usually use a pointer type as T")
bot.warnedValueDB = true bot.warnedValueData = true
} }
bot.dbContext = ctx bot.appData = ctx
bot.hasDBContext = true bot.hasAppData = true
return bot return bot
} }
// UpdateTypes sets the list of update types the bot will request from Telegram. // SetUpdateTypes sets the list of update types the bot will request from Telegram.
// Overwrites any previously set types. // Overwrites any previously set types.
func (bot *Bot[T]) UpdateTypes(t ...tgapi.UpdateType) *Bot[T] { func (bot *Bot[T]) SetUpdateTypes(t ...tgapi.UpdateType) *Bot[T] {
if !bot.configMutable("UpdateTypes") { if !bot.configMutable("UpdateTypes") {
return bot return bot
} }
@@ -480,10 +491,10 @@ func (bot *Bot[T]) AddPrefixes(prefixes ...string) *Bot[T] {
return bot return bot
} }
// ErrorTemplate sets the format string for error messages sent to users. // SetErrorTemplate sets the format string for error messages sent to users.
// Use "%s" to insert the error message. // Use "%s" to insert the error message.
// Example: "❌ Error: %s" → "❌ Error: Command not found". // Example: "❌ Error: %s" → "❌ Error: Command not found".
func (bot *Bot[T]) ErrorTemplate(s string) *Bot[T] { func (bot *Bot[T]) SetErrorTemplate(s string) *Bot[T] {
if !bot.configMutable("ErrorTemplate") { if !bot.configMutable("ErrorTemplate") {
return bot return bot
} }
@@ -491,8 +502,8 @@ func (bot *Bot[T]) ErrorTemplate(s string) *Bot[T] {
return bot return bot
} }
// Debug enables or disables debug logging. // SetDebug enables or disables debug logging.
func (bot *Bot[T]) Debug(debug bool) *Bot[T] { func (bot *Bot[T]) SetDebug(debug bool) *Bot[T] {
bot.debug = debug bot.debug = debug
level := slog.FATAL level := slog.FATAL
if debug { if debug {
@@ -612,7 +623,7 @@ func (bot *Bot[T]) AddRunner(runner Runner[T]) *Bot[T] {
return bot return bot
} }
// AddL10n sets the localization (i18n) provider for the bot. // SetL10n sets the localization (i18n) provider for the bot.
// //
// The L10n instance must be pre-populated with translations. // The L10n instance must be pre-populated with translations.
// Translations are accessed via Bot.L10n(lang, key). // Translations are accessed via Bot.L10n(lang, key).
@@ -622,22 +633,22 @@ func (bot *Bot[T]) AddRunner(runner Runner[T]) *Bot[T] {
// l10n := l10n.New() // l10n := l10n.New()
// l10n.Add("en", "hello", "Hello!") // l10n.Add("en", "hello", "Hello!")
// l10n.Add("es", "hello", "¡Hola!") // l10n.Add("es", "hello", "¡Hola!")
// bot.AddL10n(l10n) // bot.SetL10n(l10n)
// //
// Replaces any previously set L10n instance. // Replaces any previously set L10n instance.
func (bot *Bot[T]) AddL10n(l *L10n) *Bot[T] { func (bot *Bot[T]) SetL10n(l *L10n) *Bot[T] {
if !bot.configMutable("AddL10n") { if !bot.configMutable("SetL10n") {
return bot return bot
} }
if l == nil { if l == nil {
bot.logger.Warn("AddL10n called with nil L10n; localization will be disabled") bot.logger.Warn("SetL10n called with nil L10n; localization will be disabled")
return bot return bot
} }
bot.l10n = l bot.l10n = l
return bot return bot
} }
// AddDatabaseLoggerWriter adds a database logger writer to all loggers. // AddAppDataLoggerWriter adds an app-data-backed logger writer to all loggers.
// //
// The writer will receive logs from: // The writer will receive logs from:
// - Main bot logger // - Main bot logger
@@ -647,23 +658,23 @@ func (bot *Bot[T]) AddL10n(l *L10n) *Bot[T] {
// //
// Call this after AddPlugins if plugin loggers should also receive the writer. // Call this after AddPlugins if plugin loggers should also receive the writer.
// Plugins registered later do not automatically inherit previously added // Plugins registered later do not automatically inherit previously added
// database writers; call AddDatabaseLoggerWriter again after adding them. // writers; call AddAppDataLoggerWriter again after adding them.
// //
// Example: // Example:
// //
// bot.AddDatabaseLoggerWriter(func(db *MyDB) slog.LoggerWriter { // bot.AddAppDataLoggerWriter(func(data *MyAppData) slog.LoggerWriter {
// return db.QueryLogger() // return data.QueryLogger()
// }) // })
func (bot *Bot[T]) AddDatabaseLoggerWriter(writer DbLogger[T]) *Bot[T] { func (bot *Bot[T]) AddAppDataLoggerWriter(writer AppDataLogger[T]) *Bot[T] {
if !bot.hasDBContext { if !bot.hasAppData {
bot.logger.Warnln("database context is not set; skipping database logger writer") bot.logger.Warnln("app data is not set; skipping app-data logger writer")
return bot return bot
} }
if isNilValue(bot.dbContext) { if isNilValue(bot.appData) {
bot.logger.Warnln("database context is nil; skipping database logger writer") bot.logger.Warnln("app data is nil; skipping app-data logger writer")
return bot return bot
} }
w := writer(bot.dbContext) w := writer(bot.appData)
bot.logger.AddWriter(w) bot.logger.AddWriter(w)
if bot.RequestLogger != nil { if bot.RequestLogger != nil {
bot.RequestLogger.AddWriter(w) bot.RequestLogger.AddWriter(w)
@@ -832,9 +843,9 @@ func isNilValue[T any](v T) bool {
} }
} }
func shouldWarnOnValueDBContext[T any]() bool { func shouldWarnOnValueAppData[T any]() bool {
t := reflect.TypeFor[T]() t := reflect.TypeFor[T]()
if t == reflect.TypeFor[NoDB]() { if t == reflect.TypeFor[NoData]() {
return false return false
} }
switch t.Kind() { switch t.Kind() {
@@ -845,7 +856,7 @@ func shouldWarnOnValueDBContext[T any]() bool {
} }
} }
func clonePlugin[T DbContext](p *Plugin[T]) Plugin[T] { func clonePlugin[T AppData](p *Plugin[T]) Plugin[T] {
cloned := Plugin[T]{ cloned := Plugin[T]{
name: p.name, name: p.name,
commands: make(map[string]*Command[T], len(p.commands)), commands: make(map[string]*Command[T], len(p.commands)),
@@ -872,7 +883,7 @@ func clonePlugin[T DbContext](p *Plugin[T]) Plugin[T] {
return cloned return cloned
} }
func cloneCommand[T DbContext](command *Command[T]) *Command[T] { func cloneCommand[T AppData](command *Command[T]) *Command[T] {
if command == nil { if command == nil {
return nil return nil
} }
@@ -883,7 +894,7 @@ func cloneCommand[T DbContext](command *Command[T]) *Command[T] {
return &cloned return &cloned
} }
func cloneScene[T DbContext](scene *Scene[T]) *Scene[T] { func cloneScene[T AppData](scene *Scene[T]) *Scene[T] {
if scene == nil { if scene == nil {
return nil return nil
} }
+51 -51
View File
@@ -13,7 +13,7 @@ import (
) )
func TestGetUpdateTypesReturnsCopy(t *testing.T) { func TestGetUpdateTypesReturnsCopy(t *testing.T) {
bot := &Bot[NoDB]{updateTypes: []tgapi.UpdateType{tgapi.UpdateTypeMessage}} bot := &Bot[NoData]{updateTypes: []tgapi.UpdateType{tgapi.UpdateTypeMessage}}
got := bot.GetUpdateTypes() got := bot.GetUpdateTypes()
got[0] = tgapi.UpdateTypeCallbackQuery got[0] = tgapi.UpdateTypeCallbackQuery
@@ -24,17 +24,17 @@ func TestGetUpdateTypesReturnsCopy(t *testing.T) {
} }
func TestAddPluginsSnapshotsConfiguration(t *testing.T) { func TestAddPluginsSnapshotsConfiguration(t *testing.T) {
bot := &Bot[NoDB]{logger: slog.CreateLogger()} bot := &Bot[NoData]{logger: slog.CreateLogger()}
plugin := NewPlugin[NoDB]("demo") plugin := NewPlugin[NoData]("demo")
cmd := plugin.NewCommand(func(ctx *MsgContext, db NoDB) error { return nil }, "start") cmd := plugin.NewCommand(func(ctx *MsgContext, db NoData) error { return nil }, "start")
plugin.AddMiddleware(NewMiddleware("base", func(ctx *MsgContext, db NoDB) bool { return true })) plugin.AddMiddleware(NewMiddleware("base", func(ctx *MsgContext, db NoData) bool { return true }))
bot.AddPlugins(plugin) bot.AddPlugins(plugin)
cmd.SetDescription("mutated after registration") cmd.SetDescription("mutated after registration")
plugin.NewCommand(func(ctx *MsgContext, db NoDB) error { return nil }, "late") plugin.NewCommand(func(ctx *MsgContext, db NoData) error { return nil }, "late")
plugin.AddMiddleware(NewMiddleware("late", func(ctx *MsgContext, db NoDB) bool { return true })) plugin.AddMiddleware(NewMiddleware("late", func(ctx *MsgContext, db NoData) bool { return true }))
registered := bot.plugins[0] registered := bot.plugins[0]
if _, exists := registered.commands["late"]; exists { if _, exists := registered.commands["late"]; exists {
@@ -49,7 +49,7 @@ func TestAddPluginsSnapshotsConfiguration(t *testing.T) {
} }
func TestBotPayloadTypeConfiguration(t *testing.T) { func TestBotPayloadTypeConfiguration(t *testing.T) {
bot := &Bot[NoDB]{payloadType: BotPayloadBase64} bot := &Bot[NoData]{payloadType: BotPayloadBase64}
if got := bot.GetPayloadType(); got != BotPayloadBase64 { if got := bot.GetPayloadType(); got != BotPayloadBase64 {
t.Fatalf("unexpected initial payload type: %q", got) t.Fatalf("unexpected initial payload type: %q", got)
@@ -65,8 +65,8 @@ func TestBotPayloadTypeConfiguration(t *testing.T) {
} }
func TestAddPluginsSkipsNilPlugin(t *testing.T) { func TestAddPluginsSkipsNilPlugin(t *testing.T) {
bot := &Bot[NoDB]{logger: slog.CreateLogger()} bot := &Bot[NoData]{logger: slog.CreateLogger()}
plugin := NewPlugin[NoDB]("demo") plugin := NewPlugin[NoData]("demo")
bot.AddPlugins(nil, plugin) bot.AddPlugins(nil, plugin)
@@ -79,7 +79,7 @@ func TestAddPluginsSkipsNilPlugin(t *testing.T) {
} }
func TestInitLoggersFallsBackToStdoutLoggerOnFileError(t *testing.T) { func TestInitLoggersFallsBackToStdoutLoggerOnFileError(t *testing.T) {
bot := &Bot[NoDB]{} bot := &Bot[NoData]{}
bot.initLoggers(&BotOpts{ bot.initLoggers(&BotOpts{
Debug: true, Debug: true,
@@ -122,39 +122,39 @@ func TestNextPollRetryDelay(t *testing.T) {
} }
} }
func TestAddDatabaseLoggerWriterSkipsWhenDBContextIsUnset(t *testing.T) { func TestAddDatabaseLoggerWriterSkipsWhenAppDataIsUnset(t *testing.T) {
bot := &Bot[NoDB]{logger: slog.CreateLogger()} bot := &Bot[NoData]{logger: slog.CreateLogger()}
called := false called := false
bot.AddDatabaseLoggerWriter(func(db NoDB) slog.LoggerWriter { bot.AddAppDataLoggerWriter(func(db NoData) slog.LoggerWriter {
called = true called = true
return nil return nil
}) })
if called { if called {
t.Fatal("expected database logger writer to be skipped when db context is unset") t.Fatal("expected app-data logger writer to be skipped when app data is unset")
} }
} }
func TestAddDatabaseLoggerWriterSkipsWhenDBContextIsNil(t *testing.T) { func TestAddDatabaseLoggerWriterSkipsWhenAppDataIsNil(t *testing.T) {
type testDB struct{} type testDB struct{}
bot := &Bot[*testDB]{logger: slog.CreateLogger()} bot := &Bot[*testDB]{logger: slog.CreateLogger()}
var db *testDB var db *testDB
bot.DatabaseContext(db) bot.SetAppData(db)
called := false called := false
bot.AddDatabaseLoggerWriter(func(db *testDB) slog.LoggerWriter { bot.AddAppDataLoggerWriter(func(db *testDB) slog.LoggerWriter {
called = true called = true
return nil return nil
}) })
if called { if called {
t.Fatal("expected database logger writer to be skipped when db context is nil") t.Fatal("expected app-data logger writer to be skipped when app data is nil")
} }
} }
func TestShouldWarnOnValueDBContext(t *testing.T) { func TestShouldWarnOnValueAppData(t *testing.T) {
type testDB struct{} type testDB struct{}
type dbIface interface{ Ping() error } type dbIface interface{ Ping() error }
@@ -163,36 +163,36 @@ func TestShouldWarnOnValueDBContext(t *testing.T) {
got bool got bool
want bool want bool
}{ }{
{name: "NoDB", got: shouldWarnOnValueDBContext[NoDB](), want: false}, {name: "NoData", got: shouldWarnOnValueAppData[NoData](), want: false},
{name: "pointer", got: shouldWarnOnValueDBContext[*testDB](), want: false}, {name: "pointer", got: shouldWarnOnValueAppData[*testDB](), want: false},
{name: "interface", got: shouldWarnOnValueDBContext[dbIface](), want: false}, {name: "interface", got: shouldWarnOnValueAppData[dbIface](), want: false},
{name: "map", got: shouldWarnOnValueDBContext[map[string]int](), want: false}, {name: "map", got: shouldWarnOnValueAppData[map[string]int](), want: false},
{name: "struct", got: shouldWarnOnValueDBContext[testDB](), want: true}, {name: "struct", got: shouldWarnOnValueAppData[testDB](), want: true},
{name: "int", got: shouldWarnOnValueDBContext[int](), want: true}, {name: "int", got: shouldWarnOnValueAppData[int](), want: true},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
if tt.got != tt.want { if tt.got != tt.want {
t.Fatalf("shouldWarnOnValueDBContext = %v, want %v", tt.got, tt.want) t.Fatalf("shouldWarnOnValueAppData = %v, want %v", tt.got, tt.want)
} }
}) })
} }
} }
func TestDatabaseContextMarksValueWarningOnce(t *testing.T) { func TestSetAppDataMarksValueWarningOnce(t *testing.T) {
type testDB struct{} type testDB struct{}
bot := &Bot[testDB]{logger: slog.CreateLogger()} bot := &Bot[testDB]{logger: slog.CreateLogger()}
bot.DatabaseContext(testDB{}) bot.SetAppData(testDB{})
if !bot.warnedValueDB { if !bot.warnedValueData {
t.Fatal("expected value-typed database context to mark warning state") t.Fatal("expected value-typed app data to mark warning state")
} }
ptrBot := &Bot[*testDB]{logger: slog.CreateLogger()} ptrBot := &Bot[*testDB]{logger: slog.CreateLogger()}
ptrBot.DatabaseContext(&testDB{}) ptrBot.SetAppData(&testDB{})
if ptrBot.warnedValueDB { if ptrBot.warnedValueData {
t.Fatal("did not expect pointer-typed database context to mark warning state") t.Fatal("did not expect pointer-typed app data to mark warning state")
} }
} }
@@ -200,10 +200,10 @@ func TestRunWithContextRejectsSecondRun(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
cancel() cancel()
bot := &Bot[NoDB]{ bot := &Bot[NoData]{
logger: slog.CreateLogger(), logger: slog.CreateLogger(),
prefixes: []string{"/"}, prefixes: []string{"/"},
plugins: []Plugin[NoDB]{{name: "demo"}}, plugins: []Plugin[NoData]{{name: "demo"}},
updateQueue: make(chan *tgapi.Update, 1), updateQueue: make(chan *tgapi.Update, 1),
maxWorkers: 1, maxWorkers: 1,
} }
@@ -235,23 +235,23 @@ func TestBotConfigurationFreezesAfterRunStarts(t *testing.T) {
} }
tests := []struct { tests := []struct {
name string name string
check func(t *testing.T, bot *Bot[*testDB]) check func(t *testing.T, bot *Bot[*testDB])
}{ }{
{ {
name: "DatabaseContext", name: "SetAppData",
check: func(t *testing.T, bot *Bot[*testDB]) { check: func(t *testing.T, bot *Bot[*testDB]) {
original := &testDB{Name: "before"} original := &testDB{Name: "before"}
bot.DatabaseContext(original) bot.SetAppData(original)
if err := bot.beginRun(); err != nil { if err := bot.beginRun(); err != nil {
t.Fatalf("beginRun returned error: %v", err) t.Fatalf("beginRun returned error: %v", err)
} }
t.Cleanup(bot.finishRun) t.Cleanup(bot.finishRun)
later := &testDB{Name: "after"} later := &testDB{Name: "after"}
bot.DatabaseContext(later) bot.SetAppData(later)
if bot.dbContext != original { if bot.appData != original {
t.Fatal("DatabaseContext mutated after configuration freeze") t.Fatal("SetAppData mutated after configuration freeze")
} }
}, },
}, },
@@ -264,7 +264,7 @@ func TestBotConfigurationFreezesAfterRunStarts(t *testing.T) {
} }
t.Cleanup(bot.finishRun) t.Cleanup(bot.finishRun)
bot.UpdateTypes(tgapi.UpdateTypePoll) bot.SetUpdateTypes(tgapi.UpdateTypePoll)
if !reflect.DeepEqual(bot.updateTypes, original) { if !reflect.DeepEqual(bot.updateTypes, original) {
t.Fatalf("UpdateTypes mutated after configuration freeze: got %v want %v", bot.updateTypes, original) t.Fatalf("UpdateTypes mutated after configuration freeze: got %v want %v", bot.updateTypes, original)
} }
@@ -336,7 +336,7 @@ func TestBotConfigurationFreezesAfterRunStarts(t *testing.T) {
} }
t.Cleanup(bot.finishRun) t.Cleanup(bot.finishRun)
bot.ErrorTemplate("changed") bot.SetErrorTemplate("changed")
if bot.errorTemplate != "%s" { if bot.errorTemplate != "%s" {
t.Fatalf("errorTemplate mutated after configuration freeze: got %q want %q", bot.errorTemplate, "%s") t.Fatalf("errorTemplate mutated after configuration freeze: got %q want %q", bot.errorTemplate, "%s")
} }
@@ -396,7 +396,7 @@ func TestBotConfigurationFreezesAfterRunStarts(t *testing.T) {
} }
t.Cleanup(bot.finishRun) t.Cleanup(bot.finishRun)
bot.AddL10n(&L10n{}) bot.SetL10n(&L10n{})
if bot.l10n != original { if bot.l10n != original {
t.Fatal("l10n mutated after configuration freeze") t.Fatal("l10n mutated after configuration freeze")
} }
@@ -412,13 +412,13 @@ func TestBotConfigurationFreezesAfterRunStarts(t *testing.T) {
} }
func TestAddPluginsAndRuntimeRegistrationsNoOpAfterRunStarts(t *testing.T) { func TestAddPluginsAndRuntimeRegistrationsNoOpAfterRunStarts(t *testing.T) {
bot := &Bot[NoDB]{ bot := &Bot[NoData]{
logger: slog.CreateLogger(), logger: slog.CreateLogger(),
prefixes: []string{"/"}, prefixes: []string{"/"},
middlewares: []Middleware[NoDB]{NewMiddleware("base", func(ctx *MsgContext, db NoDB) bool { return true })}, middlewares: []Middleware[NoData]{NewMiddleware("base", func(ctx *MsgContext, db NoData) bool { return true })},
runners: []Runner[NoDB]{NewRunner("base", func(bot *Bot[NoDB]) error { return nil })}, runners: []Runner[NoData]{NewRunner("base", func(bot *Bot[NoData]) error { return nil })},
} }
plugin := NewPlugin[NoDB]("late") plugin := NewPlugin[NoData]("late")
if err := bot.beginRun(); err != nil { if err := bot.beginRun(); err != nil {
t.Fatalf("beginRun returned error: %v", err) t.Fatalf("beginRun returned error: %v", err)
@@ -426,8 +426,8 @@ func TestAddPluginsAndRuntimeRegistrationsNoOpAfterRunStarts(t *testing.T) {
defer bot.finishRun() defer bot.finishRun()
bot.AddPlugins(plugin) bot.AddPlugins(plugin)
bot.AddMiddleware(NewMiddleware("late", func(ctx *MsgContext, db NoDB) bool { return true })) bot.AddMiddleware(NewMiddleware("late", func(ctx *MsgContext, db NoData) bool { return true }))
bot.AddRunner(NewRunner("late", func(bot *Bot[NoDB]) error { return nil })) bot.AddRunner(NewRunner("late", func(bot *Bot[NoData]) error { return nil }))
if len(bot.plugins) != 0 { if len(bot.plugins) != 0 {
t.Fatalf("expected AddPlugins to be ignored after configuration freeze, got %d plugins", len(bot.plugins)) t.Fatalf("expected AddPlugins to be ignored after configuration freeze, got %d plugins", len(bot.plugins))
+6 -6
View File
@@ -43,16 +43,16 @@ func TestAutoGenerateCommandsChecksLimitBeforeDelete(t *testing.T) {
} }
}() }()
plugin := NewPlugin[NoDB]("overflow") plugin := NewPlugin[NoData]("overflow")
exec := func(ctx *MsgContext, db NoDB) error { return nil } exec := func(ctx *MsgContext, db NoData) error { return nil }
for i := 0; i < 101; i++ { for i := 0; i < 101; i++ {
plugin.AddCommand(NewCommand(exec, "cmd"+strconv.Itoa(i))) plugin.AddCommand(NewCommand(exec, "cmd"+strconv.Itoa(i)))
} }
bot := &Bot[NoDB]{ bot := &Bot[NoData]{
api: api, api: api,
logger: slog.CreateLogger(), logger: slog.CreateLogger(),
plugins: []Plugin[NoDB]{*plugin}, plugins: []Plugin[NoData]{*plugin},
} }
err := bot.AutoGenerateCommands() err := bot.AutoGenerateCommands()
@@ -65,8 +65,8 @@ func TestAutoGenerateCommandsChecksLimitBeforeDelete(t *testing.T) {
} }
func TestGatherCommandsForPluginReturnsSortedCommands(t *testing.T) { func TestGatherCommandsForPluginReturnsSortedCommands(t *testing.T) {
plugin := NewPlugin[NoDB]("sorted") plugin := NewPlugin[NoData]("sorted")
exec := func(ctx *MsgContext, db NoDB) error { return nil } exec := func(ctx *MsgContext, db NoData) error { return nil }
plugin.AddCommand(NewCommand(exec, "zeta")) plugin.AddCommand(NewCommand(exec, "zeta"))
plugin.AddCommand(NewCommand(exec, "alpha")) plugin.AddCommand(NewCommand(exec, "alpha"))
+3 -3
View File
@@ -13,17 +13,17 @@ Core concepts:
Example usage: Example usage:
bot, err := laniakea.NewBot[*mydb.DBContext](laniakea.LoadOptsFromEnv()) bot, err := laniakea.NewBot[*mydb.AppData](laniakea.LoadOptsFromEnv())
if err != nil { if err != nil {
return err return err
} }
bot.DatabaseContext(myDB). bot.SetAppData(myDB).
AddUpdateType(tgapi.UpdateTypeMessage). AddUpdateType(tgapi.UpdateTypeMessage).
AddPrefixes("/", "!"). AddPrefixes("/", "!").
AddPlugins(&startPlugin, &helpPlugin). AddPlugins(&startPlugin, &helpPlugin).
AddMiddleware(authMiddleware, logMiddleware). AddMiddleware(authMiddleware, logMiddleware).
AddRunner(cleanupRunner). AddRunner(cleanupRunner).
AddL10n(l10n.New()) SetL10n(l10n.New())
return bot.Run() return bot.Run()
+7 -7
View File
@@ -37,7 +37,7 @@ func (bot *Bot[T]) handle(parentCtx context.Context, u *tgapi.Update) {
bot.prepareUpdateCtx(u, msgCtx) bot.prepareUpdateCtx(u, msgCtx)
for _, middleware := range bot.middlewares { for _, middleware := range bot.middlewares {
if !middleware.Execute(msgCtx, bot.dbContext) { if !middleware.Execute(msgCtx, bot.appData) {
return return
} }
} }
@@ -102,10 +102,10 @@ func (bot *Bot[T]) handleMessage(update *tgapi.Update, ctx *MsgContext) {
if plugin.logger != nil { if plugin.logger != nil {
ctx.Logger = plugin.logger ctx.Logger = plugin.logger
} }
if !plugin.executeMiddlewares(ctx, bot.dbContext) { if !plugin.executeMiddlewares(ctx, bot.appData) {
return return
} }
plugin.executeCmd(cmd, ctx, bot.dbContext) plugin.executeCmd(cmd, ctx, bot.appData)
return return
} }
} }
@@ -130,10 +130,10 @@ func (bot *Bot[T]) handleCallback(update *tgapi.Update, ctx *MsgContext) {
if ctx.Logger == nil { if ctx.Logger == nil {
ctx.Logger = bot.logger ctx.Logger = bot.logger
} }
if !plugin.executeMiddlewares(ctx, bot.dbContext) { if !plugin.executeMiddlewares(ctx, bot.appData) {
return return
} }
plugin.executePayload(data.Command, ctx, bot.dbContext) plugin.executePayload(data.Command, ctx, bot.appData)
return return
} }
} }
@@ -149,10 +149,10 @@ func (bot *Bot[T]) handleUpdate(u *tgapi.Update, ctx *MsgContext) {
if plugin.logger != nil { if plugin.logger != nil {
pluginCtx.Logger = plugin.logger pluginCtx.Logger = plugin.logger
} }
if !plugin.executeMiddlewares(pluginCtx, bot.dbContext) { if !plugin.executeMiddlewares(pluginCtx, bot.appData) {
continue continue
} }
if err := handler(pluginCtx, bot.dbContext); err != nil { if err := handler(pluginCtx, bot.appData); err != nil {
pluginCtx.error(err) pluginCtx.error(err)
} }
} }
+68 -68
View File
@@ -13,7 +13,7 @@ func ptr[T any](v T) *T {
} }
func TestCheckPrefixesSkipsEmptyPrefixes(t *testing.T) { func TestCheckPrefixesSkipsEmptyPrefixes(t *testing.T) {
bot := &Bot[NoDB]{prefixes: []string{"", "/"}} bot := &Bot[NoData]{prefixes: []string{"", "/"}}
if prefix, ok := bot.checkPrefixes("hello"); ok { if prefix, ok := bot.checkPrefixes("hello"); ok {
t.Fatalf("unexpected prefix match for plain text: %q", prefix) t.Fatalf("unexpected prefix match for plain text: %q", prefix)
@@ -27,10 +27,10 @@ func TestBotMiddlewareReceivesLogger(t *testing.T) {
logger := slog.CreateLogger() logger := slog.CreateLogger()
called := false called := false
bot := &Bot[NoDB]{ bot := &Bot[NoData]{
logger: logger, logger: logger,
middlewares: []Middleware[NoDB]{ middlewares: []Middleware[NoData]{
NewMiddleware("logger-check", func(ctx *MsgContext, db NoDB) bool { NewMiddleware("logger-check", func(ctx *MsgContext, db NoData) bool {
called = true called = true
if ctx.Logger != logger { if ctx.Logger != logger {
t.Fatalf("expected bot logger in middleware context, got %#v", ctx.Logger) t.Fatalf("expected bot logger in middleware context, got %#v", ctx.Logger)
@@ -55,8 +55,8 @@ func TestBotMiddlewareReceivesLogger(t *testing.T) {
} }
func TestAddUpdateHandlerRejectsReservedUpdateTypes(t *testing.T) { func TestAddUpdateHandlerRejectsReservedUpdateTypes(t *testing.T) {
plugin := NewPlugin[NoDB]("test") plugin := NewPlugin[NoData]("test")
handler := func(ctx *MsgContext, db NoDB) error { return nil } handler := func(ctx *MsgContext, db NoData) error { return nil }
for _, updateType := range []tgapi.UpdateType{ for _, updateType := range []tgapi.UpdateType{
tgapi.UpdateTypeMessage, tgapi.UpdateTypeMessage,
@@ -80,14 +80,14 @@ func TestAddUpdateHandlerRejectsReservedUpdateTypes(t *testing.T) {
func TestPrepareUpdateCtxContract(t *testing.T) { func TestPrepareUpdateCtxContract(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
update *tgapi.Update update *tgapi.Update
wantMsg bool wantMsg bool
wantFrom bool wantFrom bool
wantFromID int64 wantFromID int64
wantCallbackID string wantCallbackID string
wantCallbackMsgID int wantCallbackMsgID int
wantInlineMsgID string wantInlineMsgID string
}{ }{
{ {
name: "message", name: "message",
@@ -145,7 +145,7 @@ func TestPrepareUpdateCtxContract(t *testing.T) {
{ {
name: "inline query", name: "inline query",
update: &tgapi.Update{ update: &tgapi.Update{
Type: tgapi.UpdateTypeInlineQuery, Type: tgapi.UpdateTypeInlineQuery,
InlineQuery: &tgapi.InlineQuery{ID: "iq", From: tgapi.User{ID: 104}}, InlineQuery: &tgapi.InlineQuery{ID: "iq", From: tgapi.User{ID: 104}},
}, },
wantFrom: true, wantFrom: true,
@@ -154,7 +154,7 @@ func TestPrepareUpdateCtxContract(t *testing.T) {
{ {
name: "chosen inline result", name: "chosen inline result",
update: &tgapi.Update{ update: &tgapi.Update{
Type: tgapi.UpdateTypeChosenInlineResult, Type: tgapi.UpdateTypeChosenInlineResult,
ChosenInlineResult: &tgapi.ChosenInlineResult{ResultID: "res", From: tgapi.User{ID: 105}}, ChosenInlineResult: &tgapi.ChosenInlineResult{ResultID: "res", From: tgapi.User{ID: 105}},
}, },
wantFrom: true, wantFrom: true,
@@ -189,15 +189,15 @@ func TestPrepareUpdateCtxContract(t *testing.T) {
InlineMessageID: ptr("inline-42"), InlineMessageID: ptr("inline-42"),
}, },
}, },
wantFrom: true, wantFrom: true,
wantFromID: 107, wantFromID: 107,
wantCallbackID: "cb-2", wantCallbackID: "cb-2",
wantInlineMsgID:"inline-42", wantInlineMsgID: "inline-42",
}, },
{ {
name: "shipping query", name: "shipping query",
update: &tgapi.Update{ update: &tgapi.Update{
Type: tgapi.UpdateTypeShippingQuery, Type: tgapi.UpdateTypeShippingQuery,
ShippingQuery: &tgapi.ShippingQuery{ID: "ship", From: tgapi.User{ID: 108}}, ShippingQuery: &tgapi.ShippingQuery{ID: "ship", From: tgapi.User{ID: 108}},
}, },
wantFrom: true, wantFrom: true,
@@ -206,7 +206,7 @@ func TestPrepareUpdateCtxContract(t *testing.T) {
{ {
name: "pre checkout query", name: "pre checkout query",
update: &tgapi.Update{ update: &tgapi.Update{
Type: tgapi.UpdateTypePreCheckoutQuery, Type: tgapi.UpdateTypePreCheckoutQuery,
PreCheckoutQuery: &tgapi.PreCheckoutQuery{ID: "pre", From: tgapi.User{ID: 109}}, PreCheckoutQuery: &tgapi.PreCheckoutQuery{ID: "pre", From: tgapi.User{ID: 109}},
}, },
wantFrom: true, wantFrom: true,
@@ -215,7 +215,7 @@ func TestPrepareUpdateCtxContract(t *testing.T) {
{ {
name: "purchased paid media", name: "purchased paid media",
update: &tgapi.Update{ update: &tgapi.Update{
Type: tgapi.UpdateTypePurchasedPaidMedia, Type: tgapi.UpdateTypePurchasedPaidMedia,
PurchasedPaidMedia: &tgapi.PaidMediaPurchased{From: tgapi.User{ID: 110}}, PurchasedPaidMedia: &tgapi.PaidMediaPurchased{From: tgapi.User{ID: 110}},
}, },
wantFrom: true, wantFrom: true,
@@ -224,7 +224,7 @@ func TestPrepareUpdateCtxContract(t *testing.T) {
{ {
name: "my chat member", name: "my chat member",
update: &tgapi.Update{ update: &tgapi.Update{
Type: tgapi.UpdateTypeMyChatMember, Type: tgapi.UpdateTypeMyChatMember,
MyChatMember: &tgapi.ChatMemberUpdated{From: tgapi.User{ID: 111}}, MyChatMember: &tgapi.ChatMemberUpdated{From: tgapi.User{ID: 111}},
}, },
wantFrom: true, wantFrom: true,
@@ -233,7 +233,7 @@ func TestPrepareUpdateCtxContract(t *testing.T) {
{ {
name: "chat member", name: "chat member",
update: &tgapi.Update{ update: &tgapi.Update{
Type: tgapi.UpdateTypeChatMember, Type: tgapi.UpdateTypeChatMember,
ChatMember: &tgapi.ChatMemberUpdated{From: tgapi.User{ID: 112}}, ChatMember: &tgapi.ChatMemberUpdated{From: tgapi.User{ID: 112}},
}, },
wantFrom: true, wantFrom: true,
@@ -242,7 +242,7 @@ func TestPrepareUpdateCtxContract(t *testing.T) {
{ {
name: "chat join request", name: "chat join request",
update: &tgapi.Update{ update: &tgapi.Update{
Type: tgapi.UpdateTypeChatJoinRequest, Type: tgapi.UpdateTypeChatJoinRequest,
ChatJoinRequest: &tgapi.ChatJoinRequest{From: tgapi.User{ID: 113}}, ChatJoinRequest: &tgapi.ChatJoinRequest{From: tgapi.User{ID: 113}},
}, },
wantFrom: true, wantFrom: true,
@@ -251,7 +251,7 @@ func TestPrepareUpdateCtxContract(t *testing.T) {
{ {
name: "business connection", name: "business connection",
update: &tgapi.Update{ update: &tgapi.Update{
Type: tgapi.UpdateTypeBusinessConnection, Type: tgapi.UpdateTypeBusinessConnection,
BusinessConnection: &tgapi.BusinessConnection{User: tgapi.User{ID: 114}}, BusinessConnection: &tgapi.BusinessConnection{User: tgapi.User{ID: 114}},
}, },
wantFrom: true, wantFrom: true,
@@ -260,7 +260,7 @@ func TestPrepareUpdateCtxContract(t *testing.T) {
{ {
name: "poll answer", name: "poll answer",
update: &tgapi.Update{ update: &tgapi.Update{
Type: tgapi.UpdateTypePollAnswer, Type: tgapi.UpdateTypePollAnswer,
PollAnswer: &tgapi.PollAnswer{User: tgapi.User{ID: 115}}, PollAnswer: &tgapi.PollAnswer{User: tgapi.User{ID: 115}},
}, },
wantFrom: true, wantFrom: true,
@@ -269,7 +269,7 @@ func TestPrepareUpdateCtxContract(t *testing.T) {
{ {
name: "message reaction", name: "message reaction",
update: &tgapi.Update{ update: &tgapi.Update{
Type: tgapi.UpdateTypeMessageReaction, Type: tgapi.UpdateTypeMessageReaction,
MessageReaction: &tgapi.MessageReactionUpdated{User: &tgapi.User{ID: 116}}, MessageReaction: &tgapi.MessageReactionUpdated{User: &tgapi.User{ID: 116}},
}, },
wantFrom: true, wantFrom: true,
@@ -307,7 +307,7 @@ func TestPrepareUpdateCtxContract(t *testing.T) {
{ {
name: "message reaction count", name: "message reaction count",
update: &tgapi.Update{ update: &tgapi.Update{
Type: tgapi.UpdateTypeMessageReactionCount, Type: tgapi.UpdateTypeMessageReactionCount,
MessageReactionCount: &tgapi.MessageReactionCountUpdated{}, MessageReactionCount: &tgapi.MessageReactionCountUpdated{},
}, },
}, },
@@ -315,7 +315,7 @@ func TestPrepareUpdateCtxContract(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
bot := &Bot[NoDB]{} bot := &Bot[NoData]{}
ctx := &MsgContext{} ctx := &MsgContext{}
bot.prepareUpdateCtx(tt.update, ctx) bot.prepareUpdateCtx(tt.update, ctx)
@@ -384,7 +384,7 @@ func TestHandleUpdateHandlersPopulateFromContext(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
called := false called := false
plugin := NewPlugin[NoDB]("test").AddUpdateHandler(tt.update.Type, func(ctx *MsgContext, db NoDB) error { plugin := NewPlugin[NoData]("test").AddUpdateHandler(tt.update.Type, func(ctx *MsgContext, db NoData) error {
called = true called = true
if ctx.Update.UpdateID != tt.update.UpdateID { if ctx.Update.UpdateID != tt.update.UpdateID {
t.Fatalf("unexpected update in context: got %d want %d", ctx.Update.UpdateID, tt.update.UpdateID) t.Fatalf("unexpected update in context: got %d want %d", ctx.Update.UpdateID, tt.update.UpdateID)
@@ -404,9 +404,9 @@ func TestHandleUpdateHandlersPopulateFromContext(t *testing.T) {
return nil return nil
}) })
bot := &Bot[NoDB]{ bot := &Bot[NoData]{
logger: slog.CreateLogger(), logger: slog.CreateLogger(),
plugins: []Plugin[NoDB]{clonePlugin(plugin)}, plugins: []Plugin[NoData]{clonePlugin(plugin)},
} }
bot.handle(context.Background(), tt.update) bot.handle(context.Background(), tt.update)
@@ -422,7 +422,7 @@ func TestHandleUpdateHandlersReceiveIsolatedContexts(t *testing.T) {
firstCalled := false firstCalled := false
secondCalled := false secondCalled := false
first := NewPlugin[NoDB]("first").AddUpdateHandler(tgapi.UpdateTypeInlineQuery, func(ctx *MsgContext, db NoDB) error { first := NewPlugin[NoData]("first").AddUpdateHandler(tgapi.UpdateTypeInlineQuery, func(ctx *MsgContext, db NoData) error {
firstCalled = true firstCalled = true
if ctx.FromID != 41 { if ctx.FromID != 41 {
t.Fatalf("unexpected FromID in first handler: got %d want 41", ctx.FromID) t.Fatalf("unexpected FromID in first handler: got %d want 41", ctx.FromID)
@@ -433,7 +433,7 @@ func TestHandleUpdateHandlersReceiveIsolatedContexts(t *testing.T) {
ctx.Args = []string{"mutated"} ctx.Args = []string{"mutated"}
return nil return nil
}) })
second := NewPlugin[NoDB]("second").AddUpdateHandler(tgapi.UpdateTypeInlineQuery, func(ctx *MsgContext, db NoDB) error { second := NewPlugin[NoData]("second").AddUpdateHandler(tgapi.UpdateTypeInlineQuery, func(ctx *MsgContext, db NoData) error {
secondCalled = true secondCalled = true
if ctx.From == nil { if ctx.From == nil {
t.Fatal("expected ctx.From to remain populated for second handler") t.Fatal("expected ctx.From to remain populated for second handler")
@@ -450,9 +450,9 @@ func TestHandleUpdateHandlersReceiveIsolatedContexts(t *testing.T) {
return nil return nil
}) })
bot := &Bot[NoDB]{ bot := &Bot[NoData]{
logger: slog.CreateLogger(), logger: slog.CreateLogger(),
plugins: []Plugin[NoDB]{ plugins: []Plugin[NoData]{
clonePlugin(first), clonePlugin(first),
clonePlugin(second), clonePlugin(second),
}, },
@@ -475,8 +475,8 @@ func TestHandleUpdateHandlersReceiveIsolatedContexts(t *testing.T) {
func TestHandleChannelPostCommandWithSenderChat(t *testing.T) { func TestHandleChannelPostCommandWithSenderChat(t *testing.T) {
called := false called := false
plugin := NewPlugin[NoDB]("test") plugin := NewPlugin[NoData]("test")
plugin.NewCommand(func(ctx *MsgContext, db NoDB) error { plugin.NewCommand(func(ctx *MsgContext, db NoData) error {
called = true called = true
if ctx.Msg == nil { if ctx.Msg == nil {
t.Fatal("expected message context") t.Fatal("expected message context")
@@ -493,10 +493,10 @@ func TestHandleChannelPostCommandWithSenderChat(t *testing.T) {
return nil return nil
}, "ping") }, "ping")
bot := &Bot[NoDB]{ bot := &Bot[NoData]{
logger: slog.CreateLogger(), logger: slog.CreateLogger(),
prefixes: []string{"/"}, prefixes: []string{"/"},
plugins: []Plugin[NoDB]{clonePlugin(plugin)}, plugins: []Plugin[NoData]{clonePlugin(plugin)},
} }
bot.handle(context.Background(), &tgapi.Update{ bot.handle(context.Background(), &tgapi.Update{
@@ -522,18 +522,18 @@ func TestCommandHandlerBindArgsEndToEnd(t *testing.T) {
} }
var got banInput var got banInput
plugin := NewPlugin[NoDB]("test") plugin := NewPlugin[NoData]("test")
plugin.NewCommand(func(ctx *MsgContext, db NoDB) error { plugin.NewCommand(func(ctx *MsgContext, db NoData) error {
return ctx.BindArgs(&got) return ctx.BindArgs(&got)
}, "ban", }, "ban",
NewCommandArg("user_id").SetValueType(CommandValueIntType).SetRequired(), NewCommandArg("user_id").SetValueType(CommandValueIntType).SetRequired(),
NewCommandArg("reason").SetRequired(), NewCommandArg("reason").SetRequired(),
) )
bot := &Bot[NoDB]{ bot := &Bot[NoData]{
logger: slog.CreateLogger(), logger: slog.CreateLogger(),
prefixes: []string{"/"}, prefixes: []string{"/"},
plugins: []Plugin[NoDB]{clonePlugin(plugin)}, plugins: []Plugin[NoData]{clonePlugin(plugin)},
} }
bot.handle(context.Background(), &tgapi.Update{ bot.handle(context.Background(), &tgapi.Update{
@@ -559,18 +559,18 @@ func TestPayloadHandlerBindArgsEndToEnd(t *testing.T) {
} }
var got payloadInput var got payloadInput
plugin := NewPlugin[NoDB]("test") plugin := NewPlugin[NoData]("test")
plugin.NewPayload(func(ctx *MsgContext, db NoDB) error { plugin.NewPayload(func(ctx *MsgContext, db NoData) error {
return ctx.BindArgs(&got) return ctx.BindArgs(&got)
}, "approve", }, "approve",
NewCommandArg("id").SetValueType(CommandValueIntType).SetRequired(), NewCommandArg("id").SetValueType(CommandValueIntType).SetRequired(),
NewCommandArg("note").SetRequired(), NewCommandArg("note").SetRequired(),
) )
bot := &Bot[NoDB]{ bot := &Bot[NoData]{
logger: slog.CreateLogger(), logger: slog.CreateLogger(),
payloadType: BotPayloadJson, payloadType: BotPayloadJson,
plugins: []Plugin[NoDB]{clonePlugin(plugin)}, plugins: []Plugin[NoData]{clonePlugin(plugin)},
} }
data, err := encodeJsonPayload(CallbackData{ data, err := encodeJsonPayload(CallbackData{
@@ -601,12 +601,12 @@ func TestHandleEditedMessageStaysOutOfCommandFlow(t *testing.T) {
commandCalled := false commandCalled := false
updateCalled := false updateCalled := false
plugin := NewPlugin[NoDB]("test") plugin := NewPlugin[NoData]("test")
plugin.NewCommand(func(ctx *MsgContext, db NoDB) error { plugin.NewCommand(func(ctx *MsgContext, db NoData) error {
commandCalled = true commandCalled = true
return nil return nil
}, "ping") }, "ping")
plugin.AddUpdateHandler(tgapi.UpdateTypeEditedMessage, func(ctx *MsgContext, db NoDB) error { plugin.AddUpdateHandler(tgapi.UpdateTypeEditedMessage, func(ctx *MsgContext, db NoData) error {
updateCalled = true updateCalled = true
if ctx.Msg == nil { if ctx.Msg == nil {
t.Fatal("expected ctx.Msg in edited message handler") t.Fatal("expected ctx.Msg in edited message handler")
@@ -620,10 +620,10 @@ func TestHandleEditedMessageStaysOutOfCommandFlow(t *testing.T) {
return nil return nil
}) })
bot := &Bot[NoDB]{ bot := &Bot[NoData]{
logger: slog.CreateLogger(), logger: slog.CreateLogger(),
prefixes: []string{"/"}, prefixes: []string{"/"},
plugins: []Plugin[NoDB]{clonePlugin(plugin)}, plugins: []Plugin[NoData]{clonePlugin(plugin)},
} }
bot.handle(context.Background(), &tgapi.Update{ bot.handle(context.Background(), &tgapi.Update{
@@ -649,12 +649,12 @@ func TestHandleEditedChannelPostStaysOutOfCommandFlow(t *testing.T) {
commandCalled := false commandCalled := false
updateCalled := false updateCalled := false
plugin := NewPlugin[NoDB]("test") plugin := NewPlugin[NoData]("test")
plugin.NewCommand(func(ctx *MsgContext, db NoDB) error { plugin.NewCommand(func(ctx *MsgContext, db NoData) error {
commandCalled = true commandCalled = true
return nil return nil
}, "ping") }, "ping")
plugin.AddUpdateHandler(tgapi.UpdateTypeEditedChannelPost, func(ctx *MsgContext, db NoDB) error { plugin.AddUpdateHandler(tgapi.UpdateTypeEditedChannelPost, func(ctx *MsgContext, db NoData) error {
updateCalled = true updateCalled = true
if ctx.Msg == nil { if ctx.Msg == nil {
t.Fatal("expected ctx.Msg in edited channel post handler") t.Fatal("expected ctx.Msg in edited channel post handler")
@@ -662,10 +662,10 @@ func TestHandleEditedChannelPostStaysOutOfCommandFlow(t *testing.T) {
return nil return nil
}) })
bot := &Bot[NoDB]{ bot := &Bot[NoData]{
logger: slog.CreateLogger(), logger: slog.CreateLogger(),
prefixes: []string{"/"}, prefixes: []string{"/"},
plugins: []Plugin[NoDB]{clonePlugin(plugin)}, plugins: []Plugin[NoData]{clonePlugin(plugin)},
} }
bot.handle(context.Background(), &tgapi.Update{ bot.handle(context.Background(), &tgapi.Update{
@@ -688,8 +688,8 @@ func TestHandleEditedChannelPostStaysOutOfCommandFlow(t *testing.T) {
func TestHandleCallbackPopulatesMessageTargets(t *testing.T) { func TestHandleCallbackPopulatesMessageTargets(t *testing.T) {
called := false called := false
plugin := NewPlugin[NoDB]("test") plugin := NewPlugin[NoData]("test")
plugin.NewPayload(func(ctx *MsgContext, db NoDB) error { plugin.NewPayload(func(ctx *MsgContext, db NoData) error {
called = true called = true
if ctx.CallbackQueryId != "cb-msg" { if ctx.CallbackQueryId != "cb-msg" {
t.Fatalf("unexpected CallbackQueryId: %q", ctx.CallbackQueryId) t.Fatalf("unexpected CallbackQueryId: %q", ctx.CallbackQueryId)
@@ -715,10 +715,10 @@ func TestHandleCallbackPopulatesMessageTargets(t *testing.T) {
return nil return nil
}, "approve") }, "approve")
bot := &Bot[NoDB]{ bot := &Bot[NoData]{
logger: slog.CreateLogger(), logger: slog.CreateLogger(),
payloadType: BotPayloadJson, payloadType: BotPayloadJson,
plugins: []Plugin[NoDB]{clonePlugin(plugin)}, plugins: []Plugin[NoData]{clonePlugin(plugin)},
} }
data, err := encodeJsonPayload(CallbackData{Command: "approve", Args: []string{"7", "ok"}}) data, err := encodeJsonPayload(CallbackData{Command: "approve", Args: []string{"7", "ok"}})
@@ -747,8 +747,8 @@ func TestHandleCallbackPopulatesMessageTargets(t *testing.T) {
func TestHandleCallbackPopulatesInlineTargets(t *testing.T) { func TestHandleCallbackPopulatesInlineTargets(t *testing.T) {
called := false called := false
plugin := NewPlugin[NoDB]("test") plugin := NewPlugin[NoData]("test")
plugin.NewPayload(func(ctx *MsgContext, db NoDB) error { plugin.NewPayload(func(ctx *MsgContext, db NoData) error {
called = true called = true
if ctx.CallbackQueryId != "cb-inline" { if ctx.CallbackQueryId != "cb-inline" {
t.Fatalf("unexpected CallbackQueryId: %q", ctx.CallbackQueryId) t.Fatalf("unexpected CallbackQueryId: %q", ctx.CallbackQueryId)
@@ -774,10 +774,10 @@ func TestHandleCallbackPopulatesInlineTargets(t *testing.T) {
return nil return nil
}, "inline.approve") }, "inline.approve")
bot := &Bot[NoDB]{ bot := &Bot[NoData]{
logger: slog.CreateLogger(), logger: slog.CreateLogger(),
payloadType: BotPayloadJson, payloadType: BotPayloadJson,
plugins: []Plugin[NoDB]{clonePlugin(plugin)}, plugins: []Plugin[NoData]{clonePlugin(plugin)},
} }
data, err := encodeJsonPayload(CallbackData{Command: "inline.approve", Args: []string{"9"}}) data, err := encodeJsonPayload(CallbackData{Command: "inline.approve", Args: []string{"9"}})
+8 -8
View File
@@ -85,13 +85,13 @@ func (c CommandArg) SetRequired() CommandArg {
} }
// CommandExecutor is the function type that executes a command. // CommandExecutor is the function type that executes a command.
// It receives the message context and a database context (generic). // It receives the message context and injected application data.
// Returning a non-nil error routes it through the bot's error handler. // Returning a non-nil error routes it through the bot's error handler.
type CommandExecutor[T DbContext] func(ctx *MsgContext, dbContext T) error type CommandExecutor[T AppData] func(ctx *MsgContext, dbContext T) error
// Command represents a bot command with arguments, description, and executor. // Command represents a bot command with arguments, description, and executor.
// Can be registered in a Plugin and optionally skipped from auto-generation. // Can be registered in a Plugin and optionally skipped from auto-generation.
type Command[T DbContext] struct { type Command[T AppData] struct {
command string // The command trigger (e.g., "/start") command string // The command trigger (e.g., "/start")
description string // Human-readable description for help description string // Human-readable description for help
exec CommandExecutor[T] // Function to execute when command is triggered exec CommandExecutor[T] // Function to execute when command is triggered
@@ -162,7 +162,7 @@ func (c *Command[T]) validateArgs(args []string) error {
// A Plugin is intended to be fully configured before it is passed to Bot.AddPlugins. // A Plugin is intended to be fully configured before it is passed to Bot.AddPlugins.
// After registration, treat the plugin as committed and do not mutate it further. // After registration, treat the plugin as committed and do not mutate it further.
// Post-registration changes through the original *Plugin are not a supported API. // Post-registration changes through the original *Plugin are not a supported API.
type Plugin[T DbContext] struct { type Plugin[T AppData] struct {
name string // Name of the plugin (e.g., "admin", "user") name string // Name of the plugin (e.g., "admin", "user")
commands map[string]*Command[T] // Registered commands (triggered by message) commands map[string]*Command[T] // Registered commands (triggered by message)
payloads map[string]*Command[T] // Registered payloads (triggered by callback data) payloads map[string]*Command[T] // Registered payloads (triggered by callback data)
@@ -177,7 +177,7 @@ type Plugin[T DbContext] struct {
} }
// NewPlugin creates a new Plugin with the given name. // NewPlugin creates a new Plugin with the given name.
func NewPlugin[T DbContext](name string) *Plugin[T] { func NewPlugin[T AppData](name string) *Plugin[T] {
return &Plugin[T]{ return &Plugin[T]{
name: name, name: name,
commands: make(map[string]*Command[T]), commands: make(map[string]*Command[T]),
@@ -380,11 +380,11 @@ func (p *Plugin[T]) executeMiddlewares(ctx *MsgContext, db T) bool {
// MiddlewareExecutor is the function type for middleware logic. // MiddlewareExecutor is the function type for middleware logic.
// Returns true to continue execution, false to block it. // Returns true to continue execution, false to block it.
// If async, return value is ignored. // If async, return value is ignored.
type MiddlewareExecutor[T DbContext] func(ctx *MsgContext, db T) bool type MiddlewareExecutor[T AppData] func(ctx *MsgContext, db T) bool
// Middleware represents a reusable execution interceptor. // Middleware represents a reusable execution interceptor.
// Can be synchronous (blocking) or asynchronous (non-blocking). // Can be synchronous (blocking) or asynchronous (non-blocking).
type Middleware[T DbContext] struct { type Middleware[T AppData] struct {
name string // Human-readable name for logging/debugging name string // Human-readable name for logging/debugging
executor MiddlewareExecutor[T] // Function to execute executor MiddlewareExecutor[T] // Function to execute
order int // Optional sort order (not used yet) order int // Optional sort order (not used yet)
@@ -392,7 +392,7 @@ type Middleware[T DbContext] struct {
} }
// NewMiddleware creates a new synchronous middleware. // NewMiddleware creates a new synchronous middleware.
func NewMiddleware[T DbContext](name string, executor MiddlewareExecutor[T]) Middleware[T] { func NewMiddleware[T AppData](name string, executor MiddlewareExecutor[T]) Middleware[T] {
return Middleware[T]{name, executor, 0, false} return Middleware[T]{name, executor, 0, false}
} }
+4 -4
View File
@@ -6,7 +6,7 @@ import (
) )
func TestValidateArgsRequiresFullMatch(t *testing.T) { func TestValidateArgsRequiresFullMatch(t *testing.T) {
intCmd := NewCommand[NoDB](func(ctx *MsgContext, db NoDB) error { return nil }, "int", NewCommandArg("n").SetValueType(CommandValueIntType).SetRequired()) intCmd := NewCommand[NoData](func(ctx *MsgContext, db NoData) error { return nil }, "int", NewCommandArg("n").SetValueType(CommandValueIntType).SetRequired())
if err := intCmd.validateArgs([]string{"123"}); err != nil { if err := intCmd.validateArgs([]string{"123"}); err != nil {
t.Fatalf("expected valid integer argument, got %v", err) t.Fatalf("expected valid integer argument, got %v", err)
} }
@@ -14,7 +14,7 @@ func TestValidateArgsRequiresFullMatch(t *testing.T) {
t.Fatalf("expected ErrCmdArgRegexpMismatch for partial int match, got %v", err) t.Fatalf("expected ErrCmdArgRegexpMismatch for partial int match, got %v", err)
} }
boolCmd := NewCommand[NoDB](func(ctx *MsgContext, db NoDB) error { return nil }, "bool", NewCommandArg("flag").SetValueType(CommandValueBoolType).SetRequired()) boolCmd := NewCommand[NoData](func(ctx *MsgContext, db NoData) error { return nil }, "bool", NewCommandArg("flag").SetValueType(CommandValueBoolType).SetRequired())
if err := boolCmd.validateArgs([]string{"false"}); err != nil { if err := boolCmd.validateArgs([]string{"false"}); err != nil {
t.Fatalf("expected valid bool argument, got %v", err) t.Fatalf("expected valid bool argument, got %v", err)
} }
@@ -24,8 +24,8 @@ func TestValidateArgsRequiresFullMatch(t *testing.T) {
} }
func TestValidateArgsEnforcesRequiredArgIndex(t *testing.T) { func TestValidateArgsEnforcesRequiredArgIndex(t *testing.T) {
cmd := NewCommand[NoDB]( cmd := NewCommand[NoData](
func(ctx *MsgContext, db NoDB) error { return nil }, func(ctx *MsgContext, db NoData) error { return nil },
"mixed", "mixed",
NewCommandArg("optional"), NewCommandArg("optional"),
NewCommandArg("required").SetRequired(), NewCommandArg("required").SetRequired(),
+3 -3
View File
@@ -7,7 +7,7 @@ import (
// RunnerFn is the function type for a runner. It receives a pointer to // RunnerFn is the function type for a runner. It receives a pointer to
// the Bot and returns an error if execution fails. // the Bot and returns an error if execution fails.
type RunnerFn[T DbContext] func(*Bot[T]) error type RunnerFn[T AppData] func(*Bot[T]) error
// Runner represents a configurable background or one-time task to be // Runner represents a configurable background or one-time task to be
// executed by a Bot. // executed by a Bot.
@@ -20,7 +20,7 @@ type RunnerFn[T DbContext] func(*Bot[T]) error
// - onetime=true, async=true: Run once in a goroutine (non-blocking). // - onetime=true, async=true: Run once in a goroutine (non-blocking).
// - onetime=false, async=true: Run repeatedly in a goroutine with timeout. // - onetime=false, async=true: Run repeatedly in a goroutine with timeout.
// - onetime=false, async=false: Invalid configuration — ignored with warning. // - onetime=false, async=false: Invalid configuration — ignored with warning.
type Runner[T DbContext] struct { type Runner[T AppData] struct {
name string // Human-readable name for logging name string // Human-readable name for logging
onetime bool // If true, runs once; if false, runs periodically onetime bool // If true, runs once; if false, runs periodically
async bool // If true, runs in a goroutine; else, runs synchronously async bool // If true, runs in a goroutine; else, runs synchronously
@@ -33,7 +33,7 @@ type Runner[T DbContext] struct {
// //
// Builder methods (Onetime, Async, Timeout) can be chained to customize behavior. // Builder methods (Onetime, Async, Timeout) can be chained to customize behavior.
// DO NOT call builder methods concurrently or after Execute(). // DO NOT call builder methods concurrently or after Execute().
func NewRunner[T DbContext](name string, fn RunnerFn[T]) Runner[T] { func NewRunner[T AppData](name string, fn RunnerFn[T]) Runner[T] {
return Runner[T]{ return Runner[T]{
name: name, name: name,
fn: fn, fn: fn,
+6 -6
View File
@@ -11,10 +11,10 @@ import (
func TestExecRunnersRunsOnetimeSyncRunner(t *testing.T) { func TestExecRunnersRunsOnetimeSyncRunner(t *testing.T) {
var calls atomic.Int32 var calls atomic.Int32
bot := &Bot[NoDB]{ bot := &Bot[NoData]{
logger: slog.CreateLogger(), logger: slog.CreateLogger(),
runners: []Runner[NoDB]{ runners: []Runner[NoData]{
NewRunner("sync-once", func(*Bot[NoDB]) error { NewRunner("sync-once", func(*Bot[NoData]) error {
calls.Add(1) calls.Add(1)
return nil return nil
}).Onetime(true).Async(false), }).Onetime(true).Async(false),
@@ -33,10 +33,10 @@ func TestExecRunnersStopsBackgroundRunnerOnCancel(t *testing.T) {
triggered := make(chan struct{}, 1) triggered := make(chan struct{}, 1)
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
bot := &Bot[NoDB]{ bot := &Bot[NoData]{
logger: slog.CreateLogger(), logger: slog.CreateLogger(),
runners: []Runner[NoDB]{ runners: []Runner[NoData]{
NewRunner("background", func(*Bot[NoDB]) error { NewRunner("background", func(*Bot[NoData]) error {
if calls.Add(1) == 1 { if calls.Add(1) == 1 {
triggered <- struct{}{} triggered <- struct{}{}
} }
+4 -4
View File
@@ -26,7 +26,7 @@ func (bot *Bot[T]) tryHandleScene(ctx *MsgContext) (bool, error) {
if scene.PluginName != "" && scene.PluginName != plugin.name { if scene.PluginName != "" && scene.PluginName != plugin.name {
continue continue
} }
if !plugin.executeMiddlewares(ctx, bot.dbContext) { if !plugin.executeMiddlewares(ctx, bot.appData) {
return false, nil return false, nil
} }
sceneCtx := &SceneContext{ sceneCtx := &SceneContext{
@@ -59,7 +59,7 @@ func (bot *Bot[T]) executeScene(scene *Scene[T], ctx *SceneContext) (bool, error
ctx.Text = args ctx.Text = args
ctx.Args = strings.Fields(args) ctx.Args = strings.Fields(args)
res, matched, err := scene.executeCommand(cmd, ctx, bot.dbContext) res, matched, err := scene.executeCommand(cmd, ctx, bot.appData)
if err != nil { if err != nil {
return false, err return false, err
} }
@@ -71,7 +71,7 @@ func (bot *Bot[T]) executeScene(scene *Scene[T], ctx *SceneContext) (bool, error
ctx.Args = nil ctx.Args = nil
ctx.Prefix = "" ctx.Prefix = ""
if ctx.sess.Step != "" { if ctx.sess.Step != "" {
res, matched, err := scene.executeStep(ctx.sess.Step, ctx, bot.dbContext) res, matched, err := scene.executeStep(ctx.sess.Step, ctx, bot.appData)
if err != nil { if err != nil {
return false, err return false, err
} }
@@ -80,7 +80,7 @@ func (bot *Bot[T]) executeScene(scene *Scene[T], ctx *SceneContext) (bool, error
} }
} }
res, matched, err := scene.executeMessage(ctx, bot.dbContext) res, matched, err := scene.executeMessage(ctx, bot.appData)
if err != nil { if err != nil {
return false, err return false, err
} }
+25 -25
View File
@@ -28,8 +28,8 @@ func (s failingSessionStore) Delete(key string) error {
} }
func TestPluginAddSceneRegistersScene(t *testing.T) { func TestPluginAddSceneRegistersScene(t *testing.T) {
plugin := NewPlugin[NoDB]("wizard") plugin := NewPlugin[NoData]("wizard")
scene := NewScene[NoDB]("signup") scene := NewScene[NoData]("signup")
plugin.AddScene(scene) plugin.AddScene(scene)
@@ -44,10 +44,10 @@ func TestPluginAddSceneRegistersScene(t *testing.T) {
func TestBotAddPluginsPreservesScenesAndHandlesThem(t *testing.T) { func TestBotAddPluginsPreservesScenesAndHandlesThem(t *testing.T) {
called := false called := false
plugin := NewPlugin[NoDB]("wizard") plugin := NewPlugin[NoData]("wizard")
plugin.NewScene("signup"). plugin.NewScene("signup").
SetEntry("start"). SetEntry("start").
OnStep("start", func(ctx *SceneContext, db NoDB) (SceneResult, error) { OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) {
called = true called = true
if ctx.Text != "hello there" { if ctx.Text != "hello there" {
t.Fatalf("unexpected scene text: got %q want %q", ctx.Text, "hello there") t.Fatalf("unexpected scene text: got %q want %q", ctx.Text, "hello there")
@@ -55,7 +55,7 @@ func TestBotAddPluginsPreservesScenesAndHandlesThem(t *testing.T) {
return ctx.Exit(), nil return ctx.Exit(), nil
}) })
bot := &Bot[NoDB]{ bot := &Bot[NoData]{
logger: slog.CreateLogger(), logger: slog.CreateLogger(),
prefixes: []string{"/"}, prefixes: []string{"/"},
sessionStore: NewMemorySessionStore(), sessionStore: NewMemorySessionStore(),
@@ -145,10 +145,10 @@ func TestBuildSceneKeyRejectsMissingContextFields(t *testing.T) {
func TestEnterSceneRejectsMissingEntryConfiguration(t *testing.T) { func TestEnterSceneRejectsMissingEntryConfiguration(t *testing.T) {
t.Run("empty entry", func(t *testing.T) { t.Run("empty entry", func(t *testing.T) {
plugin := NewPlugin[NoDB]("wizard") plugin := NewPlugin[NoData]("wizard")
plugin.NewScene("signup") plugin.NewScene("signup")
bot := &Bot[NoDB]{ bot := &Bot[NoData]{
logger: slog.CreateLogger(), logger: slog.CreateLogger(),
sessionStore: NewMemorySessionStore(), sessionStore: NewMemorySessionStore(),
sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser}, sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser},
@@ -168,10 +168,10 @@ func TestEnterSceneRejectsMissingEntryConfiguration(t *testing.T) {
}) })
t.Run("missing entry step", func(t *testing.T) { t.Run("missing entry step", func(t *testing.T) {
plugin := NewPlugin[NoDB]("wizard") plugin := NewPlugin[NoData]("wizard")
plugin.NewScene("signup").SetEntry("start") plugin.NewScene("signup").SetEntry("start")
bot := &Bot[NoDB]{ bot := &Bot[NoData]{
logger: slog.CreateLogger(), logger: slog.CreateLogger(),
sessionStore: NewMemorySessionStore(), sessionStore: NewMemorySessionStore(),
sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser}, sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser},
@@ -209,14 +209,14 @@ func TestSceneCommandHandlerRunsBeforeStep(t *testing.T) {
sceneCommandCalled := false sceneCommandCalled := false
stepCalled := false stepCalled := false
plugin := NewPlugin[NoDB]("wizard") plugin := NewPlugin[NoData]("wizard")
plugin.NewScene("signup"). plugin.NewScene("signup").
SetEntry("start"). SetEntry("start").
OnStep("start", func(ctx *SceneContext, db NoDB) (SceneResult, error) { OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) {
stepCalled = true stepCalled = true
return ctx.Stay(), nil return ctx.Stay(), nil
}). }).
OnCommand("cancel", func(ctx *SceneContext, db NoDB) (SceneResult, error) { OnCommand("cancel", func(ctx *SceneContext, db NoData) (SceneResult, error) {
sceneCommandCalled = true sceneCommandCalled = true
if ctx.Prefix != "/" { if ctx.Prefix != "/" {
t.Fatalf("unexpected prefix: got %q want /", ctx.Prefix) t.Fatalf("unexpected prefix: got %q want /", ctx.Prefix)
@@ -230,7 +230,7 @@ func TestSceneCommandHandlerRunsBeforeStep(t *testing.T) {
return ctx.Exit(), nil return ctx.Exit(), nil
}) })
bot := &Bot[NoDB]{ bot := &Bot[NoData]{
logger: slog.CreateLogger(), logger: slog.CreateLogger(),
prefixes: []string{"/"}, prefixes: []string{"/"},
sessionStore: NewMemorySessionStore(), sessionStore: NewMemorySessionStore(),
@@ -269,14 +269,14 @@ func TestSceneCommandHandlerRunsBeforeStep(t *testing.T) {
func TestScenePassDoesNotPersistSessionData(t *testing.T) { func TestScenePassDoesNotPersistSessionData(t *testing.T) {
commandCalled := false commandCalled := false
plugin := NewPlugin[NoDB]("wizard") plugin := NewPlugin[NoData]("wizard")
plugin.NewCommand(func(ctx *MsgContext, db NoDB) error { plugin.NewCommand(func(ctx *MsgContext, db NoData) error {
commandCalled = true commandCalled = true
return nil return nil
}, "ping") }, "ping")
plugin.NewScene("signup"). plugin.NewScene("signup").
SetEntry("start"). SetEntry("start").
OnStep("start", func(ctx *SceneContext, db NoDB) (SceneResult, error) { OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) {
if err := ctx.SaveData(struct { if err := ctx.SaveData(struct {
Value string `json:"value"` Value string `json:"value"`
}{Value: "changed"}); err != nil { }{Value: "changed"}); err != nil {
@@ -285,7 +285,7 @@ func TestScenePassDoesNotPersistSessionData(t *testing.T) {
return ctx.Pass(), nil return ctx.Pass(), nil
}) })
bot := &Bot[NoDB]{ bot := &Bot[NoData]{
logger: slog.CreateLogger(), logger: slog.CreateLogger(),
prefixes: []string{"/"}, prefixes: []string{"/"},
sessionStore: NewMemorySessionStore(), sessionStore: NewMemorySessionStore(),
@@ -348,13 +348,13 @@ func TestScenePassDoesNotPersistSessionData(t *testing.T) {
func TestSceneMessageFallbackRunsWhenNoCommandOrStepMatch(t *testing.T) { func TestSceneMessageFallbackRunsWhenNoCommandOrStepMatch(t *testing.T) {
fallbackCalled := false fallbackCalled := false
plugin := NewPlugin[NoDB]("wizard") plugin := NewPlugin[NoData]("wizard")
plugin.NewScene("signup"). plugin.NewScene("signup").
SetEntry("start"). SetEntry("start").
OnStep("start", func(ctx *SceneContext, db NoDB) (SceneResult, error) { OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) {
return ctx.Stay(), nil return ctx.Stay(), nil
}). }).
OnMessage(func(ctx *SceneContext, db NoDB) (SceneResult, error) { OnMessage(func(ctx *SceneContext, db NoData) (SceneResult, error) {
fallbackCalled = true fallbackCalled = true
if ctx.Text != "hello fallback" { if ctx.Text != "hello fallback" {
t.Fatalf("unexpected fallback text: got %q want %q", ctx.Text, "hello fallback") t.Fatalf("unexpected fallback text: got %q want %q", ctx.Text, "hello fallback")
@@ -362,7 +362,7 @@ func TestSceneMessageFallbackRunsWhenNoCommandOrStepMatch(t *testing.T) {
return ctx.Exit(), nil return ctx.Exit(), nil
}) })
bot := &Bot[NoDB]{ bot := &Bot[NoData]{
logger: slog.CreateLogger(), logger: slog.CreateLogger(),
prefixes: []string{"/"}, prefixes: []string{"/"},
sessionStore: NewMemorySessionStore(), sessionStore: NewMemorySessionStore(),
@@ -407,7 +407,7 @@ func TestSceneMessageFallbackRunsWhenNoCommandOrStepMatch(t *testing.T) {
} }
func TestFindSceneSessionSupportsUserScopeWithoutMessage(t *testing.T) { func TestFindSceneSessionSupportsUserScopeWithoutMessage(t *testing.T) {
bot := &Bot[NoDB]{ bot := &Bot[NoData]{
logger: slog.CreateLogger(), logger: slog.CreateLogger(),
sessionStore: NewMemorySessionStore(), sessionStore: NewMemorySessionStore(),
sceneScopePriority: []SceneScope{SceneScopeUser, SceneScopeChat, SceneScopeUserChat}, sceneScopePriority: []SceneScope{SceneScopeUser, SceneScopeChat, SceneScopeUserChat},
@@ -434,7 +434,7 @@ func TestSceneStoreErrorsPropagate(t *testing.T) {
setErr := errors.New("set failed") setErr := errors.New("set failed")
t.Run("find scene session get error", func(t *testing.T) { t.Run("find scene session get error", func(t *testing.T) {
bot := &Bot[NoDB]{ bot := &Bot[NoData]{
logger: slog.CreateLogger(), logger: slog.CreateLogger(),
sessionStore: failingSessionStore{getErr: getErr}, sessionStore: failingSessionStore{getErr: getErr},
sceneScopePriority: []SceneScope{SceneScopeUser}, sceneScopePriority: []SceneScope{SceneScopeUser},
@@ -447,10 +447,10 @@ func TestSceneStoreErrorsPropagate(t *testing.T) {
}) })
t.Run("apply scene result set error", func(t *testing.T) { t.Run("apply scene result set error", func(t *testing.T) {
scene := NewScene[NoDB]("signup").OnStep("start", func(ctx *SceneContext, db NoDB) (SceneResult, error) { scene := NewScene[NoData]("signup").OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) {
return ctx.Stay(), nil return ctx.Stay(), nil
}) })
bot := &Bot[NoDB]{ bot := &Bot[NoData]{
logger: slog.CreateLogger(), logger: slog.CreateLogger(),
sessionStore: failingSessionStore{setErr: setErr}, sessionStore: failingSessionStore{setErr: setErr},
sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser}, sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser},