REPOSITORY / ScuroNeko/Laniakea
Pull Requests
v1.0.0 #9
@@ -4,16 +4,23 @@
|
|||||||
|
|
||||||
### Added
|
### 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.
|
- `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
|
### 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.
|
- 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` 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.
|
- `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(...)`.
|
- 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
|
### 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 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 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 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 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.
|
- 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.
|
||||||
|
|||||||
@@ -12,11 +12,13 @@ Russian page:
|
|||||||
|
|
||||||
Current priority split:
|
Current priority split:
|
||||||
|
|
||||||
- `Priority 1`: webhook runtime model, authorization and policy model, observability model.
|
- `Priority 1`: observability model.
|
||||||
- `Priority 2`: service layer and dependency graph model, plugin composition contract.
|
- `Priority 2`: service layer and dependency graph model.
|
||||||
|
- `Partial`: webhook runtime model, plugin composition contract.
|
||||||
|
|
||||||
Completed former high-priority items:
|
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] 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] 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.
|
- `[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.
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"maps"
|
"maps"
|
||||||
"reflect"
|
"reflect"
|
||||||
"slices"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -282,38 +280,6 @@ func (bot *Bot[T]) CloseRemote(ctx context.Context) error {
|
|||||||
return nil
|
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).
|
// GetUpdateOffset returns the current update offset (thread-safe).
|
||||||
func (bot *Bot[T]) GetUpdateOffset() int {
|
func (bot *Bot[T]) GetUpdateOffset() int {
|
||||||
bot.updateOffsetMu.Lock()
|
bot.updateOffsetMu.Lock()
|
||||||
@@ -328,18 +294,9 @@ func (bot *Bot[T]) SetUpdateOffset(offset int) {
|
|||||||
bot.updateOffset = offset
|
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.
|
// GetLogger returns the main bot logger.
|
||||||
func (bot *Bot[T]) GetLogger() *slog.Logger { return 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
|
// GetLoggerLevel returns the effective log level derived from the bot's debug
|
||||||
// flag.
|
// flag.
|
||||||
func (bot *Bot[T]) GetLoggerLevel() slog.LogLevel {
|
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)
|
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.
|
// RunWithContext starts the bot with a given context for graceful shutdown.
|
||||||
//
|
//
|
||||||
// This is the main entry point for bot execution. It:
|
// 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.
|
// RunWithContext does not close API, uploader, or logger resources on return.
|
||||||
// The caller must invoke Close after RunWithContext finishes.
|
// 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.
|
// A Bot is single-use. After RunWithContext returns, later calls return ErrBotAlreadyRun.
|
||||||
func (bot *Bot[T]) RunWithContext(ctx context.Context) error {
|
func (bot *Bot[T]) RunWithContext(ctx context.Context) error {
|
||||||
if len(bot.prefixes) == 0 {
|
if len(bot.prefixes) == 0 {
|
||||||
@@ -802,6 +417,37 @@ func (bot *Bot[T]) Run() error {
|
|||||||
return bot.RunWithContext(context.Background())
|
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 {
|
func (bot *Bot[T]) beginRun() error {
|
||||||
bot.runStateMu.Lock()
|
bot.runStateMu.Lock()
|
||||||
defer bot.runStateMu.Unlock()
|
defer bot.runStateMu.Unlock()
|
||||||
|
|||||||
+203
@@ -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
@@ -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
@@ -22,7 +22,7 @@ func TestMsgContextNewDraftWorksWithoutLimiter(t *testing.T) {
|
|||||||
ctx := &MsgContext{
|
ctx := &MsgContext{
|
||||||
Api: &tgapi.API{},
|
Api: &tgapi.API{},
|
||||||
Msg: &tgapi.Message{
|
Msg: &tgapi.Message{
|
||||||
Chat: &tgapi.Chat{ID: 42, Type: string(tgapi.ChatTypePrivate)},
|
Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate},
|
||||||
},
|
},
|
||||||
Logger: slog.CreateLogger(),
|
Logger: slog.CreateLogger(),
|
||||||
draftProvider: NewRandomDraftProvider(&tgapi.API{}),
|
draftProvider: NewRandomDraftProvider(&tgapi.API{}),
|
||||||
|
|||||||
+3
-243
@@ -6,7 +6,6 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
"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 {
|
func cloneMsgContext(src *MsgContext) *MsgContext {
|
||||||
cloned := *src
|
cloned := *src
|
||||||
if src.Args != nil {
|
if src.Args != nil {
|
||||||
@@ -166,139 +68,6 @@ func cloneMsgContext(src *MsgContext) *MsgContext {
|
|||||||
return &cloned
|
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) {
|
func encodeJsonPayload(d CallbackData) (string, error) {
|
||||||
b, err := json.Marshal(d)
|
b, err := json.Marshal(d)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -306,11 +75,13 @@ func encodeJsonPayload(d CallbackData) (string, error) {
|
|||||||
}
|
}
|
||||||
return string(b), nil
|
return string(b), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func decodeJsonPayload(s string) (CallbackData, error) {
|
func decodeJsonPayload(s string) (CallbackData, error) {
|
||||||
var data CallbackData
|
var data CallbackData
|
||||||
err := json.Unmarshal([]byte(s), &data)
|
err := json.Unmarshal([]byte(s), &data)
|
||||||
return data, err
|
return data, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func encodeBase64Payload(d CallbackData) (string, error) {
|
func encodeBase64Payload(d CallbackData) (string, error) {
|
||||||
data, err := encodeJsonPayload(d)
|
data, err := encodeJsonPayload(d)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -321,15 +92,6 @@ func encodeBase64Payload(d CallbackData) (string, error) {
|
|||||||
return string(dst), nil
|
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) {
|
func decodeBase64Payload(s string) (CallbackData, error) {
|
||||||
b, err := base64.RawURLEncoding.DecodeString(s)
|
b, err := base64.RawURLEncoding.DecodeString(s)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -337,6 +99,7 @@ func decodeBase64Payload(s string) (CallbackData, error) {
|
|||||||
}
|
}
|
||||||
return decodeJsonPayload(string(b))
|
return decodeJsonPayload(string(b))
|
||||||
}
|
}
|
||||||
|
|
||||||
func decodePayload(payloadType BotPayloadType, s string, strict bool) (CallbackData, BotPayloadType, error) {
|
func decodePayload(payloadType BotPayloadType, s string, strict bool) (CallbackData, BotPayloadType, error) {
|
||||||
switch payloadType {
|
switch payloadType {
|
||||||
case BotPayloadBase64:
|
case BotPayloadBase64:
|
||||||
@@ -369,9 +132,6 @@ func decodePayload(payloadType BotPayloadType, s string, strict bool) (CallbackD
|
|||||||
return CallbackData{}, "", ErrInvalidPayloadType
|
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) {
|
func (bot *Bot[T]) decodePayload(s string) (CallbackData, error) {
|
||||||
data, decodedType, err := decodePayload(bot.payloadType, s, bot.strictPayloadType)
|
data, decodedType, err := decodePayload(bot.payloadType, s, bot.strictPayloadType)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+39
-7
@@ -85,6 +85,8 @@ func TestPrepareUpdateCtxContract(t *testing.T) {
|
|||||||
wantMsg bool
|
wantMsg bool
|
||||||
wantFrom bool
|
wantFrom bool
|
||||||
wantFromID int64
|
wantFromID int64
|
||||||
|
wantChat bool
|
||||||
|
wantChatID int64
|
||||||
wantCallbackID string
|
wantCallbackID string
|
||||||
wantCallbackMsgID int
|
wantCallbackMsgID int
|
||||||
wantInlineMsgID string
|
wantInlineMsgID string
|
||||||
@@ -102,6 +104,8 @@ func TestPrepareUpdateCtxContract(t *testing.T) {
|
|||||||
wantMsg: true,
|
wantMsg: true,
|
||||||
wantFrom: true,
|
wantFrom: true,
|
||||||
wantFromID: 101,
|
wantFromID: 101,
|
||||||
|
wantChat: true,
|
||||||
|
wantChatID: 1001,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "edited message",
|
name: "edited message",
|
||||||
@@ -116,6 +120,8 @@ func TestPrepareUpdateCtxContract(t *testing.T) {
|
|||||||
wantMsg: true,
|
wantMsg: true,
|
||||||
wantFrom: true,
|
wantFrom: true,
|
||||||
wantFromID: 102,
|
wantFromID: 102,
|
||||||
|
wantChat: true,
|
||||||
|
wantChatID: 1002,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "channel post sender chat",
|
name: "channel post sender chat",
|
||||||
@@ -127,6 +133,8 @@ func TestPrepareUpdateCtxContract(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
wantMsg: true,
|
wantMsg: true,
|
||||||
|
wantChat: true,
|
||||||
|
wantChatID: -1003,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "business message",
|
name: "business message",
|
||||||
@@ -141,6 +149,8 @@ func TestPrepareUpdateCtxContract(t *testing.T) {
|
|||||||
wantMsg: true,
|
wantMsg: true,
|
||||||
wantFrom: true,
|
wantFrom: true,
|
||||||
wantFromID: 103,
|
wantFromID: 103,
|
||||||
|
wantChat: true,
|
||||||
|
wantChatID: 1004,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "inline query",
|
name: "inline query",
|
||||||
@@ -176,6 +186,8 @@ func TestPrepareUpdateCtxContract(t *testing.T) {
|
|||||||
wantMsg: true,
|
wantMsg: true,
|
||||||
wantFrom: true,
|
wantFrom: true,
|
||||||
wantFromID: 106,
|
wantFromID: 106,
|
||||||
|
wantChat: true,
|
||||||
|
wantChatID: 1005,
|
||||||
wantCallbackID: "cb-1",
|
wantCallbackID: "cb-1",
|
||||||
wantCallbackMsgID: 77,
|
wantCallbackMsgID: 77,
|
||||||
},
|
},
|
||||||
@@ -225,28 +237,34 @@ func TestPrepareUpdateCtxContract(t *testing.T) {
|
|||||||
name: "my chat member",
|
name: "my chat member",
|
||||||
update: &tgapi.Update{
|
update: &tgapi.Update{
|
||||||
Type: tgapi.UpdateTypeMyChatMember,
|
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,
|
wantFrom: true,
|
||||||
wantFromID: 111,
|
wantFromID: 111,
|
||||||
|
wantChat: true,
|
||||||
|
wantChatID: -2001,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "chat member",
|
name: "chat member",
|
||||||
update: &tgapi.Update{
|
update: &tgapi.Update{
|
||||||
Type: tgapi.UpdateTypeChatMember,
|
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,
|
wantFrom: true,
|
||||||
wantFromID: 112,
|
wantFromID: 112,
|
||||||
|
wantChat: true,
|
||||||
|
wantChatID: -2002,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "chat join request",
|
name: "chat join request",
|
||||||
update: &tgapi.Update{
|
update: &tgapi.Update{
|
||||||
Type: tgapi.UpdateTypeChatJoinRequest,
|
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,
|
wantFrom: true,
|
||||||
wantFromID: 113,
|
wantFromID: 113,
|
||||||
|
wantChat: true,
|
||||||
|
wantChatID: -2003,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "business connection",
|
name: "business connection",
|
||||||
@@ -270,32 +288,40 @@ func TestPrepareUpdateCtxContract(t *testing.T) {
|
|||||||
name: "message reaction",
|
name: "message reaction",
|
||||||
update: &tgapi.Update{
|
update: &tgapi.Update{
|
||||||
Type: tgapi.UpdateTypeMessageReaction,
|
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,
|
wantFrom: true,
|
||||||
wantFromID: 116,
|
wantFromID: 116,
|
||||||
|
wantChat: true,
|
||||||
|
wantChatID: -2004,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "chat boost",
|
name: "chat boost",
|
||||||
update: &tgapi.Update{
|
update: &tgapi.Update{
|
||||||
Type: tgapi.UpdateTypeChatBoost,
|
Type: tgapi.UpdateTypeChatBoost,
|
||||||
ChatBoost: &tgapi.ChatBoostUpdated{
|
ChatBoost: &tgapi.ChatBoostUpdated{
|
||||||
|
Chat: tgapi.Chat{ID: -2005},
|
||||||
Boost: tgapi.ChatBoost{Source: tgapi.ChatBoostSource{User: tgapi.User{ID: 117}}},
|
Boost: tgapi.ChatBoost{Source: tgapi.ChatBoostSource{User: tgapi.User{ID: 117}}},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
wantFrom: true,
|
wantFrom: true,
|
||||||
wantFromID: 117,
|
wantFromID: 117,
|
||||||
|
wantChat: true,
|
||||||
|
wantChatID: -2005,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "removed chat boost",
|
name: "removed chat boost",
|
||||||
update: &tgapi.Update{
|
update: &tgapi.Update{
|
||||||
Type: tgapi.UpdateTypeRemovedChatBoost,
|
Type: tgapi.UpdateTypeRemovedChatBoost,
|
||||||
RemovedChatBoost: &tgapi.ChatBoostRemoved{
|
RemovedChatBoost: &tgapi.ChatBoostRemoved{
|
||||||
|
Chat: tgapi.Chat{ID: -2006},
|
||||||
Source: tgapi.ChatBoostSource{User: tgapi.User{ID: 118}},
|
Source: tgapi.ChatBoostSource{User: tgapi.User{ID: 118}},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
wantFrom: true,
|
wantFrom: true,
|
||||||
wantFromID: 118,
|
wantFromID: 118,
|
||||||
|
wantChat: true,
|
||||||
|
wantChatID: -2006,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "poll",
|
name: "poll",
|
||||||
@@ -328,6 +354,12 @@ func TestPrepareUpdateCtxContract(t *testing.T) {
|
|||||||
if ctx.FromID != tt.wantFromID {
|
if ctx.FromID != tt.wantFromID {
|
||||||
t.Fatalf("unexpected FromID: got %d want %d", 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 {
|
if ctx.CallbackQueryId != tt.wantCallbackID {
|
||||||
t.Fatalf("unexpected CallbackQueryId: got %q want %q", 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{
|
ChannelPost: &tgapi.Message{
|
||||||
MessageID: 55,
|
MessageID: 55,
|
||||||
Text: "/ping",
|
Text: "/ping",
|
||||||
SenderChat: &tgapi.Chat{ID: -1001, Type: string(tgapi.ChatTypeChannel)},
|
SenderChat: &tgapi.Chat{ID: -1001, Type: tgapi.ChatTypeChannel},
|
||||||
Chat: &tgapi.Chat{ID: -1001, Type: string(tgapi.ChatTypeChannel)},
|
Chat: &tgapi.Chat{ID: -1001, Type: tgapi.ChatTypeChannel},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -542,7 +574,7 @@ func TestCommandHandlerBindArgsEndToEnd(t *testing.T) {
|
|||||||
Message: &tgapi.Message{
|
Message: &tgapi.Message{
|
||||||
MessageID: 1,
|
MessageID: 1,
|
||||||
Text: "/ban 42 too loud",
|
Text: "/ban 42 too loud",
|
||||||
Chat: &tgapi.Chat{ID: 99, Type: string(tgapi.ChatTypePrivate)},
|
Chat: &tgapi.Chat{ID: 99, Type: tgapi.ChatTypePrivate},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import (
|
|||||||
// - Update is always present.
|
// - Update is always present.
|
||||||
// - Msg is populated only for update kinds that carry a Telegram message object.
|
// - 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.
|
// - 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.
|
// - Text, Args, and Prefix are populated only by command or scene command routing.
|
||||||
// - CallbackQueryId, CallbackMsgId, and InlineMsgId are populated only for
|
// - CallbackQueryId, CallbackMsgId, and InlineMsgId are populated only for
|
||||||
// callback query handling when the corresponding callback targets exist.
|
// 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.
|
// 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.
|
// It stays nil for sender-chat-only updates and update kinds without a user.
|
||||||
From *tgapi.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.
|
// 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.
|
// 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.
|
// FromID is the normalized sender ID when the current update exposes a user.
|
||||||
// It is zero when the update has no user identity.
|
// It is zero when the update has no user identity.
|
||||||
FromID int64
|
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
|
// Prefix is the matched command prefix for command routing and scene-local
|
||||||
// command routing. It is empty outside those flows.
|
// command routing. It is empty outside those flows.
|
||||||
Prefix string
|
Prefix string
|
||||||
|
|||||||
+6
-6
@@ -47,7 +47,7 @@ func TestAnswerPhotoIncludesDirectMessagesTopicID(t *testing.T) {
|
|||||||
ctx := &MsgContext{
|
ctx := &MsgContext{
|
||||||
Api: api,
|
Api: api,
|
||||||
Msg: &tgapi.Message{
|
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},
|
DirectMessageTopic: &tgapi.DirectMessageTopic{TopicID: 77},
|
||||||
},
|
},
|
||||||
Logger: slog.CreateLogger(),
|
Logger: slog.CreateLogger(),
|
||||||
@@ -201,7 +201,7 @@ func TestErrorDefaultRemainsUserVisibleForMessageFlow(t *testing.T) {
|
|||||||
|
|
||||||
ctx := &MsgContext{
|
ctx := &MsgContext{
|
||||||
Api: api,
|
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(),
|
Logger: slog.CreateLogger(),
|
||||||
errorTemplate: "Error: %s",
|
errorTemplate: "Error: %s",
|
||||||
}
|
}
|
||||||
@@ -237,7 +237,7 @@ func TestErrorInternalSkipsUserReplyForMessageFlow(t *testing.T) {
|
|||||||
|
|
||||||
ctx := &MsgContext{
|
ctx := &MsgContext{
|
||||||
Api: api,
|
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(),
|
Logger: slog.CreateLogger(),
|
||||||
errorTemplate: "Error: %s",
|
errorTemplate: "Error: %s",
|
||||||
}
|
}
|
||||||
@@ -326,7 +326,7 @@ func TestErrorUserVisibleAnswersCallback(t *testing.T) {
|
|||||||
|
|
||||||
func TestAnswerRejectsEmptyMessage(t *testing.T) {
|
func TestAnswerRejectsEmptyMessage(t *testing.T) {
|
||||||
ctx := &MsgContext{
|
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(),
|
Logger: slog.CreateLogger(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,7 +356,7 @@ func TestAnswerRejectsLongMessageWithoutSendingRequest(t *testing.T) {
|
|||||||
|
|
||||||
ctx := &MsgContext{
|
ctx := &MsgContext{
|
||||||
Api: api,
|
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(),
|
Logger: slog.CreateLogger(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -440,7 +440,7 @@ func TestAnswerLongSplitsRequestsAndAttachesKeyboardToLastChunk(t *testing.T) {
|
|||||||
|
|
||||||
ctx := &MsgContext{
|
ctx := &MsgContext{
|
||||||
Api: api,
|
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(),
|
Logger: slog.CreateLogger(),
|
||||||
}
|
}
|
||||||
kb := NewInlineKeyboardJson(1).AddCallbackButton("A", "cmd")
|
kb := NewInlineKeyboardJson(1).AddCallbackButton("A", "cmd")
|
||||||
|
|||||||
+117
@@ -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 "", "", ""
|
||||||
|
}
|
||||||
@@ -231,6 +231,12 @@ func (p *Plugin[T]) AddScene(scene *Scene[T]) *Plugin[T] {
|
|||||||
return p
|
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.
|
// NewScene creates, registers, and returns a new scene owned by the plugin.
|
||||||
func (p *Plugin[T]) NewScene(name string) *Scene[T] {
|
func (p *Plugin[T]) NewScene(name string) *Scene[T] {
|
||||||
scene := NewScene[T](name)
|
scene := NewScene[T](name)
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -72,7 +72,7 @@ func TestBotAddPluginsPreservesScenesAndHandlesThem(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enterCtx := &MsgContext{
|
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,
|
FromID: 42,
|
||||||
sceneRuntime: bot,
|
sceneRuntime: bot,
|
||||||
}
|
}
|
||||||
@@ -86,7 +86,7 @@ func TestBotAddPluginsPreservesScenesAndHandlesThem(t *testing.T) {
|
|||||||
Message: &tgapi.Message{
|
Message: &tgapi.Message{
|
||||||
MessageID: 7,
|
MessageID: 7,
|
||||||
Text: "hello there",
|
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},
|
From: &tgapi.User{ID: 42},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -96,7 +96,7 @@ func TestBotAddPluginsPreservesScenesAndHandlesThem(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
lookupCtx := &MsgContext{
|
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,
|
FromID: 42,
|
||||||
}
|
}
|
||||||
if _, session, err := bot.findSceneSession(lookupCtx); err == nil && session.Scene != "" {
|
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",
|
name: "missing from id for user chat scope",
|
||||||
scope: SceneScopeUserChat,
|
scope: SceneScopeUserChat,
|
||||||
ctx: &MsgContext{
|
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)
|
bot.AddPlugins(plugin)
|
||||||
|
|
||||||
ctx := &MsgContext{
|
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,
|
FromID: 42,
|
||||||
sceneRuntime: bot,
|
sceneRuntime: bot,
|
||||||
}
|
}
|
||||||
@@ -179,7 +179,7 @@ func TestEnterSceneRejectsMissingEntryConfiguration(t *testing.T) {
|
|||||||
bot.AddPlugins(plugin)
|
bot.AddPlugins(plugin)
|
||||||
|
|
||||||
ctx := &MsgContext{
|
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,
|
FromID: 42,
|
||||||
sceneRuntime: bot,
|
sceneRuntime: bot,
|
||||||
}
|
}
|
||||||
@@ -239,7 +239,7 @@ func TestSceneCommandHandlerRunsBeforeStep(t *testing.T) {
|
|||||||
bot.AddPlugins(plugin)
|
bot.AddPlugins(plugin)
|
||||||
|
|
||||||
enterCtx := &MsgContext{
|
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,
|
FromID: 42,
|
||||||
sceneRuntime: bot,
|
sceneRuntime: bot,
|
||||||
}
|
}
|
||||||
@@ -253,7 +253,7 @@ func TestSceneCommandHandlerRunsBeforeStep(t *testing.T) {
|
|||||||
Message: &tgapi.Message{
|
Message: &tgapi.Message{
|
||||||
MessageID: 8,
|
MessageID: 8,
|
||||||
Text: "/cancel right now",
|
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},
|
From: &tgapi.User{ID: 42},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -294,7 +294,7 @@ func TestScenePassDoesNotPersistSessionData(t *testing.T) {
|
|||||||
bot.AddPlugins(plugin)
|
bot.AddPlugins(plugin)
|
||||||
|
|
||||||
enterCtx := &MsgContext{
|
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,
|
FromID: 42,
|
||||||
sceneRuntime: bot,
|
sceneRuntime: bot,
|
||||||
}
|
}
|
||||||
@@ -303,7 +303,7 @@ func TestScenePassDoesNotPersistSessionData(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
key, ok := buildSceneKey(SceneScopeUserChat, &MsgContext{
|
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,
|
FromID: 42,
|
||||||
})
|
})
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -324,7 +324,7 @@ func TestScenePassDoesNotPersistSessionData(t *testing.T) {
|
|||||||
Message: &tgapi.Message{
|
Message: &tgapi.Message{
|
||||||
MessageID: 9,
|
MessageID: 9,
|
||||||
Text: "/ping",
|
Text: "/ping",
|
||||||
Chat: &tgapi.Chat{ID: 100, Type: string(tgapi.ChatTypePrivate)},
|
Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate},
|
||||||
From: &tgapi.User{ID: 42},
|
From: &tgapi.User{ID: 42},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -371,7 +371,7 @@ func TestSceneMessageFallbackRunsWhenNoCommandOrStepMatch(t *testing.T) {
|
|||||||
bot.AddPlugins(plugin)
|
bot.AddPlugins(plugin)
|
||||||
|
|
||||||
enterCtx := &MsgContext{
|
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,
|
FromID: 42,
|
||||||
sceneRuntime: bot,
|
sceneRuntime: bot,
|
||||||
}
|
}
|
||||||
@@ -380,7 +380,7 @@ func TestSceneMessageFallbackRunsWhenNoCommandOrStepMatch(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
key, ok := buildSceneKey(SceneScopeUserChat, &MsgContext{
|
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,
|
FromID: 42,
|
||||||
})
|
})
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -396,7 +396,7 @@ func TestSceneMessageFallbackRunsWhenNoCommandOrStepMatch(t *testing.T) {
|
|||||||
Message: &tgapi.Message{
|
Message: &tgapi.Message{
|
||||||
MessageID: 10,
|
MessageID: 10,
|
||||||
Text: "hello fallback",
|
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},
|
From: &tgapi.User{ID: 42},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ package tgapi
|
|||||||
// See https://core.telegram.org/bots/api#chat
|
// See https://core.telegram.org/bots/api#chat
|
||||||
type Chat struct {
|
type Chat struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Type string `json:"type"`
|
Type ChatType `json:"type"`
|
||||||
Title *string `json:"title,omitempty"`
|
Title *string `json:"title,omitempty"`
|
||||||
Username *string `json:"username,omitempty"`
|
Username *string `json:"username,omitempty"`
|
||||||
FirstName *string `json:"first_name,omitempty"`
|
FirstName *string `json:"first_name,omitempty"`
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user