diff --git a/CHANGELOG.md b/CHANGELOG.md index fea96ea..7c260b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## v1.0.0-rc.15 + +### Changed +- Added plugin-level message fallback handlers for text messages and channel posts that do not match commands. + +### Tests +- Added regression coverage for plugin message fallback routing, observer lifecycle events, command precedence, and middleware blocking. + ## v1.0.0-rc.14 ### Bot API 9.6 diff --git a/bot_utils.go b/bot_utils.go index c6fa9b4..097aa19 100644 --- a/bot_utils.go +++ b/bot_utils.go @@ -122,15 +122,16 @@ func shouldWarnOnValueAppData[T any]() bool { func clonePlugin[T AppData](p *Plugin[T]) Plugin[T] { cloned := Plugin[T]{ - name: p.name, - commands: make(map[string]*Command[T], len(p.commands)), - payloads: make(map[string]*Command[T], len(p.payloads)), - scenes: make(map[string]*Scene[T], len(p.scenes)), - middlewares: append(extypes.Slice[Middleware[T]](nil), p.middlewares...), - skipAutoCmd: p.skipAutoCmd, - logger: p.logger, - handlers: make(map[tgapi.UpdateType]CommandExecutor[T]), - onClose: p.onClose, + name: p.name, + commands: make(map[string]*Command[T], len(p.commands)), + payloads: make(map[string]*Command[T], len(p.payloads)), + scenes: make(map[string]*Scene[T], len(p.scenes)), + middlewares: append(extypes.Slice[Middleware[T]](nil), p.middlewares...), + skipAutoCmd: p.skipAutoCmd, + logger: p.logger, + messageFallback: p.messageFallback, + handlers: make(map[tgapi.UpdateType]CommandExecutor[T]), + onClose: p.onClose, } for name, command := range p.commands { diff --git a/handler_test.go b/handler_test.go index a42b6ef..2f1fdcf 100644 --- a/handler_test.go +++ b/handler_test.go @@ -13,13 +13,16 @@ type recordingObserver struct { started []HandlerStartedEvent finished []HandlerFinishedEvent errors []ErrorEvent + handled []UpdateHandledEvent policies []PolicyCheckedEvent runners []RunnerFinishedEvent retries []PollingRetryEvent } func (*recordingObserver) OnReceiveUpdate(context.Context, UpdateReceivedEvent) {} -func (*recordingObserver) OnHandledUpdate(context.Context, UpdateHandledEvent) {} +func (o *recordingObserver) OnHandledUpdate(_ context.Context, ev UpdateHandledEvent) { + o.handled = append(o.handled, ev) +} func (o *recordingObserver) OnHandlerStarted(_ context.Context, ev HandlerStartedEvent) { o.started = append(o.started, ev) } @@ -584,6 +587,169 @@ func TestHandleUpdateObserverEmitsUpdateErrors(t *testing.T) { } } +func TestHandleMessageFallbackRunsAfterCommandMiss(t *testing.T) { + observer := &recordingObserver{} + called := false + plugin := NewPlugin[NoData]("test") + plugin.SetMessageFallback(func(ctx *MsgContext, db NoData) error { + called = true + if ctx.Text != "/missing hello world" { + t.Fatalf("unexpected fallback text: got %q", ctx.Text) + } + if ctx.Prefix != "/" { + t.Fatalf("unexpected fallback prefix: got %q", ctx.Prefix) + } + wantArgs := []string{"/missing", "hello", "world"} + if len(ctx.Args) != len(wantArgs) || ctx.Args[0] != wantArgs[0] || ctx.Args[1] != wantArgs[1] || ctx.Args[2] != wantArgs[2] { + t.Fatalf("unexpected fallback args: got %v want %v", ctx.Args, wantArgs) + } + return nil + }) + + bot := &Bot[NoData]{ + logger: slog.CreateLogger(), + prefixes: []string{"/"}, + observer: observer, + } + bot.AddPlugins(plugin) + + bot.handle(context.Background(), &tgapi.Update{ + UpdateID: 5, + Type: tgapi.UpdateTypeMessage, + Message: &tgapi.Message{ + MessageID: 1, + Text: "/missing hello world", + From: &tgapi.User{ID: 41}, + Chat: &tgapi.Chat{ID: 99}, + }, + }) + + if !called { + t.Fatal("expected message fallback to be called") + } + if len(observer.started) != 1 { + t.Fatalf("expected one started event, got %d", len(observer.started)) + } + if got := observer.started[0]; got.HandlerKind != HandlerMessageKind || got.HandlerName != "message_fallback" || got.Plugin != "test" { + t.Fatalf("unexpected started event: %#v", got) + } + if len(observer.finished) != 1 { + t.Fatalf("expected one finished event, got %d", len(observer.finished)) + } + if got := observer.finished[0]; got.HandlerKind != HandlerMessageKind || got.HandlerName != "message_fallback" || got.Plugin != "test" || got.Err != nil { + t.Fatalf("unexpected finished event: %#v", got) + } + if len(observer.handled) != 1 || !observer.handled[0].Handled { + t.Fatalf("expected handled update event, got %#v", observer.handled) + } +} + +func TestHandleMessageFallbackRunsForPlainText(t *testing.T) { + called := false + plugin := NewPlugin[NoData]("test").SetMessageFallback(func(ctx *MsgContext, db NoData) error { + called = true + if ctx.Text != "hello fallback" { + t.Fatalf("unexpected fallback text: got %q", ctx.Text) + } + if ctx.Prefix != "" { + t.Fatalf("unexpected fallback prefix: got %q", ctx.Prefix) + } + return nil + }) + + bot := &Bot[NoData]{ + logger: slog.CreateLogger(), + prefixes: []string{"/"}, + } + bot.AddPlugins(plugin) + + bot.handle(context.Background(), &tgapi.Update{ + UpdateID: 6, + Type: tgapi.UpdateTypeMessage, + Message: &tgapi.Message{ + MessageID: 1, + Text: "hello fallback", + From: &tgapi.User{ID: 41}, + Chat: &tgapi.Chat{ID: 99}, + }, + }) + + if !called { + t.Fatal("expected message fallback to be called") + } +} + +func TestHandleMessageFallbackRespectsMiddleware(t *testing.T) { + called := false + plugin := NewPlugin[NoData]("test") + plugin.AddMiddleware(NewMiddleware("block", func(ctx *MsgContext, db NoData) bool { + return false + })) + plugin.SetMessageFallback(func(ctx *MsgContext, db NoData) error { + called = true + return nil + }) + + bot := &Bot[NoData]{ + logger: slog.CreateLogger(), + prefixes: []string{"/"}, + } + bot.AddPlugins(plugin) + + bot.handle(context.Background(), &tgapi.Update{ + UpdateID: 7, + Type: tgapi.UpdateTypeMessage, + Message: &tgapi.Message{ + MessageID: 1, + Text: "blocked", + From: &tgapi.User{ID: 41}, + Chat: &tgapi.Chat{ID: 99}, + }, + }) + + if called { + t.Fatal("message fallback must not run when plugin middleware blocks") + } +} + +func TestHandleMessageFallbackDoesNotRunWhenCommandMatches(t *testing.T) { + commandCalled := false + fallbackCalled := false + plugin := NewPlugin[NoData]("test") + plugin.NewCommand(func(ctx *MsgContext, db NoData) error { + commandCalled = true + return nil + }, "start") + plugin.SetMessageFallback(func(ctx *MsgContext, db NoData) error { + fallbackCalled = true + return nil + }) + + bot := &Bot[NoData]{ + logger: slog.CreateLogger(), + prefixes: []string{"/"}, + } + bot.AddPlugins(plugin) + + bot.handle(context.Background(), &tgapi.Update{ + UpdateID: 8, + Type: tgapi.UpdateTypeMessage, + Message: &tgapi.Message{ + MessageID: 1, + Text: "/start", + From: &tgapi.User{ID: 41}, + Chat: &tgapi.Chat{ID: 99}, + }, + }) + + if !commandCalled { + t.Fatal("expected command handler to be called") + } + if fallbackCalled { + t.Fatal("message fallback must not run when command matches") + } +} + func TestHandleChannelPostCommandWithSenderChat(t *testing.T) { called := false plugin := NewPlugin[NoData]("test") diff --git a/msg_handler.go b/msg_handler.go index 8d355d2..5053ddd 100644 --- a/msg_handler.go +++ b/msg_handler.go @@ -8,27 +8,14 @@ import ( ) func (bot *Bot[T]) handleMessage(update *tgapi.Update, ctx *MsgContext) bool { - var msg *tgapi.Message - if update.Message != nil { - msg = update.Message - } else if update.ChannelPost != nil { - msg = update.ChannelPost - } else { - return false - } - - var text string - if len(msg.Text) > 0 { - text = msg.Text - } else if len(msg.Caption) > 0 { - text = msg.Caption - } else { + text, ok := messageText(update) + if !ok { return false } prefix, cmd, args := bot.parseCommand(text) if cmd == "" { - return false + return bot.handleFallback(update, ctx) } ctx.Prefix = prefix @@ -99,7 +86,98 @@ func (bot *Bot[T]) handleMessage(update *tgapi.Update, ctx *MsgContext) bool { return true } } - return false + + return bot.handleFallback(update, ctx) +} + +func (bot *Bot[T]) handleFallback(update *tgapi.Update, ctx *MsgContext) bool { + text, ok := messageText(update) + if !ok { + return false + } + + prefix, _, _ := bot.parseCommand(text) + handled := false + for _, plugin := range bot.plugins { + if plugin.messageFallback == nil { + continue + } + + pluginCtx := cloneMsgContext(ctx) + pluginCtx.Prefix = prefix + pluginCtx.Text = text + pluginCtx.Args = strings.Fields(text) + if plugin.logger != nil { + pluginCtx.Logger = plugin.logger + } + if !plugin.executeMiddlewares(pluginCtx, bot.appData) { + continue + } + + startTime := time.Now() + bot.safeEmitEvent(pluginCtx.Context(), HandlerStartedEvent{ + UpdateID: update.UpdateID, + UpdateType: update.Type, + Plugin: plugin.name, + HandlerKind: HandlerMessageKind, + HandlerName: "message_fallback", + FromID: pluginCtx.FromID, + ChatID: pluginCtx.ChatID, + }) + err := plugin.messageFallback(pluginCtx, bot.appData) + endEvent := HandlerFinishedEvent{ + UpdateID: update.UpdateID, + UpdateType: update.Type, + Plugin: plugin.name, + HandlerKind: HandlerMessageKind, + HandlerName: "message_fallback", + FromID: pluginCtx.FromID, + ChatID: pluginCtx.ChatID, + Duration: time.Since(startTime), + } + if err != nil { + endEvent.Err = err + endEvent.UserFacing = IsUserError(err) + } + bot.safeEmitEvent(pluginCtx.Context(), endEvent) + if err != nil { + pluginCtx.error(err) + bot.safeEmitEvent(pluginCtx.Context(), ErrorEvent{ + UpdateID: update.UpdateID, + UpdateType: update.Type, + Plugin: plugin.name, + HandlerKind: HandlerMessageKind, + HandlerName: "message_fallback", + FromID: pluginCtx.FromID, + ChatID: pluginCtx.ChatID, + Err: err, + UserFacing: IsUserError(err), + }) + } + handled = true + } + return handled +} + +func messageText(update *tgapi.Update) (string, bool) { + var msg *tgapi.Message + if update.Message != nil { + msg = update.Message + } else if update.ChannelPost != nil { + msg = update.ChannelPost + } else { + return "", false + } + + var text string + if len(msg.Text) > 0 { + text = msg.Text + } else if len(msg.Caption) > 0 { + text = msg.Caption + } else { + return "", false + } + return text, true } func (bot *Bot[T]) handleCallback(update *tgapi.Update, ctx *MsgContext) bool { diff --git a/observer.go b/observer.go index d5df215..d454acc 100644 --- a/observer.go +++ b/observer.go @@ -14,6 +14,8 @@ type HandlerEventKind string const ( // HandlerCommandKind identifies a command handler. HandlerCommandKind HandlerEventKind = "command" + // HandlerMessageKind identifies a message fallback handler. + HandlerMessageKind HandlerEventKind = "message" // HandlerPayloadKind identifies a callback payload handler. HandlerPayloadKind HandlerEventKind = "payload" // HandlerUpdateKind identifies a generic update handler. diff --git a/plugins.go b/plugins.go index e61a223..014322b 100644 --- a/plugins.go +++ b/plugins.go @@ -171,7 +171,8 @@ type Plugin[T AppData] struct { skipAutoCmd bool // If true, all commands in this plugin are excluded from auto-help logger *slog.Logger - handlers map[tgapi.UpdateType]CommandExecutor[T] + messageFallback CommandExecutor[T] + handlers map[tgapi.UpdateType]CommandExecutor[T] onClose func() error } @@ -243,12 +244,6 @@ func (p *Plugin[T]) AddScene(scene *Scene[T]) *Plugin[T] { return p } -// UsePolicy registers a Policy as plugin middleware for all plugin handlers. -func (p *Plugin[T]) UsePolicy(name string, policy Policy[T]) *Plugin[T] { - mw := RequirePolicy(name, policy) - return p.AddMiddleware(mw) -} - // NewScene creates, registers, and returns a new scene owned by the plugin. func (p *Plugin[T]) NewScene(name string) *Scene[T] { scene := NewScene[T](name) @@ -257,6 +252,12 @@ func (p *Plugin[T]) NewScene(name string) *Scene[T] { return scene } +// 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) +} + // AddUpdateHandler registers a handler for a non-command update type. // Message, channel post, and callback query updates stay on the command/payload flow. func (p *Plugin[T]) AddUpdateHandler(t tgapi.UpdateType, handler CommandExecutor[T]) *Plugin[T] { @@ -316,6 +317,13 @@ func (p *Plugin[T]) SetOnClose(f func() error) *Plugin[T] { return p } +// SetMessageFallback registers a fallback handler for messages that do not +// match a command. +func (p *Plugin[T]) SetMessageFallback(handler CommandExecutor[T]) *Plugin[T] { + p.messageFallback = handler + return p +} + // Close releases plugin-owned resources such as its logger and optional // OnClose callback. func (p *Plugin[T]) Close() error { diff --git a/utils/version.go b/utils/version.go index 22bdb51..5209b17 100644 --- a/utils/version.go +++ b/utils/version.go @@ -2,7 +2,7 @@ package utils const ( // VersionString is the module version string. - VersionString = "1.0.0-rc.14" + VersionString = "1.0.0-rc.15" // VersionMajor is the module major version. VersionMajor = 1 // VersionMinor is the module minor version. @@ -10,5 +10,5 @@ const ( // VersionPatch is the module patch version. VersionPatch = 0 // VersionBeta is the prerelease counter for the current version. - VersionBeta = 14 + VersionBeta = 15 )