REPOSITORY / ScuroNeko/Laniakea

Pull Requests

PULL REQUESTS REPOSITORY

v1.0.0 #9

Merged
ScuroNeko merged 101 commits from dev into main 2026-05-20 13:43:34 +03:00
16 changed files with 287 additions and 274 deletions
Showing only changes of commit e2444752c2 - Show all commits
+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},