diff --git a/.gitignore b/.gitignore index 88b114b..848b64a 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ .vscode/ test/ .codex/ +.codex diff --git a/CHANGELOG.md b/CHANGELOG.md index 299e5b4..9287891 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index b550fd5..d8cabd4 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/README_RU.md b/README_RU.md index f038beb..5a76dd5 100644 --- a/README_RU.md +++ b/README_RU.md @@ -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) diff --git a/bot.go b/bot.go index 70d47b3..02e73e9 100644 --- a/bot.go +++ b/bot.go @@ -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 } diff --git a/bot_test.go b/bot_test.go index 712e0ab..69f5832 100644 --- a/bot_test.go +++ b/bot_test.go @@ -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)) diff --git a/cmd_generator_test.go b/cmd_generator_test.go index 269ef0a..064b2b0 100644 --- a/cmd_generator_test.go +++ b/cmd_generator_test.go @@ -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")) diff --git a/doc.go b/doc.go index 9a6dcee..6cb8433 100644 --- a/doc.go +++ b/doc.go @@ -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() diff --git a/handler.go b/handler.go index e93c387..afdb882 100644 --- a/handler.go +++ b/handler.go @@ -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) } } diff --git a/handler_test.go b/handler_test.go index 9bf901a..74aa6f8 100644 --- a/handler_test.go +++ b/handler_test.go @@ -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"}}) diff --git a/plugins.go b/plugins.go index 3d4067e..53f8b76 100644 --- a/plugins.go +++ b/plugins.go @@ -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} } diff --git a/plugins_test.go b/plugins_test.go index d2d44c5..b293aec 100644 --- a/plugins_test.go +++ b/plugins_test.go @@ -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(), diff --git a/runners.go b/runners.go index dd55246..11c1d1d 100644 --- a/runners.go +++ b/runners.go @@ -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, diff --git a/runners_test.go b/runners_test.go index ec04a28..5223203 100644 --- a/runners_test.go +++ b/runners_test.go @@ -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{}{} } diff --git a/scene_handler.go b/scene_handler.go index ef790b7..9ddcc81 100644 --- a/scene_handler.go +++ b/scene_handler.go @@ -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 } diff --git a/scene_test.go b/scene_test.go index 394becb..71ae470 100644 --- a/scene_test.go +++ b/scene_test.go @@ -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},