add authorization policies

Introduce first-class policy helpers and composition through middleware

Normalize chat context for more updates and add regression coverage

Document the completed backlog item in changelog, TODO, and wiki
This commit is contained in:
2026-03-30 18:17:28 +03:00
parent e2444752c2
commit 140f3397b2
17 changed files with 1221 additions and 667 deletions
+7
View File
@@ -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.
+4 -2
View File
@@ -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.
+31 -385
View File
@@ -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()
+203
View File
@@ -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
}
+156
View File
@@ -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
}
+1 -1
View File
@@ -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{}),
+3 -243
View File
@@ -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 {
+40 -8
View File
@@ -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},
},
})
+7
View File
@@ -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
+6 -6
View File
@@ -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")
+117
View File
@@ -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 "", "", ""
}
+6
View File
@@ -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)
+210
View File
@@ -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
}
}
+229
View File
@@ -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)
}
}
+14 -14
View File
@@ -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},
},
})
+8 -8
View File
@@ -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.
+179
View File
@@ -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
}
}