REPOSITORY / ScuroNeko/Laniakea
Pull Requests
v1.0.0 #9
@@ -3,3 +3,4 @@
|
||||
.vscode/
|
||||
test/
|
||||
.codex/
|
||||
.codex
|
||||
|
||||
@@ -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.
|
||||
- `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.
|
||||
- Godoc, README examples, and regression-test naming now consistently describe the shared generic dependency model as app data, including `NoData` and `SetAppData(...)`.
|
||||
|
||||
### Tests
|
||||
- Added regression coverage for the bot configuration freeze model, including ignored post-run mutations for core bot configuration methods and late registration paths.
|
||||
|
||||
@@ -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).
|
||||
* **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).
|
||||
* **Context-Aware:** Pass custom database or state contexts to your handlers.
|
||||
* **Fluent Interface:** Chain methods for clean configuration (e.g., `bot.ErrorTemplate(...).AddPlugins(...)`).
|
||||
* **Context-Aware:** Pass custom application data or state contexts to your handlers.
|
||||
* **Configurable API:** Mix `Set...` and `Add...` helpers to configure bots clearly (for example, `bot.SetErrorTemplate(...).AddPlugins(...)`).
|
||||
|
||||
---
|
||||
|
||||
@@ -53,8 +53,8 @@ import (
|
||||
// echo is a command handler function.
|
||||
// It receives two parameters:
|
||||
// - ctx: the message context (contains info about the message, sender, chat, etc.)
|
||||
// - db: your custom database context (here we use NoDB, a placeholder for no database)
|
||||
func echo(ctx *laniakea.MsgContext, db laniakea.NoDB) error {
|
||||
// - data: your shared application data (here we use NoData, a placeholder for no shared data)
|
||||
func echo(ctx *laniakea.MsgContext, data laniakea.NoData) error {
|
||||
// Answer the user with the text they sent, without any command prefix.
|
||||
// ctx.Text contains the user's message with the command part stripped off.
|
||||
ctx.Answer(ctx.Text) // User input WITHOUT command
|
||||
@@ -66,8 +66,8 @@ func main() {
|
||||
opts := &laniakea.BotOpts{Token: "TOKEN"}
|
||||
|
||||
// 2. Initialize a new bot instance.
|
||||
// We use laniakea.NoDB as the database context type (no database needed for this example).
|
||||
bot, err := laniakea.NewBot[laniakea.NoDB](opts)
|
||||
// We use laniakea.NoData as the application data type (no shared data needed for this example).
|
||||
bot, err := laniakea.NewBot[laniakea.NoData](opts)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
@@ -76,7 +76,7 @@ func main() {
|
||||
|
||||
// 3. Create a new plugin named "ping".
|
||||
// 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.
|
||||
// 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).
|
||||
// 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")
|
||||
return nil
|
||||
}, "ping"))
|
||||
|
||||
// 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.
|
||||
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.
|
||||
// This is optional but very useful for most bots.
|
||||
@@ -109,11 +109,11 @@ func main() {
|
||||
|
||||
### How It Works
|
||||
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.
|
||||
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.
|
||||
6. `ErrorTemplate`: Sets a template for error messages. The %s placeholder is replaced by the actual error.
|
||||
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. `SetErrorTemplate`: Sets a template for error messages. The %s placeholder is replaced by the actual error.
|
||||
7. `AutoGenerateCommands`: Registers plugin-defined commands with Telegram across the supported scopes.
|
||||
8. `Run()`: Starts the bot's update polling loop and returns an error if startup or polling fails.
|
||||
9. A `Bot` instance is single-use. After `Run()` or `RunWithContext()` returns, create a new bot instance for the next session.
|
||||
@@ -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.
|
||||
|
||||
### 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
|
||||
type MyDB struct { /* ... */ }
|
||||
@@ -179,7 +179,7 @@ bot, err := laniakea.NewBot[*MyDB](opts)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
bot.DatabaseContext(db)
|
||||
bot.SetAppData(db)
|
||||
```
|
||||
|
||||
### Scenes and Sessions
|
||||
|
||||
+16
-16
@@ -22,8 +22,8 @@
|
||||
* **Поддержка промежуточных слоёв (Middleware):** Выполняйте код до или после команд (например, логирование, проверка доступа).
|
||||
* **Автоматическая генерация команд:** Генерируйте справку и списки команд автоматически.
|
||||
* **Встроенный ограничитель запросов (Rate Limiter):** Защитите бота от превышения лимитов Telegram API (с обработкой `retry_after`).
|
||||
* **Контекст данных:** Передавайте свой контекст базы данных или состояния в обработчики.
|
||||
* **Текучий интерфейс (Fluent Interface):** Стройте цепочки методов для чистой конфигурации (например, `bot.ErrorTemplate(...).AddPlugins(...)`).
|
||||
* **Контекст данных:** Передавайте общие данные приложения или state в обработчики.
|
||||
* **Настраиваемый API:** Комбинируйте `Set...` и `Add...` helper-методы для понятной конфигурации, например `bot.SetErrorTemplate(...).AddPlugins(...)`.
|
||||
|
||||
---
|
||||
|
||||
@@ -54,8 +54,8 @@ import (
|
||||
// echo — это функция-обработчик команды.
|
||||
// Она получает два параметра:
|
||||
// - ctx: контекст сообщения (содержит информацию о сообщении, отправителе, чате и т.д.)
|
||||
// - db: ваш пользовательский контекст базы данных (здесь мы используем NoDB — заглушку)
|
||||
func echo(ctx *laniakea.MsgContext, db laniakea.NoDB) error {
|
||||
// - data: ваши общие данные приложения (здесь мы используем NoData — заглушку без общих зависимостей)
|
||||
func echo(ctx *laniakea.MsgContext, data laniakea.NoData) error {
|
||||
// Отвечаем пользователю текстом, который он прислал, без префикса команды.
|
||||
// ctx.Text содержит сообщение пользователя, из которого удалена часть с командой.
|
||||
ctx.Answer(ctx.Text) // Ввод пользователя БЕЗ команды
|
||||
@@ -67,8 +67,8 @@ func main() {
|
||||
opts := &laniakea.BotOpts{Token: "TOKEN"}
|
||||
|
||||
// 2. Инициализируем новый экземпляр бота.
|
||||
// Используем laniakea.NoDB как тип контекста базы данных (база не нужна для примера).
|
||||
bot, err := laniakea.NewBot[laniakea.NoDB](opts)
|
||||
// Используем laniakea.NoData как тип данных приложения (общие зависимости не нужны для примера).
|
||||
bot, err := laniakea.NewBot[laniakea.NoData](opts)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
@@ -77,7 +77,7 @@ func main() {
|
||||
|
||||
// 3. Создаём новый плагин с именем "ping".
|
||||
// Плагины помогают группировать связанные команды и промежуточные обработчики.
|
||||
p := laniakea.NewPlugin[laniakea.NoDB]("ping")
|
||||
p := laniakea.NewPlugin[laniakea.NoData]("ping")
|
||||
|
||||
// 4. Добавляем команду в плагин.
|
||||
// p.NewCommand(echo, "echo") создаёт команду, которая вызывает функцию 'echo' по команде "/echo".
|
||||
@@ -85,15 +85,15 @@ func main() {
|
||||
|
||||
// 5. Добавляем ещё одну команду, используя анонимную функцию (замыкание).
|
||||
// Эта команда просто отвечает "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")
|
||||
return nil
|
||||
}, "ping"))
|
||||
|
||||
// 6. Настраиваем бота: задаём шаблон ошибки и добавляем плагин.
|
||||
// ErrorTemplate устанавливает формат для сообщений об ошибках (где %s будет заменён на текст ошибки).
|
||||
// SetErrorTemplate устанавливает формат для сообщений об ошибках (где %s будет заменён на текст ошибки).
|
||||
// AddPlugins(p) регистрирует наш плагин "ping" в боте.
|
||||
bot = bot.ErrorTemplate("Ошибка\n\n%s").AddPlugins(p)
|
||||
bot = bot.SetErrorTemplate("Ошибка\n\n%s").AddPlugins(p)
|
||||
|
||||
// 7. Автоматически генерируем команды, такие как /start, /help и список всех зарегистрированных команд.
|
||||
// Это необязательно, но очень полезно для большинства ботов.
|
||||
@@ -110,11 +110,11 @@ func main() {
|
||||
|
||||
### Как это работает
|
||||
1. `BotOpts`: Содержит конфигурацию, например, токен API.
|
||||
2. `NewBot[T]`: Создаёт экземпляр бота. Параметр типа T позволяет передать пользовательский контекст базы данных (например, *sql.DB), который будет доступен во всех обработчиках. Используйте laniakea.NoDB, если он не нужен.
|
||||
2. `NewBot[T]`: Создаёт экземпляр бота. Параметр типа T позволяет передать общие данные приложения (например, *sql.DB или контейнер сервисов), которые будут доступны во всех обработчиках. Используйте laniakea.NoData, если они не нужны.
|
||||
3. `NewPlugin`: Создаёт логическую группу для команд и Middleware.
|
||||
4. `AddCommand`: Регистрирует команду. Первый аргумент — функция-обработчик (`func(*MsgContext, T) error`), второй — имя команды (без слеша).
|
||||
5. **Функции-обработчики**: Получают *MsgContext (детали сообщения, методы типа Answer) и ваш контекст базы данных T, а ошибку возвращают для централизованной обработки.
|
||||
6. `ErrorTemplate`: Устанавливает шаблон для сообщений об ошибках. Плейсхолдер %s заменяется на текст ошибки.
|
||||
5. **Функции-обработчики**: Получают *MsgContext (детали сообщения, методы типа Answer) и ваши данные приложения типа T, а ошибку возвращают для централизованной обработки.
|
||||
6. `SetErrorTemplate`: Устанавливает шаблон для сообщений об ошибках. Плейсхолдер %s заменяется на текст ошибки.
|
||||
7. `AutoGenerateCommands`: Регистрирует команды из плагинов в Telegram для поддерживаемых scope.
|
||||
8. `Run()`: Запускает цикл опроса обновлений бота и возвращает ошибку, если старт или polling завершился неуспешно.
|
||||
9. Экземпляр `Bot` одноразовый. После завершения `Run()` или `RunWithContext()` для следующего запуска создавайте новый бот.
|
||||
@@ -157,8 +157,8 @@ func myHandler(ctx *laniakea.MsgContext, db *MyDB) error {
|
||||
- Поля: `Text`, `Args`, `From`, `FromID`, `Msg`, `InlineMsgId`, `CallbackQueryId` и другие.
|
||||
- И много других методов и полей!
|
||||
|
||||
### Контекст базы данных (Database Context)
|
||||
Параметр типа `T` в `NewBot[T]` — мощная функция. Вы можете передать любой тип, но для разделяемых зависимостей вроде пула соединений с БД обычно стоит использовать pointer type.
|
||||
### App Data
|
||||
Параметр типа `T` в `NewBot[T]` — мощная возможность. Вы можете передать любой тип, но для разделяемых зависимостей вроде пула соединений с БД, контейнера сервисов или API-клиента обычно стоит использовать pointer type.
|
||||
|
||||
```go
|
||||
type MyDB struct { /* ... */ }
|
||||
@@ -167,7 +167,7 @@ bot, err := laniakea.NewBot[*MyDB](opts)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
bot.DatabaseContext(db)
|
||||
bot.SetAppData(db)
|
||||
```
|
||||
|
||||
### Сцены и сессии (Scenes and Sessions)
|
||||
|
||||
@@ -19,8 +19,11 @@ import (
|
||||
"github.com/alitto/pond/v2"
|
||||
)
|
||||
|
||||
// DbContext is the generic dependency type injected into bots, plugins, and handlers.
|
||||
// Use it for shared application state such as database handles or service containers.
|
||||
// AppData is the generic shared application data type injected into bots,
|
||||
// plugins, and handlers.
|
||||
//
|
||||
// Use it for long-lived shared dependencies such as database handles, service
|
||||
// containers, API clients, or immutable configuration snapshots.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
@@ -30,18 +33,22 @@ import (
|
||||
// if err != nil {
|
||||
// return err
|
||||
// }
|
||||
// bot.DatabaseContext(myDB)
|
||||
// bot.SetAppData(myDB)
|
||||
//
|
||||
// Use NoDB if no database is needed.
|
||||
type DbContext any
|
||||
// Use NoData if no shared application data is needed.
|
||||
type AppData any
|
||||
|
||||
// NoDB is a placeholder type for bots that do not use a database.
|
||||
// Use Bot[NoDB] to indicate no dependency injection is required.
|
||||
type NoDB struct{ DbContext }
|
||||
// NoData is a placeholder type for bots that do not use shared application
|
||||
// data.
|
||||
//
|
||||
// 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.
|
||||
// Used to inject database-specific log output (e.g., SQL queries, ORM events).
|
||||
type DbLogger[T DbContext] func(db T) slog.LoggerWriter
|
||||
// AppDataLogger builds a slog.LoggerWriter from injected application data.
|
||||
//
|
||||
// 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.
|
||||
type BotPayloadType string
|
||||
@@ -78,7 +85,7 @@ var (
|
||||
//
|
||||
// Runtime accessors are safe for concurrent use. Configure the bot before Run.
|
||||
// A Bot is single-use: after Run or RunWithContext returns, create a new Bot for the next session.
|
||||
type Bot[T DbContext] struct {
|
||||
type Bot[T AppData] struct {
|
||||
token string
|
||||
debug bool
|
||||
errorTemplate string
|
||||
@@ -98,11 +105,12 @@ type Bot[T DbContext] struct {
|
||||
|
||||
api *tgapi.API // Telegram API client
|
||||
uploader *tgapi.Uploader // File uploader
|
||||
dbContext T // Injected database context
|
||||
hasDBContext bool
|
||||
warnedValueDB bool
|
||||
l10n *L10n // Localization manager
|
||||
draftProvider *DraftProvider // Draft message builder
|
||||
l10n *L10n // Localization manager
|
||||
draftProvider *DraftProvider // Draft message builder
|
||||
|
||||
appData T // Injected application data
|
||||
hasAppData bool
|
||||
warnedValueData bool
|
||||
|
||||
sessionStore SessionStore // Session store for scene management
|
||||
sceneScopePriority []SceneScope
|
||||
@@ -328,9 +336,9 @@ func (bot *Bot[T]) GetUpdateTypes() []tgapi.UpdateType {
|
||||
// GetLogger returns the main bot logger.
|
||||
func (bot *Bot[T]) GetLogger() *slog.Logger { return bot.logger }
|
||||
|
||||
// GetDBContext returns the injected database context.
|
||||
// If DatabaseContext was not called, it returns the zero value of T.
|
||||
func (bot *Bot[T]) GetDBContext() T { return bot.dbContext }
|
||||
// GetAppData returns the injected application data.
|
||||
// If SetAppData was not called, it returns the zero value of T.
|
||||
func (bot *Bot[T]) GetAppData() T { return bot.appData }
|
||||
|
||||
// GetLoggerLevel returns the effective log level derived from the bot's debug
|
||||
// flag.
|
||||
@@ -406,27 +414,30 @@ func (bot *Bot[T]) SetSceneScopePriority(priority []SceneScope) *Bot[T] {
|
||||
return bot
|
||||
}
|
||||
|
||||
// DatabaseContext injects a database context into the bot.
|
||||
// This context is accessible to plugins and middleware via GetDBContext().
|
||||
// SetAppData injects shared application data into the bot.
|
||||
//
|
||||
// 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.
|
||||
// Value-typed contexts are supported, but the bot warns once because handlers
|
||||
// receive T by value.
|
||||
func (bot *Bot[T]) DatabaseContext(ctx T) *Bot[T] {
|
||||
if !bot.configMutable("DatabaseContext") {
|
||||
// Value-typed application data is supported, but the bot warns once because
|
||||
// handlers receive T by value.
|
||||
func (bot *Bot[T]) SetAppData(ctx T) *Bot[T] {
|
||||
if !bot.configMutable("SetAppData") {
|
||||
return bot
|
||||
}
|
||||
if !bot.warnedValueDB && shouldWarnOnValueDBContext[T]() && bot.logger != nil {
|
||||
bot.logger.Warnln("database context uses a value type; shared dependencies should usually use a pointer type as T")
|
||||
bot.warnedValueDB = true
|
||||
if !bot.warnedValueData && shouldWarnOnValueAppData[T]() && bot.logger != nil {
|
||||
bot.logger.Warnln("app data uses a value type; shared dependencies should usually use a pointer type as T")
|
||||
bot.warnedValueData = true
|
||||
}
|
||||
bot.dbContext = ctx
|
||||
bot.hasDBContext = true
|
||||
bot.appData = ctx
|
||||
bot.hasAppData = true
|
||||
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.
|
||||
func (bot *Bot[T]) UpdateTypes(t ...tgapi.UpdateType) *Bot[T] {
|
||||
func (bot *Bot[T]) SetUpdateTypes(t ...tgapi.UpdateType) *Bot[T] {
|
||||
if !bot.configMutable("UpdateTypes") {
|
||||
return bot
|
||||
}
|
||||
@@ -480,10 +491,10 @@ func (bot *Bot[T]) AddPrefixes(prefixes ...string) *Bot[T] {
|
||||
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.
|
||||
// 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") {
|
||||
return bot
|
||||
}
|
||||
@@ -491,8 +502,8 @@ func (bot *Bot[T]) ErrorTemplate(s string) *Bot[T] {
|
||||
return bot
|
||||
}
|
||||
|
||||
// Debug enables or disables debug logging.
|
||||
func (bot *Bot[T]) Debug(debug bool) *Bot[T] {
|
||||
// SetDebug enables or disables debug logging.
|
||||
func (bot *Bot[T]) SetDebug(debug bool) *Bot[T] {
|
||||
bot.debug = debug
|
||||
level := slog.FATAL
|
||||
if debug {
|
||||
@@ -612,7 +623,7 @@ func (bot *Bot[T]) AddRunner(runner Runner[T]) *Bot[T] {
|
||||
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.
|
||||
// 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.Add("en", "hello", "Hello!")
|
||||
// l10n.Add("es", "hello", "¡Hola!")
|
||||
// bot.AddL10n(l10n)
|
||||
// bot.SetL10n(l10n)
|
||||
//
|
||||
// Replaces any previously set L10n instance.
|
||||
func (bot *Bot[T]) AddL10n(l *L10n) *Bot[T] {
|
||||
if !bot.configMutable("AddL10n") {
|
||||
func (bot *Bot[T]) SetL10n(l *L10n) *Bot[T] {
|
||||
if !bot.configMutable("SetL10n") {
|
||||
return bot
|
||||
}
|
||||
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
|
||||
}
|
||||
bot.l10n = l
|
||||
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:
|
||||
// - 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.
|
||||
// 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:
|
||||
//
|
||||
// bot.AddDatabaseLoggerWriter(func(db *MyDB) slog.LoggerWriter {
|
||||
// return db.QueryLogger()
|
||||
// bot.AddAppDataLoggerWriter(func(data *MyAppData) slog.LoggerWriter {
|
||||
// return data.QueryLogger()
|
||||
// })
|
||||
func (bot *Bot[T]) AddDatabaseLoggerWriter(writer DbLogger[T]) *Bot[T] {
|
||||
if !bot.hasDBContext {
|
||||
bot.logger.Warnln("database context is not set; skipping database logger writer")
|
||||
func (bot *Bot[T]) AddAppDataLoggerWriter(writer AppDataLogger[T]) *Bot[T] {
|
||||
if !bot.hasAppData {
|
||||
bot.logger.Warnln("app data is not set; skipping app-data logger writer")
|
||||
return bot
|
||||
}
|
||||
if isNilValue(bot.dbContext) {
|
||||
bot.logger.Warnln("database context is nil; skipping database logger writer")
|
||||
if isNilValue(bot.appData) {
|
||||
bot.logger.Warnln("app data is nil; skipping app-data logger writer")
|
||||
return bot
|
||||
}
|
||||
w := writer(bot.dbContext)
|
||||
w := writer(bot.appData)
|
||||
bot.logger.AddWriter(w)
|
||||
if bot.RequestLogger != nil {
|
||||
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]()
|
||||
if t == reflect.TypeFor[NoDB]() {
|
||||
if t == reflect.TypeFor[NoData]() {
|
||||
return false
|
||||
}
|
||||
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]{
|
||||
name: p.name,
|
||||
commands: make(map[string]*Command[T], len(p.commands)),
|
||||
@@ -872,7 +883,7 @@ func clonePlugin[T DbContext](p *Plugin[T]) Plugin[T] {
|
||||
return cloned
|
||||
}
|
||||
|
||||
func cloneCommand[T DbContext](command *Command[T]) *Command[T] {
|
||||
func cloneCommand[T AppData](command *Command[T]) *Command[T] {
|
||||
if command == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -883,7 +894,7 @@ func cloneCommand[T DbContext](command *Command[T]) *Command[T] {
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func cloneScene[T DbContext](scene *Scene[T]) *Scene[T] {
|
||||
func cloneScene[T AppData](scene *Scene[T]) *Scene[T] {
|
||||
if scene == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
+51
-51
@@ -13,7 +13,7 @@ import (
|
||||
)
|
||||
|
||||
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[0] = tgapi.UpdateTypeCallbackQuery
|
||||
@@ -24,17 +24,17 @@ func TestGetUpdateTypesReturnsCopy(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAddPluginsSnapshotsConfiguration(t *testing.T) {
|
||||
bot := &Bot[NoDB]{logger: slog.CreateLogger()}
|
||||
plugin := NewPlugin[NoDB]("demo")
|
||||
bot := &Bot[NoData]{logger: slog.CreateLogger()}
|
||||
plugin := NewPlugin[NoData]("demo")
|
||||
|
||||
cmd := plugin.NewCommand(func(ctx *MsgContext, db NoDB) error { return nil }, "start")
|
||||
plugin.AddMiddleware(NewMiddleware("base", func(ctx *MsgContext, db NoDB) bool { return true }))
|
||||
cmd := plugin.NewCommand(func(ctx *MsgContext, db NoData) error { return nil }, "start")
|
||||
plugin.AddMiddleware(NewMiddleware("base", func(ctx *MsgContext, db NoData) bool { return true }))
|
||||
|
||||
bot.AddPlugins(plugin)
|
||||
|
||||
cmd.SetDescription("mutated after registration")
|
||||
plugin.NewCommand(func(ctx *MsgContext, db NoDB) error { return nil }, "late")
|
||||
plugin.AddMiddleware(NewMiddleware("late", func(ctx *MsgContext, db NoDB) bool { return true }))
|
||||
plugin.NewCommand(func(ctx *MsgContext, db NoData) error { return nil }, "late")
|
||||
plugin.AddMiddleware(NewMiddleware("late", func(ctx *MsgContext, db NoData) bool { return true }))
|
||||
|
||||
registered := bot.plugins[0]
|
||||
if _, exists := registered.commands["late"]; exists {
|
||||
@@ -49,7 +49,7 @@ func TestAddPluginsSnapshotsConfiguration(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBotPayloadTypeConfiguration(t *testing.T) {
|
||||
bot := &Bot[NoDB]{payloadType: BotPayloadBase64}
|
||||
bot := &Bot[NoData]{payloadType: BotPayloadBase64}
|
||||
|
||||
if got := bot.GetPayloadType(); got != BotPayloadBase64 {
|
||||
t.Fatalf("unexpected initial payload type: %q", got)
|
||||
@@ -65,8 +65,8 @@ func TestBotPayloadTypeConfiguration(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAddPluginsSkipsNilPlugin(t *testing.T) {
|
||||
bot := &Bot[NoDB]{logger: slog.CreateLogger()}
|
||||
plugin := NewPlugin[NoDB]("demo")
|
||||
bot := &Bot[NoData]{logger: slog.CreateLogger()}
|
||||
plugin := NewPlugin[NoData]("demo")
|
||||
|
||||
bot.AddPlugins(nil, plugin)
|
||||
|
||||
@@ -79,7 +79,7 @@ func TestAddPluginsSkipsNilPlugin(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestInitLoggersFallsBackToStdoutLoggerOnFileError(t *testing.T) {
|
||||
bot := &Bot[NoDB]{}
|
||||
bot := &Bot[NoData]{}
|
||||
|
||||
bot.initLoggers(&BotOpts{
|
||||
Debug: true,
|
||||
@@ -122,39 +122,39 @@ func TestNextPollRetryDelay(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddDatabaseLoggerWriterSkipsWhenDBContextIsUnset(t *testing.T) {
|
||||
bot := &Bot[NoDB]{logger: slog.CreateLogger()}
|
||||
func TestAddDatabaseLoggerWriterSkipsWhenAppDataIsUnset(t *testing.T) {
|
||||
bot := &Bot[NoData]{logger: slog.CreateLogger()}
|
||||
called := false
|
||||
|
||||
bot.AddDatabaseLoggerWriter(func(db NoDB) slog.LoggerWriter {
|
||||
bot.AddAppDataLoggerWriter(func(db NoData) slog.LoggerWriter {
|
||||
called = true
|
||||
return nil
|
||||
})
|
||||
|
||||
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{}
|
||||
|
||||
bot := &Bot[*testDB]{logger: slog.CreateLogger()}
|
||||
var db *testDB
|
||||
bot.DatabaseContext(db)
|
||||
bot.SetAppData(db)
|
||||
|
||||
called := false
|
||||
bot.AddDatabaseLoggerWriter(func(db *testDB) slog.LoggerWriter {
|
||||
bot.AddAppDataLoggerWriter(func(db *testDB) slog.LoggerWriter {
|
||||
called = true
|
||||
return nil
|
||||
})
|
||||
|
||||
if called {
|
||||
t.Fatal("expected database logger writer to be skipped when db context is nil")
|
||||
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 dbIface interface{ Ping() error }
|
||||
|
||||
@@ -163,36 +163,36 @@ func TestShouldWarnOnValueDBContext(t *testing.T) {
|
||||
got bool
|
||||
want bool
|
||||
}{
|
||||
{name: "NoDB", got: shouldWarnOnValueDBContext[NoDB](), want: false},
|
||||
{name: "pointer", got: shouldWarnOnValueDBContext[*testDB](), want: false},
|
||||
{name: "interface", got: shouldWarnOnValueDBContext[dbIface](), want: false},
|
||||
{name: "map", got: shouldWarnOnValueDBContext[map[string]int](), want: false},
|
||||
{name: "struct", got: shouldWarnOnValueDBContext[testDB](), want: true},
|
||||
{name: "int", got: shouldWarnOnValueDBContext[int](), want: true},
|
||||
{name: "NoData", got: shouldWarnOnValueAppData[NoData](), want: false},
|
||||
{name: "pointer", got: shouldWarnOnValueAppData[*testDB](), want: false},
|
||||
{name: "interface", got: shouldWarnOnValueAppData[dbIface](), want: false},
|
||||
{name: "map", got: shouldWarnOnValueAppData[map[string]int](), want: false},
|
||||
{name: "struct", got: shouldWarnOnValueAppData[testDB](), want: true},
|
||||
{name: "int", got: shouldWarnOnValueAppData[int](), want: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.got != tt.want {
|
||||
t.Fatalf("shouldWarnOnValueDBContext = %v, want %v", tt.got, tt.want)
|
||||
t.Fatalf("shouldWarnOnValueAppData = %v, want %v", tt.got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatabaseContextMarksValueWarningOnce(t *testing.T) {
|
||||
func TestSetAppDataMarksValueWarningOnce(t *testing.T) {
|
||||
type testDB struct{}
|
||||
|
||||
bot := &Bot[testDB]{logger: slog.CreateLogger()}
|
||||
bot.DatabaseContext(testDB{})
|
||||
if !bot.warnedValueDB {
|
||||
t.Fatal("expected value-typed database context to mark warning state")
|
||||
bot.SetAppData(testDB{})
|
||||
if !bot.warnedValueData {
|
||||
t.Fatal("expected value-typed app data to mark warning state")
|
||||
}
|
||||
|
||||
ptrBot := &Bot[*testDB]{logger: slog.CreateLogger()}
|
||||
ptrBot.DatabaseContext(&testDB{})
|
||||
if ptrBot.warnedValueDB {
|
||||
t.Fatal("did not expect pointer-typed database context to mark warning state")
|
||||
ptrBot.SetAppData(&testDB{})
|
||||
if ptrBot.warnedValueData {
|
||||
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())
|
||||
cancel()
|
||||
|
||||
bot := &Bot[NoDB]{
|
||||
bot := &Bot[NoData]{
|
||||
logger: slog.CreateLogger(),
|
||||
prefixes: []string{"/"},
|
||||
plugins: []Plugin[NoDB]{{name: "demo"}},
|
||||
plugins: []Plugin[NoData]{{name: "demo"}},
|
||||
updateQueue: make(chan *tgapi.Update, 1),
|
||||
maxWorkers: 1,
|
||||
}
|
||||
@@ -235,23 +235,23 @@ func TestBotConfigurationFreezesAfterRunStarts(t *testing.T) {
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
name string
|
||||
check func(t *testing.T, bot *Bot[*testDB])
|
||||
}{
|
||||
{
|
||||
name: "DatabaseContext",
|
||||
name: "SetAppData",
|
||||
check: func(t *testing.T, bot *Bot[*testDB]) {
|
||||
original := &testDB{Name: "before"}
|
||||
bot.DatabaseContext(original)
|
||||
bot.SetAppData(original)
|
||||
if err := bot.beginRun(); err != nil {
|
||||
t.Fatalf("beginRun returned error: %v", err)
|
||||
}
|
||||
t.Cleanup(bot.finishRun)
|
||||
|
||||
later := &testDB{Name: "after"}
|
||||
bot.DatabaseContext(later)
|
||||
if bot.dbContext != original {
|
||||
t.Fatal("DatabaseContext mutated after configuration freeze")
|
||||
bot.SetAppData(later)
|
||||
if bot.appData != original {
|
||||
t.Fatal("SetAppData mutated after configuration freeze")
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -264,7 +264,7 @@ func TestBotConfigurationFreezesAfterRunStarts(t *testing.T) {
|
||||
}
|
||||
t.Cleanup(bot.finishRun)
|
||||
|
||||
bot.UpdateTypes(tgapi.UpdateTypePoll)
|
||||
bot.SetUpdateTypes(tgapi.UpdateTypePoll)
|
||||
if !reflect.DeepEqual(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)
|
||||
|
||||
bot.ErrorTemplate("changed")
|
||||
bot.SetErrorTemplate("changed")
|
||||
if 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)
|
||||
|
||||
bot.AddL10n(&L10n{})
|
||||
bot.SetL10n(&L10n{})
|
||||
if bot.l10n != original {
|
||||
t.Fatal("l10n mutated after configuration freeze")
|
||||
}
|
||||
@@ -412,13 +412,13 @@ func TestBotConfigurationFreezesAfterRunStarts(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAddPluginsAndRuntimeRegistrationsNoOpAfterRunStarts(t *testing.T) {
|
||||
bot := &Bot[NoDB]{
|
||||
bot := &Bot[NoData]{
|
||||
logger: slog.CreateLogger(),
|
||||
prefixes: []string{"/"},
|
||||
middlewares: []Middleware[NoDB]{NewMiddleware("base", func(ctx *MsgContext, db NoDB) bool { return true })},
|
||||
runners: []Runner[NoDB]{NewRunner("base", func(bot *Bot[NoDB]) error { return nil })},
|
||||
middlewares: []Middleware[NoData]{NewMiddleware("base", func(ctx *MsgContext, db NoData) bool { return true })},
|
||||
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 {
|
||||
t.Fatalf("beginRun returned error: %v", err)
|
||||
@@ -426,8 +426,8 @@ func TestAddPluginsAndRuntimeRegistrationsNoOpAfterRunStarts(t *testing.T) {
|
||||
defer bot.finishRun()
|
||||
|
||||
bot.AddPlugins(plugin)
|
||||
bot.AddMiddleware(NewMiddleware("late", func(ctx *MsgContext, db NoDB) bool { return true }))
|
||||
bot.AddRunner(NewRunner("late", func(bot *Bot[NoDB]) error { return nil }))
|
||||
bot.AddMiddleware(NewMiddleware("late", func(ctx *MsgContext, db NoData) bool { return true }))
|
||||
bot.AddRunner(NewRunner("late", func(bot *Bot[NoData]) error { return nil }))
|
||||
|
||||
if len(bot.plugins) != 0 {
|
||||
t.Fatalf("expected AddPlugins to be ignored after configuration freeze, got %d plugins", len(bot.plugins))
|
||||
|
||||
@@ -43,16 +43,16 @@ func TestAutoGenerateCommandsChecksLimitBeforeDelete(t *testing.T) {
|
||||
}
|
||||
}()
|
||||
|
||||
plugin := NewPlugin[NoDB]("overflow")
|
||||
exec := func(ctx *MsgContext, db NoDB) error { return nil }
|
||||
plugin := NewPlugin[NoData]("overflow")
|
||||
exec := func(ctx *MsgContext, db NoData) error { return nil }
|
||||
for i := 0; i < 101; i++ {
|
||||
plugin.AddCommand(NewCommand(exec, "cmd"+strconv.Itoa(i)))
|
||||
}
|
||||
|
||||
bot := &Bot[NoDB]{
|
||||
bot := &Bot[NoData]{
|
||||
api: api,
|
||||
logger: slog.CreateLogger(),
|
||||
plugins: []Plugin[NoDB]{*plugin},
|
||||
plugins: []Plugin[NoData]{*plugin},
|
||||
}
|
||||
|
||||
err := bot.AutoGenerateCommands()
|
||||
@@ -65,8 +65,8 @@ func TestAutoGenerateCommandsChecksLimitBeforeDelete(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGatherCommandsForPluginReturnsSortedCommands(t *testing.T) {
|
||||
plugin := NewPlugin[NoDB]("sorted")
|
||||
exec := func(ctx *MsgContext, db NoDB) error { return nil }
|
||||
plugin := NewPlugin[NoData]("sorted")
|
||||
exec := func(ctx *MsgContext, db NoData) error { return nil }
|
||||
|
||||
plugin.AddCommand(NewCommand(exec, "zeta"))
|
||||
plugin.AddCommand(NewCommand(exec, "alpha"))
|
||||
|
||||
@@ -13,17 +13,17 @@ Core concepts:
|
||||
|
||||
Example usage:
|
||||
|
||||
bot, err := laniakea.NewBot[*mydb.DBContext](laniakea.LoadOptsFromEnv())
|
||||
bot, err := laniakea.NewBot[*mydb.AppData](laniakea.LoadOptsFromEnv())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bot.DatabaseContext(myDB).
|
||||
bot.SetAppData(myDB).
|
||||
AddUpdateType(tgapi.UpdateTypeMessage).
|
||||
AddPrefixes("/", "!").
|
||||
AddPlugins(&startPlugin, &helpPlugin).
|
||||
AddMiddleware(authMiddleware, logMiddleware).
|
||||
AddRunner(cleanupRunner).
|
||||
AddL10n(l10n.New())
|
||||
SetL10n(l10n.New())
|
||||
|
||||
return bot.Run()
|
||||
|
||||
|
||||
+7
-7
@@ -37,7 +37,7 @@ func (bot *Bot[T]) handle(parentCtx context.Context, u *tgapi.Update) {
|
||||
bot.prepareUpdateCtx(u, msgCtx)
|
||||
|
||||
for _, middleware := range bot.middlewares {
|
||||
if !middleware.Execute(msgCtx, bot.dbContext) {
|
||||
if !middleware.Execute(msgCtx, bot.appData) {
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -102,10 +102,10 @@ func (bot *Bot[T]) handleMessage(update *tgapi.Update, ctx *MsgContext) {
|
||||
if plugin.logger != nil {
|
||||
ctx.Logger = plugin.logger
|
||||
}
|
||||
if !plugin.executeMiddlewares(ctx, bot.dbContext) {
|
||||
if !plugin.executeMiddlewares(ctx, bot.appData) {
|
||||
return
|
||||
}
|
||||
plugin.executeCmd(cmd, ctx, bot.dbContext)
|
||||
plugin.executeCmd(cmd, ctx, bot.appData)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -130,10 +130,10 @@ func (bot *Bot[T]) handleCallback(update *tgapi.Update, ctx *MsgContext) {
|
||||
if ctx.Logger == nil {
|
||||
ctx.Logger = bot.logger
|
||||
}
|
||||
if !plugin.executeMiddlewares(ctx, bot.dbContext) {
|
||||
if !plugin.executeMiddlewares(ctx, bot.appData) {
|
||||
return
|
||||
}
|
||||
plugin.executePayload(data.Command, ctx, bot.dbContext)
|
||||
plugin.executePayload(data.Command, ctx, bot.appData)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -149,10 +149,10 @@ func (bot *Bot[T]) handleUpdate(u *tgapi.Update, ctx *MsgContext) {
|
||||
if plugin.logger != nil {
|
||||
pluginCtx.Logger = plugin.logger
|
||||
}
|
||||
if !plugin.executeMiddlewares(pluginCtx, bot.dbContext) {
|
||||
if !plugin.executeMiddlewares(pluginCtx, bot.appData) {
|
||||
continue
|
||||
}
|
||||
if err := handler(pluginCtx, bot.dbContext); err != nil {
|
||||
if err := handler(pluginCtx, bot.appData); err != nil {
|
||||
pluginCtx.error(err)
|
||||
}
|
||||
}
|
||||
|
||||
+68
-68
@@ -13,7 +13,7 @@ func ptr[T any](v T) *T {
|
||||
}
|
||||
|
||||
func TestCheckPrefixesSkipsEmptyPrefixes(t *testing.T) {
|
||||
bot := &Bot[NoDB]{prefixes: []string{"", "/"}}
|
||||
bot := &Bot[NoData]{prefixes: []string{"", "/"}}
|
||||
|
||||
if prefix, ok := bot.checkPrefixes("hello"); ok {
|
||||
t.Fatalf("unexpected prefix match for plain text: %q", prefix)
|
||||
@@ -27,10 +27,10 @@ func TestBotMiddlewareReceivesLogger(t *testing.T) {
|
||||
logger := slog.CreateLogger()
|
||||
called := false
|
||||
|
||||
bot := &Bot[NoDB]{
|
||||
bot := &Bot[NoData]{
|
||||
logger: logger,
|
||||
middlewares: []Middleware[NoDB]{
|
||||
NewMiddleware("logger-check", func(ctx *MsgContext, db NoDB) bool {
|
||||
middlewares: []Middleware[NoData]{
|
||||
NewMiddleware("logger-check", func(ctx *MsgContext, db NoData) bool {
|
||||
called = true
|
||||
if ctx.Logger != 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) {
|
||||
plugin := NewPlugin[NoDB]("test")
|
||||
handler := func(ctx *MsgContext, db NoDB) error { return nil }
|
||||
plugin := NewPlugin[NoData]("test")
|
||||
handler := func(ctx *MsgContext, db NoData) error { return nil }
|
||||
|
||||
for _, updateType := range []tgapi.UpdateType{
|
||||
tgapi.UpdateTypeMessage,
|
||||
@@ -80,14 +80,14 @@ func TestAddUpdateHandlerRejectsReservedUpdateTypes(t *testing.T) {
|
||||
|
||||
func TestPrepareUpdateCtxContract(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
update *tgapi.Update
|
||||
wantMsg bool
|
||||
wantFrom bool
|
||||
wantFromID int64
|
||||
wantCallbackID string
|
||||
wantCallbackMsgID int
|
||||
wantInlineMsgID string
|
||||
name string
|
||||
update *tgapi.Update
|
||||
wantMsg bool
|
||||
wantFrom bool
|
||||
wantFromID int64
|
||||
wantCallbackID string
|
||||
wantCallbackMsgID int
|
||||
wantInlineMsgID string
|
||||
}{
|
||||
{
|
||||
name: "message",
|
||||
@@ -145,7 +145,7 @@ func TestPrepareUpdateCtxContract(t *testing.T) {
|
||||
{
|
||||
name: "inline query",
|
||||
update: &tgapi.Update{
|
||||
Type: tgapi.UpdateTypeInlineQuery,
|
||||
Type: tgapi.UpdateTypeInlineQuery,
|
||||
InlineQuery: &tgapi.InlineQuery{ID: "iq", From: tgapi.User{ID: 104}},
|
||||
},
|
||||
wantFrom: true,
|
||||
@@ -154,7 +154,7 @@ func TestPrepareUpdateCtxContract(t *testing.T) {
|
||||
{
|
||||
name: "chosen inline result",
|
||||
update: &tgapi.Update{
|
||||
Type: tgapi.UpdateTypeChosenInlineResult,
|
||||
Type: tgapi.UpdateTypeChosenInlineResult,
|
||||
ChosenInlineResult: &tgapi.ChosenInlineResult{ResultID: "res", From: tgapi.User{ID: 105}},
|
||||
},
|
||||
wantFrom: true,
|
||||
@@ -189,15 +189,15 @@ func TestPrepareUpdateCtxContract(t *testing.T) {
|
||||
InlineMessageID: ptr("inline-42"),
|
||||
},
|
||||
},
|
||||
wantFrom: true,
|
||||
wantFromID: 107,
|
||||
wantCallbackID: "cb-2",
|
||||
wantInlineMsgID:"inline-42",
|
||||
wantFrom: true,
|
||||
wantFromID: 107,
|
||||
wantCallbackID: "cb-2",
|
||||
wantInlineMsgID: "inline-42",
|
||||
},
|
||||
{
|
||||
name: "shipping query",
|
||||
update: &tgapi.Update{
|
||||
Type: tgapi.UpdateTypeShippingQuery,
|
||||
Type: tgapi.UpdateTypeShippingQuery,
|
||||
ShippingQuery: &tgapi.ShippingQuery{ID: "ship", From: tgapi.User{ID: 108}},
|
||||
},
|
||||
wantFrom: true,
|
||||
@@ -206,7 +206,7 @@ func TestPrepareUpdateCtxContract(t *testing.T) {
|
||||
{
|
||||
name: "pre checkout query",
|
||||
update: &tgapi.Update{
|
||||
Type: tgapi.UpdateTypePreCheckoutQuery,
|
||||
Type: tgapi.UpdateTypePreCheckoutQuery,
|
||||
PreCheckoutQuery: &tgapi.PreCheckoutQuery{ID: "pre", From: tgapi.User{ID: 109}},
|
||||
},
|
||||
wantFrom: true,
|
||||
@@ -215,7 +215,7 @@ func TestPrepareUpdateCtxContract(t *testing.T) {
|
||||
{
|
||||
name: "purchased paid media",
|
||||
update: &tgapi.Update{
|
||||
Type: tgapi.UpdateTypePurchasedPaidMedia,
|
||||
Type: tgapi.UpdateTypePurchasedPaidMedia,
|
||||
PurchasedPaidMedia: &tgapi.PaidMediaPurchased{From: tgapi.User{ID: 110}},
|
||||
},
|
||||
wantFrom: true,
|
||||
@@ -224,7 +224,7 @@ func TestPrepareUpdateCtxContract(t *testing.T) {
|
||||
{
|
||||
name: "my chat member",
|
||||
update: &tgapi.Update{
|
||||
Type: tgapi.UpdateTypeMyChatMember,
|
||||
Type: tgapi.UpdateTypeMyChatMember,
|
||||
MyChatMember: &tgapi.ChatMemberUpdated{From: tgapi.User{ID: 111}},
|
||||
},
|
||||
wantFrom: true,
|
||||
@@ -233,7 +233,7 @@ func TestPrepareUpdateCtxContract(t *testing.T) {
|
||||
{
|
||||
name: "chat member",
|
||||
update: &tgapi.Update{
|
||||
Type: tgapi.UpdateTypeChatMember,
|
||||
Type: tgapi.UpdateTypeChatMember,
|
||||
ChatMember: &tgapi.ChatMemberUpdated{From: tgapi.User{ID: 112}},
|
||||
},
|
||||
wantFrom: true,
|
||||
@@ -242,7 +242,7 @@ func TestPrepareUpdateCtxContract(t *testing.T) {
|
||||
{
|
||||
name: "chat join request",
|
||||
update: &tgapi.Update{
|
||||
Type: tgapi.UpdateTypeChatJoinRequest,
|
||||
Type: tgapi.UpdateTypeChatJoinRequest,
|
||||
ChatJoinRequest: &tgapi.ChatJoinRequest{From: tgapi.User{ID: 113}},
|
||||
},
|
||||
wantFrom: true,
|
||||
@@ -251,7 +251,7 @@ func TestPrepareUpdateCtxContract(t *testing.T) {
|
||||
{
|
||||
name: "business connection",
|
||||
update: &tgapi.Update{
|
||||
Type: tgapi.UpdateTypeBusinessConnection,
|
||||
Type: tgapi.UpdateTypeBusinessConnection,
|
||||
BusinessConnection: &tgapi.BusinessConnection{User: tgapi.User{ID: 114}},
|
||||
},
|
||||
wantFrom: true,
|
||||
@@ -260,7 +260,7 @@ func TestPrepareUpdateCtxContract(t *testing.T) {
|
||||
{
|
||||
name: "poll answer",
|
||||
update: &tgapi.Update{
|
||||
Type: tgapi.UpdateTypePollAnswer,
|
||||
Type: tgapi.UpdateTypePollAnswer,
|
||||
PollAnswer: &tgapi.PollAnswer{User: tgapi.User{ID: 115}},
|
||||
},
|
||||
wantFrom: true,
|
||||
@@ -269,7 +269,7 @@ func TestPrepareUpdateCtxContract(t *testing.T) {
|
||||
{
|
||||
name: "message reaction",
|
||||
update: &tgapi.Update{
|
||||
Type: tgapi.UpdateTypeMessageReaction,
|
||||
Type: tgapi.UpdateTypeMessageReaction,
|
||||
MessageReaction: &tgapi.MessageReactionUpdated{User: &tgapi.User{ID: 116}},
|
||||
},
|
||||
wantFrom: true,
|
||||
@@ -307,7 +307,7 @@ func TestPrepareUpdateCtxContract(t *testing.T) {
|
||||
{
|
||||
name: "message reaction count",
|
||||
update: &tgapi.Update{
|
||||
Type: tgapi.UpdateTypeMessageReactionCount,
|
||||
Type: tgapi.UpdateTypeMessageReactionCount,
|
||||
MessageReactionCount: &tgapi.MessageReactionCountUpdated{},
|
||||
},
|
||||
},
|
||||
@@ -315,7 +315,7 @@ func TestPrepareUpdateCtxContract(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
bot := &Bot[NoDB]{}
|
||||
bot := &Bot[NoData]{}
|
||||
ctx := &MsgContext{}
|
||||
bot.prepareUpdateCtx(tt.update, ctx)
|
||||
|
||||
@@ -384,7 +384,7 @@ func TestHandleUpdateHandlersPopulateFromContext(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
called := false
|
||||
plugin := NewPlugin[NoDB]("test").AddUpdateHandler(tt.update.Type, func(ctx *MsgContext, db NoDB) error {
|
||||
plugin := NewPlugin[NoData]("test").AddUpdateHandler(tt.update.Type, func(ctx *MsgContext, db NoData) error {
|
||||
called = true
|
||||
if ctx.Update.UpdateID != tt.update.UpdateID {
|
||||
t.Fatalf("unexpected update in context: got %d want %d", ctx.Update.UpdateID, tt.update.UpdateID)
|
||||
@@ -404,9 +404,9 @@ func TestHandleUpdateHandlersPopulateFromContext(t *testing.T) {
|
||||
return nil
|
||||
})
|
||||
|
||||
bot := &Bot[NoDB]{
|
||||
bot := &Bot[NoData]{
|
||||
logger: slog.CreateLogger(),
|
||||
plugins: []Plugin[NoDB]{clonePlugin(plugin)},
|
||||
plugins: []Plugin[NoData]{clonePlugin(plugin)},
|
||||
}
|
||||
|
||||
bot.handle(context.Background(), tt.update)
|
||||
@@ -422,7 +422,7 @@ func TestHandleUpdateHandlersReceiveIsolatedContexts(t *testing.T) {
|
||||
firstCalled := 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
|
||||
if ctx.FromID != 41 {
|
||||
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"}
|
||||
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
|
||||
if ctx.From == nil {
|
||||
t.Fatal("expected ctx.From to remain populated for second handler")
|
||||
@@ -450,9 +450,9 @@ func TestHandleUpdateHandlersReceiveIsolatedContexts(t *testing.T) {
|
||||
return nil
|
||||
})
|
||||
|
||||
bot := &Bot[NoDB]{
|
||||
bot := &Bot[NoData]{
|
||||
logger: slog.CreateLogger(),
|
||||
plugins: []Plugin[NoDB]{
|
||||
plugins: []Plugin[NoData]{
|
||||
clonePlugin(first),
|
||||
clonePlugin(second),
|
||||
},
|
||||
@@ -475,8 +475,8 @@ func TestHandleUpdateHandlersReceiveIsolatedContexts(t *testing.T) {
|
||||
|
||||
func TestHandleChannelPostCommandWithSenderChat(t *testing.T) {
|
||||
called := false
|
||||
plugin := NewPlugin[NoDB]("test")
|
||||
plugin.NewCommand(func(ctx *MsgContext, db NoDB) error {
|
||||
plugin := NewPlugin[NoData]("test")
|
||||
plugin.NewCommand(func(ctx *MsgContext, db NoData) error {
|
||||
called = true
|
||||
if ctx.Msg == nil {
|
||||
t.Fatal("expected message context")
|
||||
@@ -493,10 +493,10 @@ func TestHandleChannelPostCommandWithSenderChat(t *testing.T) {
|
||||
return nil
|
||||
}, "ping")
|
||||
|
||||
bot := &Bot[NoDB]{
|
||||
bot := &Bot[NoData]{
|
||||
logger: slog.CreateLogger(),
|
||||
prefixes: []string{"/"},
|
||||
plugins: []Plugin[NoDB]{clonePlugin(plugin)},
|
||||
plugins: []Plugin[NoData]{clonePlugin(plugin)},
|
||||
}
|
||||
|
||||
bot.handle(context.Background(), &tgapi.Update{
|
||||
@@ -522,18 +522,18 @@ func TestCommandHandlerBindArgsEndToEnd(t *testing.T) {
|
||||
}
|
||||
|
||||
var got banInput
|
||||
plugin := NewPlugin[NoDB]("test")
|
||||
plugin.NewCommand(func(ctx *MsgContext, db NoDB) error {
|
||||
plugin := NewPlugin[NoData]("test")
|
||||
plugin.NewCommand(func(ctx *MsgContext, db NoData) error {
|
||||
return ctx.BindArgs(&got)
|
||||
}, "ban",
|
||||
NewCommandArg("user_id").SetValueType(CommandValueIntType).SetRequired(),
|
||||
NewCommandArg("reason").SetRequired(),
|
||||
)
|
||||
|
||||
bot := &Bot[NoDB]{
|
||||
bot := &Bot[NoData]{
|
||||
logger: slog.CreateLogger(),
|
||||
prefixes: []string{"/"},
|
||||
plugins: []Plugin[NoDB]{clonePlugin(plugin)},
|
||||
plugins: []Plugin[NoData]{clonePlugin(plugin)},
|
||||
}
|
||||
|
||||
bot.handle(context.Background(), &tgapi.Update{
|
||||
@@ -559,18 +559,18 @@ func TestPayloadHandlerBindArgsEndToEnd(t *testing.T) {
|
||||
}
|
||||
|
||||
var got payloadInput
|
||||
plugin := NewPlugin[NoDB]("test")
|
||||
plugin.NewPayload(func(ctx *MsgContext, db NoDB) error {
|
||||
plugin := NewPlugin[NoData]("test")
|
||||
plugin.NewPayload(func(ctx *MsgContext, db NoData) error {
|
||||
return ctx.BindArgs(&got)
|
||||
}, "approve",
|
||||
NewCommandArg("id").SetValueType(CommandValueIntType).SetRequired(),
|
||||
NewCommandArg("note").SetRequired(),
|
||||
)
|
||||
|
||||
bot := &Bot[NoDB]{
|
||||
bot := &Bot[NoData]{
|
||||
logger: slog.CreateLogger(),
|
||||
payloadType: BotPayloadJson,
|
||||
plugins: []Plugin[NoDB]{clonePlugin(plugin)},
|
||||
plugins: []Plugin[NoData]{clonePlugin(plugin)},
|
||||
}
|
||||
|
||||
data, err := encodeJsonPayload(CallbackData{
|
||||
@@ -601,12 +601,12 @@ func TestHandleEditedMessageStaysOutOfCommandFlow(t *testing.T) {
|
||||
commandCalled := false
|
||||
updateCalled := false
|
||||
|
||||
plugin := NewPlugin[NoDB]("test")
|
||||
plugin.NewCommand(func(ctx *MsgContext, db NoDB) error {
|
||||
plugin := NewPlugin[NoData]("test")
|
||||
plugin.NewCommand(func(ctx *MsgContext, db NoData) error {
|
||||
commandCalled = true
|
||||
return nil
|
||||
}, "ping")
|
||||
plugin.AddUpdateHandler(tgapi.UpdateTypeEditedMessage, func(ctx *MsgContext, db NoDB) error {
|
||||
plugin.AddUpdateHandler(tgapi.UpdateTypeEditedMessage, func(ctx *MsgContext, db NoData) error {
|
||||
updateCalled = true
|
||||
if ctx.Msg == nil {
|
||||
t.Fatal("expected ctx.Msg in edited message handler")
|
||||
@@ -620,10 +620,10 @@ func TestHandleEditedMessageStaysOutOfCommandFlow(t *testing.T) {
|
||||
return nil
|
||||
})
|
||||
|
||||
bot := &Bot[NoDB]{
|
||||
bot := &Bot[NoData]{
|
||||
logger: slog.CreateLogger(),
|
||||
prefixes: []string{"/"},
|
||||
plugins: []Plugin[NoDB]{clonePlugin(plugin)},
|
||||
plugins: []Plugin[NoData]{clonePlugin(plugin)},
|
||||
}
|
||||
|
||||
bot.handle(context.Background(), &tgapi.Update{
|
||||
@@ -649,12 +649,12 @@ func TestHandleEditedChannelPostStaysOutOfCommandFlow(t *testing.T) {
|
||||
commandCalled := false
|
||||
updateCalled := false
|
||||
|
||||
plugin := NewPlugin[NoDB]("test")
|
||||
plugin.NewCommand(func(ctx *MsgContext, db NoDB) error {
|
||||
plugin := NewPlugin[NoData]("test")
|
||||
plugin.NewCommand(func(ctx *MsgContext, db NoData) error {
|
||||
commandCalled = true
|
||||
return nil
|
||||
}, "ping")
|
||||
plugin.AddUpdateHandler(tgapi.UpdateTypeEditedChannelPost, func(ctx *MsgContext, db NoDB) error {
|
||||
plugin.AddUpdateHandler(tgapi.UpdateTypeEditedChannelPost, func(ctx *MsgContext, db NoData) error {
|
||||
updateCalled = true
|
||||
if ctx.Msg == nil {
|
||||
t.Fatal("expected ctx.Msg in edited channel post handler")
|
||||
@@ -662,10 +662,10 @@ func TestHandleEditedChannelPostStaysOutOfCommandFlow(t *testing.T) {
|
||||
return nil
|
||||
})
|
||||
|
||||
bot := &Bot[NoDB]{
|
||||
bot := &Bot[NoData]{
|
||||
logger: slog.CreateLogger(),
|
||||
prefixes: []string{"/"},
|
||||
plugins: []Plugin[NoDB]{clonePlugin(plugin)},
|
||||
plugins: []Plugin[NoData]{clonePlugin(plugin)},
|
||||
}
|
||||
|
||||
bot.handle(context.Background(), &tgapi.Update{
|
||||
@@ -688,8 +688,8 @@ func TestHandleEditedChannelPostStaysOutOfCommandFlow(t *testing.T) {
|
||||
|
||||
func TestHandleCallbackPopulatesMessageTargets(t *testing.T) {
|
||||
called := false
|
||||
plugin := NewPlugin[NoDB]("test")
|
||||
plugin.NewPayload(func(ctx *MsgContext, db NoDB) error {
|
||||
plugin := NewPlugin[NoData]("test")
|
||||
plugin.NewPayload(func(ctx *MsgContext, db NoData) error {
|
||||
called = true
|
||||
if ctx.CallbackQueryId != "cb-msg" {
|
||||
t.Fatalf("unexpected CallbackQueryId: %q", ctx.CallbackQueryId)
|
||||
@@ -715,10 +715,10 @@ func TestHandleCallbackPopulatesMessageTargets(t *testing.T) {
|
||||
return nil
|
||||
}, "approve")
|
||||
|
||||
bot := &Bot[NoDB]{
|
||||
bot := &Bot[NoData]{
|
||||
logger: slog.CreateLogger(),
|
||||
payloadType: BotPayloadJson,
|
||||
plugins: []Plugin[NoDB]{clonePlugin(plugin)},
|
||||
plugins: []Plugin[NoData]{clonePlugin(plugin)},
|
||||
}
|
||||
|
||||
data, err := encodeJsonPayload(CallbackData{Command: "approve", Args: []string{"7", "ok"}})
|
||||
@@ -747,8 +747,8 @@ func TestHandleCallbackPopulatesMessageTargets(t *testing.T) {
|
||||
|
||||
func TestHandleCallbackPopulatesInlineTargets(t *testing.T) {
|
||||
called := false
|
||||
plugin := NewPlugin[NoDB]("test")
|
||||
plugin.NewPayload(func(ctx *MsgContext, db NoDB) error {
|
||||
plugin := NewPlugin[NoData]("test")
|
||||
plugin.NewPayload(func(ctx *MsgContext, db NoData) error {
|
||||
called = true
|
||||
if ctx.CallbackQueryId != "cb-inline" {
|
||||
t.Fatalf("unexpected CallbackQueryId: %q", ctx.CallbackQueryId)
|
||||
@@ -774,10 +774,10 @@ func TestHandleCallbackPopulatesInlineTargets(t *testing.T) {
|
||||
return nil
|
||||
}, "inline.approve")
|
||||
|
||||
bot := &Bot[NoDB]{
|
||||
bot := &Bot[NoData]{
|
||||
logger: slog.CreateLogger(),
|
||||
payloadType: BotPayloadJson,
|
||||
plugins: []Plugin[NoDB]{clonePlugin(plugin)},
|
||||
plugins: []Plugin[NoData]{clonePlugin(plugin)},
|
||||
}
|
||||
|
||||
data, err := encodeJsonPayload(CallbackData{Command: "inline.approve", Args: []string{"9"}})
|
||||
|
||||
+8
-8
@@ -85,13 +85,13 @@ func (c CommandArg) SetRequired() CommandArg {
|
||||
}
|
||||
|
||||
// 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.
|
||||
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.
|
||||
// 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")
|
||||
description string // Human-readable description for help
|
||||
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.
|
||||
// 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.
|
||||
type Plugin[T DbContext] struct {
|
||||
type Plugin[T AppData] struct {
|
||||
name string // Name of the plugin (e.g., "admin", "user")
|
||||
commands map[string]*Command[T] // Registered commands (triggered by message)
|
||||
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.
|
||||
func NewPlugin[T DbContext](name string) *Plugin[T] {
|
||||
func NewPlugin[T AppData](name string) *Plugin[T] {
|
||||
return &Plugin[T]{
|
||||
name: name,
|
||||
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.
|
||||
// Returns true to continue execution, false to block it.
|
||||
// If async, return value is ignored.
|
||||
type MiddlewareExecutor[T DbContext] func(ctx *MsgContext, db T) bool
|
||||
type MiddlewareExecutor[T AppData] func(ctx *MsgContext, db T) bool
|
||||
|
||||
// Middleware represents a reusable execution interceptor.
|
||||
// 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
|
||||
executor MiddlewareExecutor[T] // Function to execute
|
||||
order int // Optional sort order (not used yet)
|
||||
@@ -392,7 +392,7 @@ type Middleware[T DbContext] struct {
|
||||
}
|
||||
|
||||
// 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}
|
||||
}
|
||||
|
||||
|
||||
+4
-4
@@ -6,7 +6,7 @@ import (
|
||||
)
|
||||
|
||||
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 {
|
||||
t.Fatalf("expected valid integer argument, got %v", err)
|
||||
}
|
||||
@@ -14,7 +14,7 @@ func TestValidateArgsRequiresFullMatch(t *testing.T) {
|
||||
t.Fatalf("expected ErrCmdArgRegexpMismatch for partial int match, got %v", err)
|
||||
}
|
||||
|
||||
boolCmd := NewCommand[NoDB](func(ctx *MsgContext, db NoDB) 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 {
|
||||
t.Fatalf("expected valid bool argument, got %v", err)
|
||||
}
|
||||
@@ -24,8 +24,8 @@ func TestValidateArgsRequiresFullMatch(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestValidateArgsEnforcesRequiredArgIndex(t *testing.T) {
|
||||
cmd := NewCommand[NoDB](
|
||||
func(ctx *MsgContext, db NoDB) error { return nil },
|
||||
cmd := NewCommand[NoData](
|
||||
func(ctx *MsgContext, db NoData) error { return nil },
|
||||
"mixed",
|
||||
NewCommandArg("optional"),
|
||||
NewCommandArg("required").SetRequired(),
|
||||
|
||||
+3
-3
@@ -7,7 +7,7 @@ import (
|
||||
|
||||
// RunnerFn is the function type for a runner. It receives a pointer to
|
||||
// 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
|
||||
// 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=false, async=true: Run repeatedly in a goroutine with timeout.
|
||||
// - 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
|
||||
onetime bool // If true, runs once; if false, runs periodically
|
||||
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.
|
||||
// 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]{
|
||||
name: name,
|
||||
fn: fn,
|
||||
|
||||
+6
-6
@@ -11,10 +11,10 @@ import (
|
||||
|
||||
func TestExecRunnersRunsOnetimeSyncRunner(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
bot := &Bot[NoDB]{
|
||||
bot := &Bot[NoData]{
|
||||
logger: slog.CreateLogger(),
|
||||
runners: []Runner[NoDB]{
|
||||
NewRunner("sync-once", func(*Bot[NoDB]) error {
|
||||
runners: []Runner[NoData]{
|
||||
NewRunner("sync-once", func(*Bot[NoData]) error {
|
||||
calls.Add(1)
|
||||
return nil
|
||||
}).Onetime(true).Async(false),
|
||||
@@ -33,10 +33,10 @@ func TestExecRunnersStopsBackgroundRunnerOnCancel(t *testing.T) {
|
||||
triggered := make(chan struct{}, 1)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
bot := &Bot[NoDB]{
|
||||
bot := &Bot[NoData]{
|
||||
logger: slog.CreateLogger(),
|
||||
runners: []Runner[NoDB]{
|
||||
NewRunner("background", func(*Bot[NoDB]) error {
|
||||
runners: []Runner[NoData]{
|
||||
NewRunner("background", func(*Bot[NoData]) error {
|
||||
if calls.Add(1) == 1 {
|
||||
triggered <- struct{}{}
|
||||
}
|
||||
|
||||
+4
-4
@@ -26,7 +26,7 @@ func (bot *Bot[T]) tryHandleScene(ctx *MsgContext) (bool, error) {
|
||||
if scene.PluginName != "" && scene.PluginName != plugin.name {
|
||||
continue
|
||||
}
|
||||
if !plugin.executeMiddlewares(ctx, bot.dbContext) {
|
||||
if !plugin.executeMiddlewares(ctx, bot.appData) {
|
||||
return false, nil
|
||||
}
|
||||
sceneCtx := &SceneContext{
|
||||
@@ -59,7 +59,7 @@ func (bot *Bot[T]) executeScene(scene *Scene[T], ctx *SceneContext) (bool, error
|
||||
ctx.Text = 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 {
|
||||
return false, err
|
||||
}
|
||||
@@ -71,7 +71,7 @@ func (bot *Bot[T]) executeScene(scene *Scene[T], ctx *SceneContext) (bool, error
|
||||
ctx.Args = nil
|
||||
ctx.Prefix = ""
|
||||
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 {
|
||||
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 {
|
||||
return false, err
|
||||
}
|
||||
|
||||
+25
-25
@@ -28,8 +28,8 @@ func (s failingSessionStore) Delete(key string) error {
|
||||
}
|
||||
|
||||
func TestPluginAddSceneRegistersScene(t *testing.T) {
|
||||
plugin := NewPlugin[NoDB]("wizard")
|
||||
scene := NewScene[NoDB]("signup")
|
||||
plugin := NewPlugin[NoData]("wizard")
|
||||
scene := NewScene[NoData]("signup")
|
||||
|
||||
plugin.AddScene(scene)
|
||||
|
||||
@@ -44,10 +44,10 @@ func TestPluginAddSceneRegistersScene(t *testing.T) {
|
||||
func TestBotAddPluginsPreservesScenesAndHandlesThem(t *testing.T) {
|
||||
called := false
|
||||
|
||||
plugin := NewPlugin[NoDB]("wizard")
|
||||
plugin := NewPlugin[NoData]("wizard")
|
||||
plugin.NewScene("signup").
|
||||
SetEntry("start").
|
||||
OnStep("start", func(ctx *SceneContext, db NoDB) (SceneResult, error) {
|
||||
OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) {
|
||||
called = true
|
||||
if 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
|
||||
})
|
||||
|
||||
bot := &Bot[NoDB]{
|
||||
bot := &Bot[NoData]{
|
||||
logger: slog.CreateLogger(),
|
||||
prefixes: []string{"/"},
|
||||
sessionStore: NewMemorySessionStore(),
|
||||
@@ -145,10 +145,10 @@ func TestBuildSceneKeyRejectsMissingContextFields(t *testing.T) {
|
||||
|
||||
func TestEnterSceneRejectsMissingEntryConfiguration(t *testing.T) {
|
||||
t.Run("empty entry", func(t *testing.T) {
|
||||
plugin := NewPlugin[NoDB]("wizard")
|
||||
plugin := NewPlugin[NoData]("wizard")
|
||||
plugin.NewScene("signup")
|
||||
|
||||
bot := &Bot[NoDB]{
|
||||
bot := &Bot[NoData]{
|
||||
logger: slog.CreateLogger(),
|
||||
sessionStore: NewMemorySessionStore(),
|
||||
sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser},
|
||||
@@ -168,10 +168,10 @@ func TestEnterSceneRejectsMissingEntryConfiguration(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")
|
||||
|
||||
bot := &Bot[NoDB]{
|
||||
bot := &Bot[NoData]{
|
||||
logger: slog.CreateLogger(),
|
||||
sessionStore: NewMemorySessionStore(),
|
||||
sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser},
|
||||
@@ -209,14 +209,14 @@ func TestSceneCommandHandlerRunsBeforeStep(t *testing.T) {
|
||||
sceneCommandCalled := false
|
||||
stepCalled := false
|
||||
|
||||
plugin := NewPlugin[NoDB]("wizard")
|
||||
plugin := NewPlugin[NoData]("wizard")
|
||||
plugin.NewScene("signup").
|
||||
SetEntry("start").
|
||||
OnStep("start", func(ctx *SceneContext, db NoDB) (SceneResult, error) {
|
||||
OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) {
|
||||
stepCalled = true
|
||||
return ctx.Stay(), nil
|
||||
}).
|
||||
OnCommand("cancel", func(ctx *SceneContext, db NoDB) (SceneResult, error) {
|
||||
OnCommand("cancel", func(ctx *SceneContext, db NoData) (SceneResult, error) {
|
||||
sceneCommandCalled = true
|
||||
if ctx.Prefix != "/" {
|
||||
t.Fatalf("unexpected prefix: got %q want /", ctx.Prefix)
|
||||
@@ -230,7 +230,7 @@ func TestSceneCommandHandlerRunsBeforeStep(t *testing.T) {
|
||||
return ctx.Exit(), nil
|
||||
})
|
||||
|
||||
bot := &Bot[NoDB]{
|
||||
bot := &Bot[NoData]{
|
||||
logger: slog.CreateLogger(),
|
||||
prefixes: []string{"/"},
|
||||
sessionStore: NewMemorySessionStore(),
|
||||
@@ -269,14 +269,14 @@ func TestSceneCommandHandlerRunsBeforeStep(t *testing.T) {
|
||||
func TestScenePassDoesNotPersistSessionData(t *testing.T) {
|
||||
commandCalled := false
|
||||
|
||||
plugin := NewPlugin[NoDB]("wizard")
|
||||
plugin.NewCommand(func(ctx *MsgContext, db NoDB) error {
|
||||
plugin := NewPlugin[NoData]("wizard")
|
||||
plugin.NewCommand(func(ctx *MsgContext, db NoData) error {
|
||||
commandCalled = true
|
||||
return nil
|
||||
}, "ping")
|
||||
plugin.NewScene("signup").
|
||||
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 {
|
||||
Value string `json:"value"`
|
||||
}{Value: "changed"}); err != nil {
|
||||
@@ -285,7 +285,7 @@ func TestScenePassDoesNotPersistSessionData(t *testing.T) {
|
||||
return ctx.Pass(), nil
|
||||
})
|
||||
|
||||
bot := &Bot[NoDB]{
|
||||
bot := &Bot[NoData]{
|
||||
logger: slog.CreateLogger(),
|
||||
prefixes: []string{"/"},
|
||||
sessionStore: NewMemorySessionStore(),
|
||||
@@ -348,13 +348,13 @@ func TestScenePassDoesNotPersistSessionData(t *testing.T) {
|
||||
func TestSceneMessageFallbackRunsWhenNoCommandOrStepMatch(t *testing.T) {
|
||||
fallbackCalled := false
|
||||
|
||||
plugin := NewPlugin[NoDB]("wizard")
|
||||
plugin := NewPlugin[NoData]("wizard")
|
||||
plugin.NewScene("signup").
|
||||
SetEntry("start").
|
||||
OnStep("start", func(ctx *SceneContext, db NoDB) (SceneResult, error) {
|
||||
OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) {
|
||||
return ctx.Stay(), nil
|
||||
}).
|
||||
OnMessage(func(ctx *SceneContext, db NoDB) (SceneResult, error) {
|
||||
OnMessage(func(ctx *SceneContext, db NoData) (SceneResult, error) {
|
||||
fallbackCalled = true
|
||||
if 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
|
||||
})
|
||||
|
||||
bot := &Bot[NoDB]{
|
||||
bot := &Bot[NoData]{
|
||||
logger: slog.CreateLogger(),
|
||||
prefixes: []string{"/"},
|
||||
sessionStore: NewMemorySessionStore(),
|
||||
@@ -407,7 +407,7 @@ func TestSceneMessageFallbackRunsWhenNoCommandOrStepMatch(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFindSceneSessionSupportsUserScopeWithoutMessage(t *testing.T) {
|
||||
bot := &Bot[NoDB]{
|
||||
bot := &Bot[NoData]{
|
||||
logger: slog.CreateLogger(),
|
||||
sessionStore: NewMemorySessionStore(),
|
||||
sceneScopePriority: []SceneScope{SceneScopeUser, SceneScopeChat, SceneScopeUserChat},
|
||||
@@ -434,7 +434,7 @@ func TestSceneStoreErrorsPropagate(t *testing.T) {
|
||||
setErr := errors.New("set failed")
|
||||
|
||||
t.Run("find scene session get error", func(t *testing.T) {
|
||||
bot := &Bot[NoDB]{
|
||||
bot := &Bot[NoData]{
|
||||
logger: slog.CreateLogger(),
|
||||
sessionStore: failingSessionStore{getErr: getErr},
|
||||
sceneScopePriority: []SceneScope{SceneScopeUser},
|
||||
@@ -447,10 +447,10 @@ func TestSceneStoreErrorsPropagate(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
|
||||
})
|
||||
bot := &Bot[NoDB]{
|
||||
bot := &Bot[NoData]{
|
||||
logger: slog.CreateLogger(),
|
||||
sessionStore: failingSessionStore{setErr: setErr},
|
||||
sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser},
|
||||
|
||||
Reference in New Issue
Block a user