REPOSITORY / ScuroNeko/Laniakea

Pull Requests

PULL REQUESTS REPOSITORY

v1.0.0 #9

Merged
ScuroNeko merged 101 commits from dev into main 2026-05-20 13:43:34 +03:00
16 changed files with 287 additions and 274 deletions
Showing only changes of commit e2444752c2 - Show all commits
+1
View File
@@ -3,3 +3,4 @@
.vscode/
test/
.codex/
.codex
+1
View File
@@ -9,6 +9,7 @@
- Bot configuration mutators now treat the bot as configuration-frozen after the first run begins and ignore late mutation attempts for bot-level config such as prefixes, payload defaults, plugins, middleware, runners, localization, scene session wiring, and database context injection.
- `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.
+16 -16
View File
@@ -21,8 +21,8 @@ A lightweight, easy-to-use, and performant Telegram Bot API wrapper for Go. It s
* **Middleware Support:** Run code before or after commands (e.g., logging, access control).
* **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
View File
@@ -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)
+68 -57
View File
@@ -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
View File
@@ -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))
+6 -6
View File
@@ -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"))
+3 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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},