diff --git a/CHANGELOG.md b/CHANGELOG.md index 9287891..c0006de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,16 +4,23 @@ ### Added - `AsUserError(...)`, `AsInternalError(...)`, `IsUserError(...)`, and `IsInternalError(...)` for explicitly marking centralized handler errors as user-visible or internal-only without breaking the existing default error flow. +- `Policy[T]`, `RequirePolicy(...)`, and built-in chat and callback policy helpers for expressing reusable authorization rules through the existing middleware pipeline. +- `Bot.UsePolicy(...)` and `Plugin.UsePolicy(...)` as shorthand for registering policies as middleware. +- `AllPolicies(...)`, `AnyPolicy(...)`, and `NotPolicy(...)` for composing reusable authorization rules without introducing a second execution pipeline. ### Changed - 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` normalization now also carries `Chat` and `ChatID` for more Telegram update kinds, allowing policy and update handlers to rely on normalized chat identity outside message-only flows. - `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(...)`. +- `tgapi.Chat.Type` now uses the typed `tgapi.ChatType` enum in public DTOs and tests instead of raw string casts. ### Tests - Added regression coverage for the bot configuration freeze model, including ignored post-run mutations for core bot configuration methods and late registration paths. - Added table-driven update-contract coverage for `prepareUpdateCtx(...)`, including message-backed, callback-backed, user-backed, and no-user update kinds. +- Added regression tests for policy middleware blocking, built-in private-chat policy decisions, normalized chat identity, and admin checks that use normalized `ChatID` and `FromID`. +- Added regression tests for policy composition semantics, including all-of, any-of, and deny inversion with preserved internal failures. - Added regression tests proving that `edited_message` and `edited_channel_post` stay out of command routing and continue through generic update handlers. - Added callback-routing regression tests for both chat-message and inline-message callback targets, including `CallbackQueryId`, `CallbackMsgId`, `InlineMsgId`, and payload-argument guarantees. - Added regression tests for the new error-visibility model in both message and callback flows, including silent internal-only errors and explicit user-visible callback replies. diff --git a/TODO.md b/TODO.md index 70a59a3..4057971 100644 --- a/TODO.md +++ b/TODO.md @@ -12,11 +12,13 @@ Russian page: Current priority split: -- `Priority 1`: webhook runtime model, authorization and policy model, observability model. -- `Priority 2`: service layer and dependency graph model, plugin composition contract. +- `Priority 1`: observability model. +- `Priority 2`: service layer and dependency graph model. +- `Partial`: webhook runtime model, plugin composition contract. Completed former high-priority items: +- `[v1.0.0-rc.13] Authorization and policy model`: added first-class `Policy[T]`, middleware integration through `RequirePolicy(...)`, plugin and bot policy registration helpers, built-in Telegram-aware policies, and composable `AllPolicies(...)`, `AnyPolicy(...)`, and `NotPolicy(...)` helpers with regression coverage. - `[v1.0.0-rc.13] Update schema contract`: documented and tested the normalized `MsgContext` update-routing contract, including routing categories and per-update field guarantees. - `[v1.0.0-rc.13] User-facing vs internal error model`: added explicit user-visible vs internal-only error markers and updated centralized handler error routing accordingly. - `[v1.0.0-rc.13] Configuration freeze model`: formalized bot configuration freeze after first run, documented lifecycle commit points, and added regression coverage for ignored late mutations. diff --git a/bot.go b/bot.go index 02e73e9..a437b92 100644 --- a/bot.go +++ b/bot.go @@ -6,8 +6,6 @@ import ( "fmt" "maps" "reflect" - "slices" - "sort" "strings" "sync" "time" @@ -282,38 +280,6 @@ func (bot *Bot[T]) CloseRemote(ctx context.Context) error { return nil } -// Internal logger setup for the bot and optional request logger. -func (bot *Bot[T]) initLoggers(opts *BotOpts) { - level := slog.FATAL - if opts.Debug { - level = slog.DEBUG - } - - bot.logger = utils.CreateLogger("BOT", level) - if opts.WriteToFile { - path := fmt.Sprintf("%s/main.log", strings.TrimRight(opts.LoggerBasePath, "/")) - logger, err := utils.CreateFileLogger("BOT", level, path) - if err != nil { - bot.logger.Errorln(err) - } else { - bot.logger = logger - } - } - - if opts.UseRequestLogger { - bot.RequestLogger = utils.CreateLogger("REQUESTS", level) - if opts.WriteToFile { - path := fmt.Sprintf("%s/requests.log", strings.TrimRight(opts.LoggerBasePath, "/")) - logger, err := utils.CreateFileLogger("REQUESTS", level, path) - if err != nil { - bot.logger.Errorln(err) - } else { - bot.RequestLogger = logger - } - } - } -} - // GetUpdateOffset returns the current update offset (thread-safe). func (bot *Bot[T]) GetUpdateOffset() int { bot.updateOffsetMu.Lock() @@ -328,18 +294,9 @@ func (bot *Bot[T]) SetUpdateOffset(offset int) { bot.updateOffset = offset } -// GetUpdateTypes returns the list of update types the bot is configured to receive. -func (bot *Bot[T]) GetUpdateTypes() []tgapi.UpdateType { - return append([]tgapi.UpdateType(nil), bot.updateTypes...) -} - // GetLogger returns the main bot logger. func (bot *Bot[T]) GetLogger() *slog.Logger { return bot.logger } -// 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. func (bot *Bot[T]) GetLoggerLevel() slog.LogLevel { @@ -356,340 +313,6 @@ func (bot *Bot[T]) L10n(lang, key string) string { return bot.l10n.Translate(lang, key) } -// SetDraftProvider replaces the default DraftProvider with a custom one. -// Useful for using LinearDraftIdGenerator to persist draft IDs across restarts. -func (bot *Bot[T]) SetDraftProvider(p *DraftProvider) *Bot[T] { - if !bot.configMutable("SetDraftProvider") { - return bot - } - bot.draftProvider = p - return bot -} - -// GetDraftProvider returns the draft provider currently used by the bot. -func (bot *Bot[T]) GetDraftProvider() *DraftProvider { - return bot.draftProvider -} - -// SetSessionStore replaces the session store used for scene management. -func (bot *Bot[T]) SetSessionStore(store SessionStore) *Bot[T] { - if !bot.configMutable("SetSessionStore") { - return bot - } - if store == nil { - bot.logger.Warn("SetSessionStore called with nil store; using default MemorySessionStore") - return bot - } - bot.sessionStore = store - return bot -} - -// GetSessionStore returns the session store used for scene management. -func (bot *Bot[T]) GetSessionStore() SessionStore { - return bot.sessionStore -} - -// SetSceneScopePriority sets the lookup order for resolving active scene sessions. -func (bot *Bot[T]) SetSceneScopePriority(priority []SceneScope) *Bot[T] { - if !bot.configMutable("SetSceneScopePriority") { - return bot - } - newPriority := make([]SceneScope, 0, 3) - for _, scope := range priority { - if scope != SceneScopeUser && scope != SceneScopeChat && scope != SceneScopeUserChat { - bot.logger.Warnln(fmt.Sprintf("invalid scene scope %v in priority list; ignoring", scope)) - continue - } - if slices.Index(newPriority, scope) >= 0 { - bot.logger.Warnln(fmt.Sprintf("duplicate scope %v in scene scope priority; ignoring duplicates", scope)) - continue - } - newPriority = append(newPriority, scope) - } - if len(newPriority) == 0 || len(newPriority) > 3 { - bot.logger.Warnln("scene scope priority must have 1 to 3 scopes; ignoring invalid input") - return bot - } - bot.sceneScopePriority = append([]SceneScope(nil), newPriority...) - return bot -} - -// 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 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.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.appData = ctx - bot.hasAppData = true - return bot -} - -// SetUpdateTypes sets the list of update types the bot will request from Telegram. -// Overwrites any previously set types. -func (bot *Bot[T]) SetUpdateTypes(t ...tgapi.UpdateType) *Bot[T] { - if !bot.configMutable("UpdateTypes") { - return bot - } - bot.updateTypes = make([]tgapi.UpdateType, 0) - bot.updateTypes = append(bot.updateTypes, t...) - return bot -} - -// SetPayloadType sets the default payload encoding type used for callback data. -// JSON stores payload as a string: `{"cmd":"command","args":[...]}`. -// Base64 stores the same JSON encoded as a Base64URL string. -// InlineKeyboard.SetPayloadType may override this value for an individual keyboard. -func (bot *Bot[T]) SetPayloadType(t BotPayloadType) *Bot[T] { - if !bot.configMutable("SetPayloadType") { - return bot - } - bot.payloadType = t - return bot -} - -// GetPayloadType returns the bot's default callback payload encoding type. -func (bot *Bot[T]) GetPayloadType() BotPayloadType { return bot.payloadType } - -// SetStrictPayloadType enables or disables strict callback payload decoding. -// When enabled, callback payloads must match the bot's default payload type. -func (bot *Bot[T]) SetStrictPayloadType(strict bool) *Bot[T] { - if !bot.configMutable("SetStrictPayloadType") { - return bot - } - bot.strictPayloadType = strict - return bot -} - -// AddUpdateType adds one or more update types to the list. -// Does not overwrite existing types. -func (bot *Bot[T]) AddUpdateType(t ...tgapi.UpdateType) *Bot[T] { - if !bot.configMutable("AddUpdateType") { - return bot - } - bot.updateTypes = append(bot.updateTypes, t...) - return bot -} - -// AddPrefixes adds one or more command prefixes (e.g., "/", "!"). -// Must have at least one prefix before Run(). -func (bot *Bot[T]) AddPrefixes(prefixes ...string) *Bot[T] { - if !bot.configMutable("AddPrefixes") { - return bot - } - bot.prefixes = append(bot.prefixes, prefixes...) - return bot -} - -// 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]) SetErrorTemplate(s string) *Bot[T] { - if !bot.configMutable("ErrorTemplate") { - return bot - } - bot.errorTemplate = s - return bot -} - -// SetDebug enables or disables debug logging. -func (bot *Bot[T]) SetDebug(debug bool) *Bot[T] { - bot.debug = debug - level := slog.FATAL - if debug { - level = slog.DEBUG - } - - bot.logger.Level(level) - if bot.RequestLogger != nil { - bot.RequestLogger.Level(level) - } - for _, p := range bot.plugins { - if p.logger == nil { - continue - } - p.logger.Level(level) - } - return bot -} - -// AddPlugins registers one or more plugins. -// Plugins are executed in registration order unless filtered by middleware. -// -// Registration is a commit point for plugin configuration. The Bot stores -// plugin metadata internally, so plugins must be fully configured before they -// are passed here. Post-registration mutation through the original *Plugin is -// not a supported API, even if some changes appear to work due to shared maps. -func (bot *Bot[T]) AddPlugins(plugin ...*Plugin[T]) *Bot[T] { - if !bot.configMutable("AddPlugins") { - return bot - } - level := bot.GetLoggerLevel() - for _, p := range plugin { - if p == nil { - if bot.logger != nil { - bot.logger.Warn("nil plugin skipped") - } - continue - } - cloned := clonePlugin(p) - if cloned.logger == nil { - cloned.logger = utils.CreateLogger(cloned.name, level) - } - bot.plugins = append(bot.plugins, cloned) - if bot.logger != nil { - bot.logger.Debugln(fmt.Sprintf("plugins with name \"%s\" registered", cloned.name)) - } - } - return bot -} - -// AddMiddleware registers one or more middleware handlers. -// -// Middleware are executed in order of increasing .order value before plugins. -// If two middleware have the same order, they are sorted lexicographically by name. -// -// Middleware can: -// - Modify or reject updates before they reach plugins -// - Inject context (e.g., user auth state, rate limit status) -// - Log, validate, or transform incoming data -// -// Example: -// -// bot.AddMiddleware(authMiddleware, rateLimitMiddleware) -// -// Middleware with an empty name are skipped with a warning. -func (bot *Bot[T]) AddMiddleware(middleware ...Middleware[T]) *Bot[T] { - if !bot.configMutable("AddMiddleware") { - return bot - } - for _, m := range middleware { - if m.name == "" { - bot.logger.Warnln("middleware must have a non-empty name") - continue - } - bot.middlewares = append(bot.middlewares, m) - bot.logger.Debugln(fmt.Sprintf("middleware with name \"%s\" registered", m.name)) - } - - // Stable sort by order (ascending), then by name (lexicographic) - sort.Slice(bot.middlewares, func(i, j int) bool { - first := bot.middlewares[i] - second := bot.middlewares[j] - if first.order != second.order { - return first.order < second.order - } - return first.name < second.name - }) - - return bot -} - -// AddRunner registers a background runner to execute concurrently with the bot. -// -// Runners are goroutines that run independently of update processing. -// Common use cases: -// - Periodic cleanup (e.g., expiring drafts, clearing temp files) -// - Metrics collection or health checks -// - Scheduled tasks (e.g., daily announcements) -// -// Runners are started immediately after Bot.Run() is called. -// -// Example: -// -// bot.AddRunner(cleanupRunner) -// -// Runners with an empty name are skipped with a warning. -func (bot *Bot[T]) AddRunner(runner Runner[T]) *Bot[T] { - if !bot.configMutable("AddRunner") { - return bot - } - if runner.name == "" { - bot.logger.Warnln("runner must have a non-empty name") - return bot - } - bot.runners = append(bot.runners, runner) - bot.logger.Debugln(fmt.Sprintf("runner with name \"%s\" registered", runner.name)) - return 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). -// -// Example: -// -// l10n := l10n.New() -// l10n.Add("en", "hello", "Hello!") -// l10n.Add("es", "hello", "¡Hola!") -// bot.SetL10n(l10n) -// -// Replaces any previously set L10n instance. -func (bot *Bot[T]) SetL10n(l *L10n) *Bot[T] { - if !bot.configMutable("SetL10n") { - return bot - } - if l == nil { - bot.logger.Warn("SetL10n called with nil L10n; localization will be disabled") - return bot - } - bot.l10n = l - return bot -} - -// AddAppDataLoggerWriter adds an app-data-backed logger writer to all loggers. -// -// The writer will receive logs from: -// - Main bot logger -// - Request logger (if enabled) -// - API and Uploader loggers -// - Already registered plugin loggers -// -// Call this after AddPlugins if plugin loggers should also receive the writer. -// Plugins registered later do not automatically inherit previously added -// writers; call AddAppDataLoggerWriter again after adding them. -// -// Example: -// -// bot.AddAppDataLoggerWriter(func(data *MyAppData) slog.LoggerWriter { -// return data.QueryLogger() -// }) -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.appData) { - bot.logger.Warnln("app data is nil; skipping app-data logger writer") - return bot - } - w := writer(bot.appData) - bot.logger.AddWriter(w) - if bot.RequestLogger != nil { - bot.RequestLogger.AddWriter(w) - } - for _, l := range bot.extraLoggers { - l.AddWriter(w) - } - for _, p := range bot.plugins { - if p.logger != nil { - p.logger.AddWriter(w) - } - } - return bot -} - // RunWithContext starts the bot with a given context for graceful shutdown. // // This is the main entry point for bot execution. It: @@ -706,14 +329,6 @@ func (bot *Bot[T]) AddAppDataLoggerWriter(writer AppDataLogger[T]) *Bot[T] { // RunWithContext does not close API, uploader, or logger resources on return. // The caller must invoke Close after RunWithContext finishes. // -// Example: -// -// ctx, cancel := context.WithCancel(context.Background()) -// go bot.RunWithContext(ctx) -// // ... later ... -// cancel() // triggers graceful shutdown -// _ = bot.Close() -// // A Bot is single-use. After RunWithContext returns, later calls return ErrBotAlreadyRun. func (bot *Bot[T]) RunWithContext(ctx context.Context) error { if len(bot.prefixes) == 0 { @@ -802,6 +417,37 @@ func (bot *Bot[T]) Run() error { return bot.RunWithContext(context.Background()) } +func (bot *Bot[T]) initLoggers(opts *BotOpts) { + level := slog.FATAL + if opts.Debug { + level = slog.DEBUG + } + + bot.logger = utils.CreateLogger("BOT", level) + if opts.WriteToFile { + path := fmt.Sprintf("%s/main.log", strings.TrimRight(opts.LoggerBasePath, "/")) + logger, err := utils.CreateFileLogger("BOT", level, path) + if err != nil { + bot.logger.Errorln(err) + } else { + bot.logger = logger + } + } + + if opts.UseRequestLogger { + bot.RequestLogger = utils.CreateLogger("REQUESTS", level) + if opts.WriteToFile { + path := fmt.Sprintf("%s/requests.log", strings.TrimRight(opts.LoggerBasePath, "/")) + logger, err := utils.CreateFileLogger("REQUESTS", level, path) + if err != nil { + bot.logger.Errorln(err) + } else { + bot.RequestLogger = logger + } + } + } +} + func (bot *Bot[T]) beginRun() error { bot.runStateMu.Lock() defer bot.runStateMu.Unlock() diff --git a/bot_config.go b/bot_config.go new file mode 100644 index 0000000..a2d8cd1 --- /dev/null +++ b/bot_config.go @@ -0,0 +1,203 @@ +package laniakea + +import ( + "fmt" + "slices" + + "git.scuroneko.dev/scuroneko/laniakea/tgapi" + "git.scuroneko.dev/scuroneko/slog" +) + +// AddPrefixes adds one or more command prefixes (e.g., "/", "!"). +// Must have at least one prefix before Run(). +func (bot *Bot[T]) AddPrefixes(prefixes ...string) *Bot[T] { + if !bot.configMutable("AddPrefixes") { + return bot + } + bot.prefixes = append(bot.prefixes, prefixes...) + return bot +} + +// SetDraftProvider replaces the default DraftProvider with a custom one. +// Useful for using LinearDraftIdGenerator to persist draft IDs across restarts. +func (bot *Bot[T]) SetDraftProvider(p *DraftProvider) *Bot[T] { + if !bot.configMutable("SetDraftProvider") { + return bot + } + bot.draftProvider = p + return bot +} + +// GetDraftProvider returns the draft provider currently used by the bot. +func (bot *Bot[T]) GetDraftProvider() *DraftProvider { + return bot.draftProvider +} + +// SetSessionStore replaces the session store used for scene management. +func (bot *Bot[T]) SetSessionStore(store SessionStore) *Bot[T] { + if !bot.configMutable("SetSessionStore") { + return bot + } + if store == nil { + bot.logger.Warn("SetSessionStore called with nil store; using default MemorySessionStore") + return bot + } + bot.sessionStore = store + return bot +} + +// GetSessionStore returns the session store used for scene management. +func (bot *Bot[T]) GetSessionStore() SessionStore { + return bot.sessionStore +} + +// SetSceneScopePriority sets the lookup order for resolving active scene sessions. +func (bot *Bot[T]) SetSceneScopePriority(priority []SceneScope) *Bot[T] { + if !bot.configMutable("SetSceneScopePriority") { + return bot + } + newPriority := make([]SceneScope, 0, 3) + for _, scope := range priority { + if scope != SceneScopeUser && scope != SceneScopeChat && scope != SceneScopeUserChat { + bot.logger.Warnln(fmt.Sprintf("invalid scene scope %v in priority list; ignoring", scope)) + continue + } + if slices.Index(newPriority, scope) >= 0 { + bot.logger.Warnln(fmt.Sprintf("duplicate scope %v in scene scope priority; ignoring duplicates", scope)) + continue + } + newPriority = append(newPriority, scope) + } + if len(newPriority) == 0 || len(newPriority) > 3 { + bot.logger.Warnln("scene scope priority must have 1 to 3 scopes; ignoring invalid input") + return bot + } + bot.sceneScopePriority = append([]SceneScope(nil), newPriority...) + return bot +} + +// 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 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.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.appData = ctx + bot.hasAppData = true + return bot +} + +// 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 } + +// SetUpdateTypes sets the list of update types the bot will request from Telegram. +// Overwrites any previously set types. +func (bot *Bot[T]) SetUpdateTypes(t ...tgapi.UpdateType) *Bot[T] { + if !bot.configMutable("UpdateTypes") { + return bot + } + bot.updateTypes = make([]tgapi.UpdateType, 0) + bot.updateTypes = append(bot.updateTypes, t...) + return bot +} + +// AddUpdateType adds one or more update types to the list. +// Does not overwrite existing types. +func (bot *Bot[T]) AddUpdateType(t ...tgapi.UpdateType) *Bot[T] { + if !bot.configMutable("AddUpdateType") { + return bot + } + bot.updateTypes = append(bot.updateTypes, t...) + return bot +} + +// GetUpdateTypes returns the list of update types the bot is configured to receive. +func (bot *Bot[T]) GetUpdateTypes() []tgapi.UpdateType { + return append([]tgapi.UpdateType(nil), bot.updateTypes...) +} + +// SetPayloadType sets the default payload encoding type used for callback data. +// JSON stores payload as a string: `{"cmd":"command","args":[...]}`. +// Base64 stores the same JSON encoded as a Base64URL string. +// InlineKeyboard.SetPayloadType may override this value for an individual keyboard. +func (bot *Bot[T]) SetPayloadType(t BotPayloadType) *Bot[T] { + if !bot.configMutable("SetPayloadType") { + return bot + } + bot.payloadType = t + return bot +} + +// GetPayloadType returns the bot's default callback payload encoding type. +func (bot *Bot[T]) GetPayloadType() BotPayloadType { return bot.payloadType } + +// SetStrictPayloadType enables or disables strict callback payload decoding. +// When enabled, callback payloads must match the bot's default payload type. +func (bot *Bot[T]) SetStrictPayloadType(strict bool) *Bot[T] { + if !bot.configMutable("SetStrictPayloadType") { + return bot + } + bot.strictPayloadType = strict + return bot +} + +// 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]) SetErrorTemplate(s string) *Bot[T] { + if !bot.configMutable("ErrorTemplate") { + return bot + } + bot.errorTemplate = s + return bot +} + +// SetDebug enables or disables debug logging. +func (bot *Bot[T]) SetDebug(debug bool) *Bot[T] { + bot.debug = debug + level := slog.FATAL + if debug { + level = slog.DEBUG + } + + bot.logger.Level(level) + if bot.RequestLogger != nil { + bot.RequestLogger.Level(level) + } + for _, p := range bot.plugins { + if p.logger == nil { + continue + } + p.logger.Level(level) + } + return 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). +// +// Replaces any previously set L10n instance. +func (bot *Bot[T]) SetL10n(l *L10n) *Bot[T] { + if !bot.configMutable("SetL10n") { + return bot + } + if l == nil { + bot.logger.Warn("SetL10n called with nil L10n; localization will be disabled") + return bot + } + bot.l10n = l + return bot +} diff --git a/bot_register.go b/bot_register.go new file mode 100644 index 0000000..6a7a4ee --- /dev/null +++ b/bot_register.go @@ -0,0 +1,156 @@ +package laniakea + +import ( + "fmt" + "sort" + + "git.scuroneko.dev/scuroneko/laniakea/utils" +) + +// AddPlugins registers one or more plugins. +// Plugins are executed in registration order unless filtered by middleware. +// +// Registration is a commit point for plugin configuration. The Bot stores +// plugin metadata internally, so plugins must be fully configured before they +// are passed here. Post-registration mutation through the original *Plugin is +// not a supported API, even if some changes appear to work due to shared maps. +func (bot *Bot[T]) AddPlugins(plugin ...*Plugin[T]) *Bot[T] { + if !bot.configMutable("AddPlugins") { + return bot + } + level := bot.GetLoggerLevel() + for _, p := range plugin { + if p == nil { + if bot.logger != nil { + bot.logger.Warn("nil plugin skipped") + } + continue + } + cloned := clonePlugin(p) + if cloned.logger == nil { + cloned.logger = utils.CreateLogger(cloned.name, level) + } + bot.plugins = append(bot.plugins, cloned) + if bot.logger != nil { + bot.logger.Debugln(fmt.Sprintf("plugins with name \"%s\" registered", cloned.name)) + } + } + return bot +} + +// AddMiddleware registers one or more middleware handlers. +// +// Middleware are executed in order of increasing .order value before plugins. +// If two middleware have the same order, they are sorted lexicographically by name. +// +// Middleware can: +// - Modify or reject updates before they reach plugins +// - Inject context (e.g., user auth state, rate limit status) +// - Log, validate, or transform incoming data +// +// Example: +// +// bot.AddMiddleware(authMiddleware, rateLimitMiddleware) +// +// Middleware with an empty name are skipped with a warning. +func (bot *Bot[T]) AddMiddleware(middleware ...Middleware[T]) *Bot[T] { + if !bot.configMutable("AddMiddleware") { + return bot + } + for _, m := range middleware { + if m.name == "" { + bot.logger.Warnln("middleware must have a non-empty name") + continue + } + bot.middlewares = append(bot.middlewares, m) + bot.logger.Debugln(fmt.Sprintf("middleware with name \"%s\" registered", m.name)) + } + + // Stable sort by order (ascending), then by name (lexicographic) + sort.Slice(bot.middlewares, func(i, j int) bool { + first := bot.middlewares[i] + second := bot.middlewares[j] + if first.order != second.order { + return first.order < second.order + } + return first.name < second.name + }) + + return bot +} + +// UsePolicy registers a Policy as a bot-level middleware. +func (bot *Bot[T]) UsePolicy(name string, policy Policy[T]) *Bot[T] { + mw := RequirePolicy(name, policy) + return bot.AddMiddleware(mw) +} + +// AddRunner registers a background runner to execute concurrently with the bot. +// +// Runners are goroutines that run independently of update processing. +// Common use cases: +// - Periodic cleanup (e.g., expiring drafts, clearing temp files) +// - Metrics collection or health checks +// - Scheduled tasks (e.g., daily announcements) +// +// Runners are started immediately after Bot.Run() is called. +// +// Example: +// +// bot.AddRunner(cleanupRunner) +// +// Runners with an empty name are skipped with a warning. +func (bot *Bot[T]) AddRunner(runner Runner[T]) *Bot[T] { + if !bot.configMutable("AddRunner") { + return bot + } + if runner.name == "" { + bot.logger.Warnln("runner must have a non-empty name") + return bot + } + bot.runners = append(bot.runners, runner) + bot.logger.Debugln(fmt.Sprintf("runner with name \"%s\" registered", runner.name)) + return bot +} + +// AddAppDataLoggerWriter adds an app-data-backed logger writer to all loggers. +// +// The writer will receive logs from: +// - Main bot logger +// - Request logger (if enabled) +// - API and Uploader loggers +// - Already registered plugin loggers +// +// Call this after AddPlugins if plugin loggers should also receive the writer. +// Plugins registered later do not automatically inherit previously added +// writers; call AddAppDataLoggerWriter again after adding them. +// +// Example: +// +// bot.AddAppDataLoggerWriter(func(data *MyAppData) slog.LoggerWriter { +// return data.QueryLogger() +// }) +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.appData) { + bot.logger.Warnln("app data is nil; skipping app-data logger writer") + return bot + } + w := writer(bot.appData) + bot.logger.AddWriter(w) + if bot.RequestLogger != nil { + bot.RequestLogger.AddWriter(w) + } + for _, l := range bot.extraLoggers { + l.AddWriter(w) + } + for _, p := range bot.plugins { + if p.logger != nil { + p.logger.AddWriter(w) + } + } + return bot +} diff --git a/drafts_test.go b/drafts_test.go index 3d79e03..757aa16 100644 --- a/drafts_test.go +++ b/drafts_test.go @@ -22,7 +22,7 @@ func TestMsgContextNewDraftWorksWithoutLimiter(t *testing.T) { ctx := &MsgContext{ Api: &tgapi.API{}, Msg: &tgapi.Message{ - Chat: &tgapi.Chat{ID: 42, Type: string(tgapi.ChatTypePrivate)}, + Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}, }, Logger: slog.CreateLogger(), draftProvider: NewRandomDraftProvider(&tgapi.API{}), diff --git a/handler.go b/handler.go index afdb882..89797e7 100644 --- a/handler.go +++ b/handler.go @@ -6,7 +6,6 @@ import ( "encoding/json" "errors" "fmt" - "strings" "git.scuroneko.dev/scuroneko/laniakea/tgapi" ) @@ -61,103 +60,6 @@ func (bot *Bot[T]) handle(parentCtx context.Context, u *tgapi.Update) { } } -func (bot *Bot[T]) handleMessage(update *tgapi.Update, ctx *MsgContext) { - var msg *tgapi.Message - if update.Message != nil { - msg = update.Message - } else if update.ChannelPost != nil { - msg = update.ChannelPost - } else { - return - } - - var text string - if len(msg.Text) > 0 { - text = msg.Text - } else if len(msg.Caption) > 0 { - text = msg.Caption - } else { - return - } - - prefix, cmd, args := bot.parseCommand(text) - if cmd == "" { - return - } - ctx.Prefix = prefix - - if strings.Contains(cmd, "@") { - botUsername := bot.username - if botUsername != "" && strings.HasSuffix(cmd, "@"+botUsername) { - cmd = cmd[:len(cmd)-len("@"+botUsername)] // убираем @botname - } - } - - // Ищем команду по точному совпадению - for _, plugin := range bot.plugins { - if _, exists := plugin.commands[cmd]; exists { - ctx.Text = args - ctx.Args = strings.Fields(args) // Убирает лишние пробелы - - if plugin.logger != nil { - ctx.Logger = plugin.logger - } - if !plugin.executeMiddlewares(ctx, bot.appData) { - return - } - plugin.executeCmd(cmd, ctx, bot.appData) - return - } - } -} - -func (bot *Bot[T]) handleCallback(update *tgapi.Update, ctx *MsgContext) { - data, err := bot.decodePayload(update.CallbackQuery.Data) - if err != nil { - bot.logger.Errorln(err) - return - } - - ctx.Args = data.Args - - for _, plugin := range bot.plugins { - _, ok := plugin.payloads[data.Command] - if !ok { - continue - } - - ctx.Logger = plugin.logger - if ctx.Logger == nil { - ctx.Logger = bot.logger - } - if !plugin.executeMiddlewares(ctx, bot.appData) { - return - } - plugin.executePayload(data.Command, ctx, bot.appData) - return - } -} - -func (bot *Bot[T]) handleUpdate(u *tgapi.Update, ctx *MsgContext) { - for _, plugin := range bot.plugins { - handler, ok := plugin.handlers[u.Type] - if !ok { - continue - } - - pluginCtx := cloneMsgContext(ctx) - if plugin.logger != nil { - pluginCtx.Logger = plugin.logger - } - if !plugin.executeMiddlewares(pluginCtx, bot.appData) { - continue - } - if err := handler(pluginCtx, bot.appData); err != nil { - pluginCtx.error(err) - } - } -} - func cloneMsgContext(src *MsgContext) *MsgContext { cloned := *src if src.Args != nil { @@ -166,139 +68,6 @@ func cloneMsgContext(src *MsgContext) *MsgContext { return &cloned } -func (bot *Bot[T]) prepareUpdateCtx(u *tgapi.Update, ctx *MsgContext) { - var from *tgapi.User - switch u.Type { - case tgapi.UpdateTypeMessage: - if u.Message != nil { - ctx.Msg = u.Message - } - case tgapi.UpdateTypeEditedMessage: - if u.EditedMessage != nil { - ctx.Msg = u.EditedMessage - } - case tgapi.UpdateTypeChannelPost: - if u.ChannelPost != nil { - ctx.Msg = u.ChannelPost - } - case tgapi.UpdateTypeEditedChannelPost: - if u.EditedChannelPost != nil { - ctx.Msg = u.EditedChannelPost - } - case tgapi.UpdateTypeBusinessMessage: - if u.BusinessMessage != nil { - ctx.Msg = u.BusinessMessage - } - case tgapi.UpdateTypeEditedBusinessMessage: - if u.EditedBusinessMessage != nil { - ctx.Msg = u.EditedBusinessMessage - } - case tgapi.UpdateTypeInlineQuery: - if u.InlineQuery != nil { - from = &u.InlineQuery.From - } - case tgapi.UpdateTypeChosenInlineResult: - if u.ChosenInlineResult != nil { - from = &u.ChosenInlineResult.From - } - case tgapi.UpdateTypeCallbackQuery: - if u.CallbackQuery != nil { - if u.CallbackQuery.Message != nil { - ctx.Msg = u.CallbackQuery.Message - ctx.CallbackMsgId = u.CallbackQuery.Message.MessageID - } - if u.CallbackQuery.InlineMessageID != nil { - ctx.InlineMsgId = *u.CallbackQuery.InlineMessageID - } - ctx.CallbackQueryId = u.CallbackQuery.ID - from = &u.CallbackQuery.From - } - case tgapi.UpdateTypeShippingQuery: - if u.ShippingQuery != nil { - from = &u.ShippingQuery.From - } - case tgapi.UpdateTypePreCheckoutQuery: - if u.PreCheckoutQuery != nil { - from = &u.PreCheckoutQuery.From - } - case tgapi.UpdateTypePurchasedPaidMedia: - if u.PurchasedPaidMedia != nil { - from = &u.PurchasedPaidMedia.From - } - case tgapi.UpdateTypeMyChatMember: - if u.MyChatMember != nil { - from = &u.MyChatMember.From - } - case tgapi.UpdateTypeChatMember: - if u.ChatMember != nil { - from = &u.ChatMember.From - } - case tgapi.UpdateTypeChatJoinRequest: - if u.ChatJoinRequest != nil { - from = &u.ChatJoinRequest.From - } - case tgapi.UpdateTypeBusinessConnection: - if u.BusinessConnection != nil { - from = &u.BusinessConnection.User - } - case tgapi.UpdateTypePollAnswer: - if u.PollAnswer != nil { - from = &u.PollAnswer.User - } - case tgapi.UpdateTypeMessageReaction: - if u.MessageReaction != nil { - from = u.MessageReaction.User - } - case tgapi.UpdateTypeChatBoost: - if u.ChatBoost != nil { - from = &u.ChatBoost.Boost.Source.User - } - case tgapi.UpdateTypeRemovedChatBoost: - if u.RemovedChatBoost != nil { - from = &u.RemovedChatBoost.Source.User - } - } - if ctx.Msg != nil && from == nil { - from = ctx.Msg.From - } - if from != nil { - ctx.From = from - ctx.FromID = from.ID - } -} - -func (bot *Bot[T]) checkPrefixes(text string) (string, bool) { - for _, prefix := range bot.prefixes { - if prefix == "" { - if bot.logger != nil { - bot.logger.Warnln("empty prefix is not allowed") - } - continue - } - if strings.HasPrefix(text, prefix) { - return prefix, true - } - } - return "", false -} -func (bot *Bot[T]) parseCommand(text string) (prefix, cmd, args string) { - if prefix, hasPrefix := bot.checkPrefixes(text); hasPrefix { - text = strings.TrimSpace(text[len(prefix):]) - spaceIndex := strings.Index(text, " ") - var cmd string - var args string - if spaceIndex == -1 { - cmd = text - args = "" - } else { - cmd = text[:spaceIndex] - args = strings.TrimSpace(text[spaceIndex:]) - } - return prefix, cmd, args - } - return "", "", "" -} - func encodeJsonPayload(d CallbackData) (string, error) { b, err := json.Marshal(d) if err != nil { @@ -306,11 +75,13 @@ func encodeJsonPayload(d CallbackData) (string, error) { } return string(b), nil } + func decodeJsonPayload(s string) (CallbackData, error) { var data CallbackData err := json.Unmarshal([]byte(s), &data) return data, err } + func encodeBase64Payload(d CallbackData) (string, error) { data, err := encodeJsonPayload(d) if err != nil { @@ -321,15 +92,6 @@ func encodeBase64Payload(d CallbackData) (string, error) { return string(dst), nil } -// func encodePayload(payloadType BotPayloadType, d CallbackData) (string, error) { -// switch payloadType { -// case BotPayloadBase64: -// return encodeBase64Payload(d) -// case BotPayloadJson: -// return encodeJsonPayload(d) -// } -// return "", ErrInvalidPayloadType -// } func decodeBase64Payload(s string) (CallbackData, error) { b, err := base64.RawURLEncoding.DecodeString(s) if err != nil { @@ -337,6 +99,7 @@ func decodeBase64Payload(s string) (CallbackData, error) { } return decodeJsonPayload(string(b)) } + func decodePayload(payloadType BotPayloadType, s string, strict bool) (CallbackData, BotPayloadType, error) { switch payloadType { case BotPayloadBase64: @@ -369,9 +132,6 @@ func decodePayload(payloadType BotPayloadType, s string, strict bool) (CallbackD return CallbackData{}, "", ErrInvalidPayloadType } -// func (bot *Bot[T]) encodePayload(d CallbackData) (string, error) { -// return encodePayload(bot.payloadType, d) -// } func (bot *Bot[T]) decodePayload(s string) (CallbackData, error) { data, decodedType, err := decodePayload(bot.payloadType, s, bot.strictPayloadType) if err != nil { diff --git a/handler_test.go b/handler_test.go index 74aa6f8..3b0674c 100644 --- a/handler_test.go +++ b/handler_test.go @@ -85,6 +85,8 @@ func TestPrepareUpdateCtxContract(t *testing.T) { wantMsg bool wantFrom bool wantFromID int64 + wantChat bool + wantChatID int64 wantCallbackID string wantCallbackMsgID int wantInlineMsgID string @@ -102,6 +104,8 @@ func TestPrepareUpdateCtxContract(t *testing.T) { wantMsg: true, wantFrom: true, wantFromID: 101, + wantChat: true, + wantChatID: 1001, }, { name: "edited message", @@ -116,6 +120,8 @@ func TestPrepareUpdateCtxContract(t *testing.T) { wantMsg: true, wantFrom: true, wantFromID: 102, + wantChat: true, + wantChatID: 1002, }, { name: "channel post sender chat", @@ -126,7 +132,9 @@ func TestPrepareUpdateCtxContract(t *testing.T) { Chat: &tgapi.Chat{ID: -1003}, }, }, - wantMsg: true, + wantMsg: true, + wantChat: true, + wantChatID: -1003, }, { name: "business message", @@ -141,6 +149,8 @@ func TestPrepareUpdateCtxContract(t *testing.T) { wantMsg: true, wantFrom: true, wantFromID: 103, + wantChat: true, + wantChatID: 1004, }, { name: "inline query", @@ -176,6 +186,8 @@ func TestPrepareUpdateCtxContract(t *testing.T) { wantMsg: true, wantFrom: true, wantFromID: 106, + wantChat: true, + wantChatID: 1005, wantCallbackID: "cb-1", wantCallbackMsgID: 77, }, @@ -225,28 +237,34 @@ func TestPrepareUpdateCtxContract(t *testing.T) { name: "my chat member", update: &tgapi.Update{ Type: tgapi.UpdateTypeMyChatMember, - MyChatMember: &tgapi.ChatMemberUpdated{From: tgapi.User{ID: 111}}, + MyChatMember: &tgapi.ChatMemberUpdated{From: tgapi.User{ID: 111}, Chat: tgapi.Chat{ID: -2001}}, }, wantFrom: true, wantFromID: 111, + wantChat: true, + wantChatID: -2001, }, { name: "chat member", update: &tgapi.Update{ Type: tgapi.UpdateTypeChatMember, - ChatMember: &tgapi.ChatMemberUpdated{From: tgapi.User{ID: 112}}, + ChatMember: &tgapi.ChatMemberUpdated{From: tgapi.User{ID: 112}, Chat: tgapi.Chat{ID: -2002}}, }, wantFrom: true, wantFromID: 112, + wantChat: true, + wantChatID: -2002, }, { name: "chat join request", update: &tgapi.Update{ Type: tgapi.UpdateTypeChatJoinRequest, - ChatJoinRequest: &tgapi.ChatJoinRequest{From: tgapi.User{ID: 113}}, + ChatJoinRequest: &tgapi.ChatJoinRequest{From: tgapi.User{ID: 113}, Chat: tgapi.Chat{ID: -2003}}, }, wantFrom: true, wantFromID: 113, + wantChat: true, + wantChatID: -2003, }, { name: "business connection", @@ -270,32 +288,40 @@ func TestPrepareUpdateCtxContract(t *testing.T) { name: "message reaction", update: &tgapi.Update{ Type: tgapi.UpdateTypeMessageReaction, - MessageReaction: &tgapi.MessageReactionUpdated{User: &tgapi.User{ID: 116}}, + MessageReaction: &tgapi.MessageReactionUpdated{User: &tgapi.User{ID: 116}, Chat: &tgapi.Chat{ID: -2004}}, }, wantFrom: true, wantFromID: 116, + wantChat: true, + wantChatID: -2004, }, { name: "chat boost", update: &tgapi.Update{ Type: tgapi.UpdateTypeChatBoost, ChatBoost: &tgapi.ChatBoostUpdated{ + Chat: tgapi.Chat{ID: -2005}, Boost: tgapi.ChatBoost{Source: tgapi.ChatBoostSource{User: tgapi.User{ID: 117}}}, }, }, wantFrom: true, wantFromID: 117, + wantChat: true, + wantChatID: -2005, }, { name: "removed chat boost", update: &tgapi.Update{ Type: tgapi.UpdateTypeRemovedChatBoost, RemovedChatBoost: &tgapi.ChatBoostRemoved{ + Chat: tgapi.Chat{ID: -2006}, Source: tgapi.ChatBoostSource{User: tgapi.User{ID: 118}}, }, }, wantFrom: true, wantFromID: 118, + wantChat: true, + wantChatID: -2006, }, { name: "poll", @@ -328,6 +354,12 @@ func TestPrepareUpdateCtxContract(t *testing.T) { if ctx.FromID != tt.wantFromID { t.Fatalf("unexpected FromID: got %d want %d", ctx.FromID, tt.wantFromID) } + if got := ctx.Chat != nil; got != tt.wantChat { + t.Fatalf("unexpected Chat presence: got %v want %v", got, tt.wantChat) + } + if ctx.ChatID != tt.wantChatID { + t.Fatalf("unexpected ChatID: got %d want %d", ctx.ChatID, tt.wantChatID) + } if ctx.CallbackQueryId != tt.wantCallbackID { t.Fatalf("unexpected CallbackQueryId: got %q want %q", ctx.CallbackQueryId, tt.wantCallbackID) } @@ -505,8 +537,8 @@ func TestHandleChannelPostCommandWithSenderChat(t *testing.T) { ChannelPost: &tgapi.Message{ MessageID: 55, Text: "/ping", - SenderChat: &tgapi.Chat{ID: -1001, Type: string(tgapi.ChatTypeChannel)}, - Chat: &tgapi.Chat{ID: -1001, Type: string(tgapi.ChatTypeChannel)}, + SenderChat: &tgapi.Chat{ID: -1001, Type: tgapi.ChatTypeChannel}, + Chat: &tgapi.Chat{ID: -1001, Type: tgapi.ChatTypeChannel}, }, }) @@ -542,7 +574,7 @@ func TestCommandHandlerBindArgsEndToEnd(t *testing.T) { Message: &tgapi.Message{ MessageID: 1, Text: "/ban 42 too loud", - Chat: &tgapi.Chat{ID: 99, Type: string(tgapi.ChatTypePrivate)}, + Chat: &tgapi.Chat{ID: 99, Type: tgapi.ChatTypePrivate}, }, }) diff --git a/msg_context.go b/msg_context.go index 31f488f..b749013 100644 --- a/msg_context.go +++ b/msg_context.go @@ -21,6 +21,7 @@ import ( // - Update is always present. // - Msg is populated only for update kinds that carry a Telegram message object. // - From and FromID are populated only when the update exposes a user identity. +// - Chat and ChatID are populated only when the update exposes a chat identity. // - Text, Args, and Prefix are populated only by command or scene command routing. // - CallbackQueryId, CallbackMsgId, and InlineMsgId are populated only for // callback query handling when the corresponding callback targets exist. @@ -38,6 +39,9 @@ type MsgContext struct { // From is the normalized Telegram user for update kinds that expose one. // It stays nil for sender-chat-only updates and update kinds without a user. From *tgapi.User + // Chat is the normalized Telegram chat for update kinds that expose one. + // It is nil for updates that do not include a chat identity. + Chat *tgapi.Chat // Logger is the logger assigned by the matched plugin for the current handler call. // It may fall back to the bot logger when the plugin has no dedicated logger. @@ -55,6 +59,9 @@ type MsgContext struct { // FromID is the normalized sender ID when the current update exposes a user. // It is zero when the update has no user identity. FromID int64 + // ChatID is the normalized chat ID when the current update exposes a chat. + // It is zero when the update has no chat identity. + ChatID int64 // Prefix is the matched command prefix for command routing and scene-local // command routing. It is empty outside those flows. Prefix string diff --git a/msg_context_test.go b/msg_context_test.go index f0465d6..78c67e0 100644 --- a/msg_context_test.go +++ b/msg_context_test.go @@ -47,7 +47,7 @@ func TestAnswerPhotoIncludesDirectMessagesTopicID(t *testing.T) { ctx := &MsgContext{ Api: api, Msg: &tgapi.Message{ - Chat: &tgapi.Chat{ID: 42, Type: string(tgapi.ChatTypePrivate)}, + Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}, DirectMessageTopic: &tgapi.DirectMessageTopic{TopicID: 77}, }, Logger: slog.CreateLogger(), @@ -201,7 +201,7 @@ func TestErrorDefaultRemainsUserVisibleForMessageFlow(t *testing.T) { ctx := &MsgContext{ Api: api, - Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: string(tgapi.ChatTypePrivate)}}, + Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}}, Logger: slog.CreateLogger(), errorTemplate: "Error: %s", } @@ -237,7 +237,7 @@ func TestErrorInternalSkipsUserReplyForMessageFlow(t *testing.T) { ctx := &MsgContext{ Api: api, - Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: string(tgapi.ChatTypePrivate)}}, + Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}}, Logger: slog.CreateLogger(), errorTemplate: "Error: %s", } @@ -326,7 +326,7 @@ func TestErrorUserVisibleAnswersCallback(t *testing.T) { func TestAnswerRejectsEmptyMessage(t *testing.T) { ctx := &MsgContext{ - Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: string(tgapi.ChatTypePrivate)}}, + Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}}, Logger: slog.CreateLogger(), } @@ -356,7 +356,7 @@ func TestAnswerRejectsLongMessageWithoutSendingRequest(t *testing.T) { ctx := &MsgContext{ Api: api, - Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: string(tgapi.ChatTypePrivate)}}, + Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}}, Logger: slog.CreateLogger(), } @@ -440,7 +440,7 @@ func TestAnswerLongSplitsRequestsAndAttachesKeyboardToLastChunk(t *testing.T) { ctx := &MsgContext{ Api: api, - Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: string(tgapi.ChatTypePrivate)}}, + Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}}, Logger: slog.CreateLogger(), } kb := NewInlineKeyboardJson(1).AddCallbackButton("A", "cmd") diff --git a/msg_handler.go b/msg_handler.go new file mode 100644 index 0000000..39dba5b --- /dev/null +++ b/msg_handler.go @@ -0,0 +1,117 @@ +package laniakea + +import ( + "strings" + + "git.scuroneko.dev/scuroneko/laniakea/tgapi" +) + +func (bot *Bot[T]) handleMessage(update *tgapi.Update, ctx *MsgContext) { + var msg *tgapi.Message + if update.Message != nil { + msg = update.Message + } else if update.ChannelPost != nil { + msg = update.ChannelPost + } else { + return + } + + var text string + if len(msg.Text) > 0 { + text = msg.Text + } else if len(msg.Caption) > 0 { + text = msg.Caption + } else { + return + } + + prefix, cmd, args := bot.parseCommand(text) + if cmd == "" { + return + } + ctx.Prefix = prefix + + if strings.Contains(cmd, "@") { + botUsername := bot.username + if botUsername != "" && strings.HasSuffix(cmd, "@"+botUsername) { + cmd = cmd[:len(cmd)-len("@"+botUsername)] // убираем @botname + } + } + + // Ищем команду по точному совпадению + for _, plugin := range bot.plugins { + if _, exists := plugin.commands[cmd]; exists { + ctx.Text = args + ctx.Args = strings.Fields(args) // Убирает лишние пробелы + + if plugin.logger != nil { + ctx.Logger = plugin.logger + } + if !plugin.executeMiddlewares(ctx, bot.appData) { + return + } + plugin.executeCmd(cmd, ctx, bot.appData) + return + } + } +} + +func (bot *Bot[T]) handleCallback(update *tgapi.Update, ctx *MsgContext) { + data, err := bot.decodePayload(update.CallbackQuery.Data) + if err != nil { + bot.logger.Errorln(err) + return + } + + ctx.Args = data.Args + + for _, plugin := range bot.plugins { + _, ok := plugin.payloads[data.Command] + if !ok { + continue + } + + ctx.Logger = plugin.logger + if ctx.Logger == nil { + ctx.Logger = bot.logger + } + if !plugin.executeMiddlewares(ctx, bot.appData) { + return + } + plugin.executePayload(data.Command, ctx, bot.appData) + return + } +} + +func (bot *Bot[T]) checkPrefixes(text string) (string, bool) { + for _, prefix := range bot.prefixes { + if prefix == "" { + if bot.logger != nil { + bot.logger.Warnln("empty prefix is not allowed") + } + continue + } + if strings.HasPrefix(text, prefix) { + return prefix, true + } + } + return "", false +} + +func (bot *Bot[T]) parseCommand(text string) (prefix, cmd, args string) { + if prefix, hasPrefix := bot.checkPrefixes(text); hasPrefix { + text = strings.TrimSpace(text[len(prefix):]) + spaceIndex := strings.Index(text, " ") + var cmd string + var args string + if spaceIndex == -1 { + cmd = text + args = "" + } else { + cmd = text[:spaceIndex] + args = strings.TrimSpace(text[spaceIndex:]) + } + return prefix, cmd, args + } + return "", "", "" +} diff --git a/plugins.go b/plugins.go index 53f8b76..14ff52f 100644 --- a/plugins.go +++ b/plugins.go @@ -231,6 +231,12 @@ func (p *Plugin[T]) AddScene(scene *Scene[T]) *Plugin[T] { return p } +// UsePolicy registers a Policy as plugin middleware for all plugin handlers. +func (p *Plugin[T]) UsePolicy(name string, policy Policy[T]) *Plugin[T] { + mw := RequirePolicy(name, policy) + return p.AddMiddleware(mw) +} + // NewScene creates, registers, and returns a new scene owned by the plugin. func (p *Plugin[T]) NewScene(name string) *Scene[T] { scene := NewScene[T](name) diff --git a/policy.go b/policy.go new file mode 100644 index 0000000..30d0a59 --- /dev/null +++ b/policy.go @@ -0,0 +1,210 @@ +package laniakea + +import ( + "errors" + "fmt" + + "git.scuroneko.dev/scuroneko/laniakea/tgapi" +) + +// Policy defines a reusable authorization rule for the current update context. +type Policy[T AppData] func(ctx *MsgContext, data T) error + +// RequirePolicy adapts a Policy into a blocking middleware. +func RequirePolicy[T AppData](name string, p Policy[T]) Middleware[T] { + return NewMiddleware(name, func(ctx *MsgContext, data T) bool { + if err := p(ctx, data); err != nil { + ctx.error(err) + return false + } + return true + }) +} + +// AllPolicies composes policies that all must succeed. +func AllPolicies[T AppData](policies ...Policy[T]) Policy[T] { + return func(ctx *MsgContext, data T) error { + for _, p := range policies { + if err := p(ctx, data); err != nil { + return err + } + } + return nil + } +} + +// AnyPolicy composes policies where at least one must succeed. +func AnyPolicy[T AppData](policies ...Policy[T]) Policy[T] { + return func(ctx *MsgContext, data T) error { + var firstDeny error + var internalErr error + for _, p := range policies { + err := p(ctx, data) + if err == nil { + return nil + } + if IsInternalError(err) { + if internalErr == nil { + internalErr = err + } + continue + } + if firstDeny == nil { + firstDeny = err + } + } + if internalErr != nil { + return internalErr + } + if firstDeny != nil { + return firstDeny + } + return AsUserError(errors.New("no policy matched")) + } +} + +// NotPolicy inverts a policy deny result while preserving internal failures. +func NotPolicy[T AppData](policy Policy[T]) Policy[T] { + return func(ctx *MsgContext, data T) error { + var err error + if err = policy(ctx, data); err == nil { + return AsUserError(errors.New("the action is not allowed due to policy violation")) + } + if IsInternalError(err) { + return err + } + return nil + } +} + +// RequirePrivateChat allows execution only in private chats. +func RequirePrivateChat[T AppData]() Policy[T] { + return func(ctx *MsgContext, data T) error { + if ctx.Msg == nil || ctx.Msg.Chat == nil { + return AsInternalError(errors.New("private-chat policy requires message chat context")) + } + + if ctx.Msg.Chat.Type != tgapi.ChatTypePrivate { + return AsUserError(errors.New("this action is only available in private chat")) + } + + return nil + } +} + +// RequireGroupChat allows execution only in group or supergroup chats. +func RequireGroupChat[T AppData]() Policy[T] { + return func(ctx *MsgContext, data T) error { + if ctx.Msg == nil || ctx.Msg.Chat == nil { + return AsInternalError(errors.New("group-chat policy requires message chat context")) + } + + if ctx.Msg.Chat.Type != tgapi.ChatTypeGroup && ctx.Msg.Chat.Type != tgapi.ChatTypeSupergroup { + return AsUserError(errors.New("this action is only available in group chats")) + } + + return nil + } +} + +// RequireSupergroupChat allows execution only in supergroup chats. +func RequireSupergroupChat[T AppData]() Policy[T] { + return func(ctx *MsgContext, data T) error { + if ctx.Msg == nil || ctx.Msg.Chat == nil { + return AsInternalError(errors.New("supergroup-chat policy requires message chat context")) + } + + if ctx.Msg.Chat.Type != tgapi.ChatTypeSupergroup { + return AsUserError(errors.New("this action is only available in supergroup chats")) + } + + return nil + } +} + +// RequireChatAdmin allows execution only for chat administrators or owners. +func RequireChatAdmin[T AppData]() Policy[T] { + return func(ctx *MsgContext, data T) error { + if ctx.FromID == 0 || ctx.ChatID == 0 { + return AsInternalError(errors.New("chat-admin policy requires message chat context")) + } + + member, err := ctx.Api.GetChatMember(tgapi.GetChatMemberP{ + ChatID: ctx.ChatID, + UserID: ctx.FromID, + }) + if err != nil { + return AsInternalError(fmt.Errorf("failed to fetch chat member status: %w", err)) + } + + if member.Status != tgapi.ChatMemberStatusAdministrator && member.Status != tgapi.ChatMemberStatusOwner { + return AsUserError(errors.New("this action is only available to chat admins")) + } + + return nil + } +} + +// RequireChatCreator allows execution only for the chat owner. +func RequireChatCreator[T AppData]() Policy[T] { + return func(ctx *MsgContext, data T) error { + if ctx.FromID == 0 || ctx.ChatID == 0 { + return AsInternalError(errors.New("chat-creator policy requires message chat context")) + } + + member, err := ctx.Api.GetChatMember(tgapi.GetChatMemberP{ + ChatID: ctx.ChatID, + UserID: ctx.FromID, + }) + if err != nil { + return AsInternalError(fmt.Errorf("failed to fetch chat creator: %w", err)) + } + + if member.Status != tgapi.ChatMemberStatusOwner { + return AsUserError(errors.New("this action is only available to the chat creator")) + } + + return nil + } +} + +// RequireBotAdmin allows execution only when the bot is an admin in the chat. +func RequireBotAdmin[T AppData]() Policy[T] { + return func(ctx *MsgContext, data T) error { + if ctx.ChatID == 0 { + return AsInternalError(errors.New("bot-admin policy requires message chat context")) + } + + bot, err := ctx.Api.GetMe() + if err != nil { + return AsInternalError(fmt.Errorf("failed to fetch bot info: %w", err)) + } + + member, err := ctx.Api.GetChatMember(tgapi.GetChatMemberP{ + ChatID: ctx.ChatID, + UserID: bot.ID, + }) + if err != nil { + return AsInternalError(fmt.Errorf("failed to fetch bot member status: %w", err)) + } + + if member.Status != tgapi.ChatMemberStatusAdministrator && member.Status != tgapi.ChatMemberStatusOwner { + return AsUserError(errors.New("this action requires the bot to be an admin in the chat")) + } + + return nil + } +} + +// RequireCallbackFromUser allows execution only for callback queries sent by non-bot users. +func RequireCallbackFromUser[T AppData]() Policy[T] { + return func(ctx *MsgContext, data T) error { + if ctx.Update.CallbackQuery == nil { + return AsInternalError(errors.New("callback-user policy requires callback query context")) + } + if ctx.Update.CallbackQuery.From.IsBot { + return AsUserError(errors.New("this action is only available to human users")) + } + return nil + } +} diff --git a/policy_test.go b/policy_test.go new file mode 100644 index 0000000..6e32ca3 --- /dev/null +++ b/policy_test.go @@ -0,0 +1,229 @@ +package laniakea + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "strings" + "testing" + + "git.scuroneko.dev/scuroneko/laniakea/tgapi" + "git.scuroneko.dev/scuroneko/slog" +) + +func TestRequirePolicyStopsExecutionOnDeniedPolicy(t *testing.T) { + var requests int + var gotBody map[string]any + + client := &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + requests++ + body, err := io.ReadAll(req.Body) + if err != nil { + t.Fatalf("failed to read request body: %v", err) + } + if err := json.Unmarshal(body, &gotBody); err != nil { + t.Fatalf("failed to decode request body: %v", err) + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"ok":true,"result":{"message_id":9,"date":1}}`)), + }, nil + }), + } + + api := tgapi.NewAPI( + tgapi.NewAPIOpts("token"). + SetAPIUrl("https://example.test"). + SetHTTPClient(client), + ) + defer func() { + if err := api.Close(); err != nil { + t.Fatalf("Close returned error: %v", err) + } + }() + + ctx := &MsgContext{ + Api: api, + Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}}, + Logger: slog.CreateLogger(), + errorTemplate: "Error: %s", + } + + mw := RequirePolicy[NoData]("deny", func(ctx *MsgContext, data NoData) error { + return AsUserError(errors.New("blocked")) + }) + + if mw.Execute(ctx, NoData{}) { + t.Fatal("expected denied policy middleware to stop execution") + } + if requests != 1 { + t.Fatalf("expected one user-facing error reply, got %d requests", requests) + } + if got := gotBody["text"]; got != "Error: blocked" { + t.Fatalf("unexpected policy error reply text: %v", got) + } +} + +func TestRequirePrivateChatAllowsPrivateChat(t *testing.T) { + ctx := &MsgContext{ + Msg: &tgapi.Message{ + Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}, + }, + Logger: slog.CreateLogger(), + } + + if err := RequirePrivateChat[NoData]()(ctx, NoData{}); err != nil { + t.Fatalf("RequirePrivateChat returned error: %v", err) + } +} + +func TestRequirePrivateChatDeniesNonPrivateChat(t *testing.T) { + ctx := &MsgContext{ + Msg: &tgapi.Message{ + Chat: &tgapi.Chat{ID: -100, Type: tgapi.ChatTypeSupergroup}, + }, + Logger: slog.CreateLogger(), + } + + err := RequirePrivateChat[NoData]()(ctx, NoData{}) + if err == nil { + t.Fatal("expected RequirePrivateChat to deny non-private chats") + } + if !IsUserError(err) { + t.Fatalf("expected user-visible deny error, got %v", err) + } +} + +func TestRequireChatAdminUsesNormalizedIDs(t *testing.T) { + var sawGetChatMember bool + var gotBody map[string]any + + client := &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + if !strings.Contains(req.URL.Path, "getChatMember") { + t.Fatalf("unexpected API method: %s", req.URL.Path) + } + sawGetChatMember = true + body, err := io.ReadAll(req.Body) + if err != nil { + t.Fatalf("failed to read request body: %v", err) + } + if err := json.Unmarshal(body, &gotBody); err != nil { + t.Fatalf("failed to decode request body: %v", err) + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader( + `{"ok":true,"result":{"status":"administrator","user":{"id":55,"is_bot":false,"first_name":"tester"}}}`, + )), + }, nil + }), + } + + api := tgapi.NewAPI( + tgapi.NewAPIOpts("token"). + SetAPIUrl("https://example.test"). + SetHTTPClient(client), + ) + defer func() { + if err := api.Close(); err != nil { + t.Fatalf("Close returned error: %v", err) + } + }() + + ctx := &MsgContext{ + Api: api, + ChatID: -2001, + FromID: 55, + Logger: slog.CreateLogger(), + } + + if err := RequireChatAdmin[NoData]()(ctx, NoData{}); err != nil { + t.Fatalf("RequireChatAdmin returned error: %v", err) + } + if !sawGetChatMember { + t.Fatal("expected GetChatMember to be called") + } + if got := gotBody["chat_id"]; got != float64(-2001) { + t.Fatalf("unexpected chat_id in request: %v", got) + } + if got := gotBody["user_id"]; got != float64(55) { + t.Fatalf("unexpected user_id in request: %v", got) + } +} + +func TestAllPoliciesReturnsFirstError(t *testing.T) { + want := AsUserError(errors.New("blocked")) + policy := AllPolicies[NoData]( + func(ctx *MsgContext, data NoData) error { return nil }, + func(ctx *MsgContext, data NoData) error { return want }, + func(ctx *MsgContext, data NoData) error { + t.Fatal("unexpected evaluation after first failure") + return nil + }, + ) + + err := policy(&MsgContext{Logger: slog.CreateLogger()}, NoData{}) + if !errors.Is(err, want) { + t.Fatalf("expected first policy error, got %v", err) + } +} + +func TestAnyPolicyAllowsLaterSuccessAfterInternalError(t *testing.T) { + policy := AnyPolicy[NoData]( + func(ctx *MsgContext, data NoData) error { return AsInternalError(errors.New("temporary")) }, + func(ctx *MsgContext, data NoData) error { return nil }, + ) + + if err := policy(&MsgContext{Logger: slog.CreateLogger()}, NoData{}); err != nil { + t.Fatalf("expected later success to allow access, got %v", err) + } +} + +func TestAnyPolicyReturnsInternalErrorWhenNonePass(t *testing.T) { + internal := AsInternalError(errors.New("temporary")) + policy := AnyPolicy[NoData]( + func(ctx *MsgContext, data NoData) error { return AsUserError(errors.New("denied")) }, + func(ctx *MsgContext, data NoData) error { return internal }, + ) + + err := policy(&MsgContext{Logger: slog.CreateLogger()}, NoData{}) + if !errors.Is(err, internal) { + t.Fatalf("expected internal error, got %v", err) + } +} + +func TestAnyPolicyReturnsFirstDenyWhenNoPolicyPasses(t *testing.T) { + first := AsUserError(errors.New("first deny")) + policy := AnyPolicy[NoData]( + func(ctx *MsgContext, data NoData) error { return first }, + func(ctx *MsgContext, data NoData) error { return AsUserError(errors.New("second deny")) }, + ) + + err := policy(&MsgContext{Logger: slog.CreateLogger()}, NoData{}) + if !errors.Is(err, first) { + t.Fatalf("expected first deny error, got %v", err) + } +} + +func TestNotPolicyInvertsUserDenyButPreservesInternalErrors(t *testing.T) { + inverted := NotPolicy[NoData](func(ctx *MsgContext, data NoData) error { + return AsUserError(errors.New("denied")) + }) + if err := inverted(&MsgContext{Logger: slog.CreateLogger()}, NoData{}); err != nil { + t.Fatalf("expected inverted deny to succeed, got %v", err) + } + + internal := AsInternalError(errors.New("temporary")) + preserve := NotPolicy[NoData](func(ctx *MsgContext, data NoData) error { + return internal + }) + err := preserve(&MsgContext{Logger: slog.CreateLogger()}, NoData{}) + if !errors.Is(err, internal) { + t.Fatalf("expected internal error to be preserved, got %v", err) + } +} diff --git a/scene_test.go b/scene_test.go index 71ae470..0137ba8 100644 --- a/scene_test.go +++ b/scene_test.go @@ -72,7 +72,7 @@ func TestBotAddPluginsPreservesScenesAndHandlesThem(t *testing.T) { } enterCtx := &MsgContext{ - Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: string(tgapi.ChatTypePrivate)}}, + Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}}, FromID: 42, sceneRuntime: bot, } @@ -86,7 +86,7 @@ func TestBotAddPluginsPreservesScenesAndHandlesThem(t *testing.T) { Message: &tgapi.Message{ MessageID: 7, Text: "hello there", - Chat: &tgapi.Chat{ID: 100, Type: string(tgapi.ChatTypePrivate)}, + Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}, From: &tgapi.User{ID: 42}, }, }) @@ -96,7 +96,7 @@ func TestBotAddPluginsPreservesScenesAndHandlesThem(t *testing.T) { } lookupCtx := &MsgContext{ - Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: string(tgapi.ChatTypePrivate)}}, + Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}}, FromID: 42, } if _, session, err := bot.findSceneSession(lookupCtx); err == nil && session.Scene != "" { @@ -129,7 +129,7 @@ func TestBuildSceneKeyRejectsMissingContextFields(t *testing.T) { name: "missing from id for user chat scope", scope: SceneScopeUserChat, ctx: &MsgContext{ - Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: string(tgapi.ChatTypePrivate)}}, + Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}}, }, }, } @@ -156,7 +156,7 @@ func TestEnterSceneRejectsMissingEntryConfiguration(t *testing.T) { bot.AddPlugins(plugin) ctx := &MsgContext{ - Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: string(tgapi.ChatTypePrivate)}}, + Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}}, FromID: 42, sceneRuntime: bot, } @@ -179,7 +179,7 @@ func TestEnterSceneRejectsMissingEntryConfiguration(t *testing.T) { bot.AddPlugins(plugin) ctx := &MsgContext{ - Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: string(tgapi.ChatTypePrivate)}}, + Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}}, FromID: 42, sceneRuntime: bot, } @@ -239,7 +239,7 @@ func TestSceneCommandHandlerRunsBeforeStep(t *testing.T) { bot.AddPlugins(plugin) enterCtx := &MsgContext{ - Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: string(tgapi.ChatTypePrivate)}}, + Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}}, FromID: 42, sceneRuntime: bot, } @@ -253,7 +253,7 @@ func TestSceneCommandHandlerRunsBeforeStep(t *testing.T) { Message: &tgapi.Message{ MessageID: 8, Text: "/cancel right now", - Chat: &tgapi.Chat{ID: 100, Type: string(tgapi.ChatTypePrivate)}, + Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}, From: &tgapi.User{ID: 42}, }, }) @@ -294,7 +294,7 @@ func TestScenePassDoesNotPersistSessionData(t *testing.T) { bot.AddPlugins(plugin) enterCtx := &MsgContext{ - Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: string(tgapi.ChatTypePrivate)}}, + Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}}, FromID: 42, sceneRuntime: bot, } @@ -303,7 +303,7 @@ func TestScenePassDoesNotPersistSessionData(t *testing.T) { } key, ok := buildSceneKey(SceneScopeUserChat, &MsgContext{ - Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: string(tgapi.ChatTypePrivate)}}, + Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}}, FromID: 42, }) if !ok { @@ -324,7 +324,7 @@ func TestScenePassDoesNotPersistSessionData(t *testing.T) { Message: &tgapi.Message{ MessageID: 9, Text: "/ping", - Chat: &tgapi.Chat{ID: 100, Type: string(tgapi.ChatTypePrivate)}, + Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}, From: &tgapi.User{ID: 42}, }, }) @@ -371,7 +371,7 @@ func TestSceneMessageFallbackRunsWhenNoCommandOrStepMatch(t *testing.T) { bot.AddPlugins(plugin) enterCtx := &MsgContext{ - Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: string(tgapi.ChatTypePrivate)}}, + Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}}, FromID: 42, sceneRuntime: bot, } @@ -380,7 +380,7 @@ func TestSceneMessageFallbackRunsWhenNoCommandOrStepMatch(t *testing.T) { } key, ok := buildSceneKey(SceneScopeUserChat, &MsgContext{ - Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: string(tgapi.ChatTypePrivate)}}, + Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}}, FromID: 42, }) if !ok { @@ -396,7 +396,7 @@ func TestSceneMessageFallbackRunsWhenNoCommandOrStepMatch(t *testing.T) { Message: &tgapi.Message{ MessageID: 10, Text: "hello fallback", - Chat: &tgapi.Chat{ID: 100, Type: string(tgapi.ChatTypePrivate)}, + Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}, From: &tgapi.User{ID: 42}, }, }) diff --git a/tgapi/chat_types.go b/tgapi/chat_types.go index d8d263e..9dd6273 100644 --- a/tgapi/chat_types.go +++ b/tgapi/chat_types.go @@ -3,14 +3,14 @@ package tgapi // Chat represents a chat (private, group, supergroup, channel). // See https://core.telegram.org/bots/api#chat type Chat struct { - ID int64 `json:"id"` - Type string `json:"type"` - Title *string `json:"title,omitempty"` - Username *string `json:"username,omitempty"` - FirstName *string `json:"first_name,omitempty"` - LastName *string `json:"last_name,omitempty"` - IsForum *bool `json:"is_forum,omitempty"` - IsDirectMessages *bool `json:"is_direct_messages,omitempty"` + ID int64 `json:"id"` + Type ChatType `json:"type"` + Title *string `json:"title,omitempty"` + Username *string `json:"username,omitempty"` + FirstName *string `json:"first_name,omitempty"` + LastName *string `json:"last_name,omitempty"` + IsForum *bool `json:"is_forum,omitempty"` + IsDirectMessages *bool `json:"is_direct_messages,omitempty"` } // ChatType represents the type of a chat. diff --git a/update_context.go b/update_context.go new file mode 100644 index 0000000..99eee95 --- /dev/null +++ b/update_context.go @@ -0,0 +1,179 @@ +package laniakea + +import "git.scuroneko.dev/scuroneko/laniakea/tgapi" + +func (bot *Bot[T]) handleUpdate(u *tgapi.Update, ctx *MsgContext) { + for _, plugin := range bot.plugins { + handler, ok := plugin.handlers[u.Type] + if !ok { + continue + } + + pluginCtx := cloneMsgContext(ctx) + if plugin.logger != nil { + pluginCtx.Logger = plugin.logger + } + if !plugin.executeMiddlewares(pluginCtx, bot.appData) { + continue + } + if err := handler(pluginCtx, bot.appData); err != nil { + pluginCtx.error(err) + } + } +} + +func (bot *Bot[T]) prepareUpdateCtx(u *tgapi.Update, ctx *MsgContext) { + var from *tgapi.User + var chat *tgapi.Chat + switch u.Type { + case tgapi.UpdateTypeMessage: + if u.Message != nil { + ctx.Msg = u.Message + if u.Message.Chat != nil { + chat = u.Message.Chat + } + if u.Message.From != nil { + from = u.Message.From + } + } + case tgapi.UpdateTypeEditedMessage: + if u.EditedMessage != nil { + ctx.Msg = u.EditedMessage + if u.EditedMessage.Chat != nil { + chat = u.EditedMessage.Chat + } + if u.EditedMessage.From != nil { + from = u.EditedMessage.From + } + } + case tgapi.UpdateTypeChannelPost: + if u.ChannelPost != nil { + ctx.Msg = u.ChannelPost + if u.ChannelPost.Chat != nil { + chat = u.ChannelPost.Chat + } + if u.ChannelPost.From != nil { + from = u.ChannelPost.From + } + } + case tgapi.UpdateTypeEditedChannelPost: + if u.EditedChannelPost != nil { + ctx.Msg = u.EditedChannelPost + if u.EditedChannelPost.Chat != nil { + chat = u.EditedChannelPost.Chat + } + if u.EditedChannelPost.From != nil { + from = u.EditedChannelPost.From + } + } + case tgapi.UpdateTypeBusinessMessage: + if u.BusinessMessage != nil { + ctx.Msg = u.BusinessMessage + if u.BusinessMessage.Chat != nil { + chat = u.BusinessMessage.Chat + } + if u.BusinessMessage.From != nil { + from = u.BusinessMessage.From + } + } + case tgapi.UpdateTypeEditedBusinessMessage: + if u.EditedBusinessMessage != nil { + ctx.Msg = u.EditedBusinessMessage + if u.EditedBusinessMessage.Chat != nil { + chat = u.EditedBusinessMessage.Chat + } + if u.EditedBusinessMessage.From != nil { + from = u.EditedBusinessMessage.From + } + } + case tgapi.UpdateTypeInlineQuery: + if u.InlineQuery != nil { + from = &u.InlineQuery.From + } + case tgapi.UpdateTypeChosenInlineResult: + if u.ChosenInlineResult != nil { + from = &u.ChosenInlineResult.From + } + case tgapi.UpdateTypeCallbackQuery: + if u.CallbackQuery != nil { + if u.CallbackQuery.Message != nil { + ctx.Msg = u.CallbackQuery.Message + ctx.CallbackMsgId = u.CallbackQuery.Message.MessageID + if u.CallbackQuery.Message.Chat != nil { + chat = u.CallbackQuery.Message.Chat + } + } + if u.CallbackQuery.InlineMessageID != nil { + ctx.InlineMsgId = *u.CallbackQuery.InlineMessageID + } + ctx.CallbackQueryId = u.CallbackQuery.ID + from = &u.CallbackQuery.From + } + case tgapi.UpdateTypeShippingQuery: + if u.ShippingQuery != nil { + from = &u.ShippingQuery.From + } + case tgapi.UpdateTypePreCheckoutQuery: + if u.PreCheckoutQuery != nil { + from = &u.PreCheckoutQuery.From + } + case tgapi.UpdateTypePurchasedPaidMedia: + if u.PurchasedPaidMedia != nil { + from = &u.PurchasedPaidMedia.From + } + case tgapi.UpdateTypeMyChatMember: + if u.MyChatMember != nil { + from = &u.MyChatMember.From + chat = &u.MyChatMember.Chat + } + case tgapi.UpdateTypeChatMember: + if u.ChatMember != nil { + from = &u.ChatMember.From + chat = &u.ChatMember.Chat + } + case tgapi.UpdateTypeChatJoinRequest: + if u.ChatJoinRequest != nil { + from = &u.ChatJoinRequest.From + chat = &u.ChatJoinRequest.Chat + + } + case tgapi.UpdateTypeBusinessConnection: + if u.BusinessConnection != nil { + from = &u.BusinessConnection.User + } + case tgapi.UpdateTypePollAnswer: + if u.PollAnswer != nil { + from = &u.PollAnswer.User + } + case tgapi.UpdateTypeMessageReaction: + if u.MessageReaction != nil { + from = u.MessageReaction.User + chat = u.MessageReaction.Chat + } + case tgapi.UpdateTypeChatBoost: + if u.ChatBoost != nil { + from = &u.ChatBoost.Boost.Source.User + chat = &u.ChatBoost.Chat + } + case tgapi.UpdateTypeRemovedChatBoost: + if u.RemovedChatBoost != nil { + from = &u.RemovedChatBoost.Source.User + chat = &u.RemovedChatBoost.Chat + } + } + if ctx.Msg != nil && from == nil { + from = ctx.Msg.From + } + if from != nil { + ctx.From = from + ctx.FromID = from.ID + } else { + ctx.FromID = 0 + } + if chat != nil { + ctx.Chat = chat + ctx.ChatID = chat.ID + } else { + ctx.ChatID = 0 + } +}