Add plugin message fallback
Route unmatched messages through plugin fallback handlers Add observer coverage and bump version to rc.15
This commit is contained in:
@@ -1,5 +1,13 @@
|
|||||||
# Changelog
|
# 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
|
## v1.0.0-rc.14
|
||||||
|
|
||||||
### Bot API 9.6
|
### Bot API 9.6
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ func clonePlugin[T AppData](p *Plugin[T]) Plugin[T] {
|
|||||||
middlewares: append(extypes.Slice[Middleware[T]](nil), p.middlewares...),
|
middlewares: append(extypes.Slice[Middleware[T]](nil), p.middlewares...),
|
||||||
skipAutoCmd: p.skipAutoCmd,
|
skipAutoCmd: p.skipAutoCmd,
|
||||||
logger: p.logger,
|
logger: p.logger,
|
||||||
|
messageFallback: p.messageFallback,
|
||||||
handlers: make(map[tgapi.UpdateType]CommandExecutor[T]),
|
handlers: make(map[tgapi.UpdateType]CommandExecutor[T]),
|
||||||
onClose: p.onClose,
|
onClose: p.onClose,
|
||||||
}
|
}
|
||||||
|
|||||||
+167
-1
@@ -13,13 +13,16 @@ type recordingObserver struct {
|
|||||||
started []HandlerStartedEvent
|
started []HandlerStartedEvent
|
||||||
finished []HandlerFinishedEvent
|
finished []HandlerFinishedEvent
|
||||||
errors []ErrorEvent
|
errors []ErrorEvent
|
||||||
|
handled []UpdateHandledEvent
|
||||||
policies []PolicyCheckedEvent
|
policies []PolicyCheckedEvent
|
||||||
runners []RunnerFinishedEvent
|
runners []RunnerFinishedEvent
|
||||||
retries []PollingRetryEvent
|
retries []PollingRetryEvent
|
||||||
}
|
}
|
||||||
|
|
||||||
func (*recordingObserver) OnReceiveUpdate(context.Context, UpdateReceivedEvent) {}
|
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) {
|
func (o *recordingObserver) OnHandlerStarted(_ context.Context, ev HandlerStartedEvent) {
|
||||||
o.started = append(o.started, ev)
|
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) {
|
func TestHandleChannelPostCommandWithSenderChat(t *testing.T) {
|
||||||
called := false
|
called := false
|
||||||
plugin := NewPlugin[NoData]("test")
|
plugin := NewPlugin[NoData]("test")
|
||||||
|
|||||||
+94
-16
@@ -8,27 +8,14 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func (bot *Bot[T]) handleMessage(update *tgapi.Update, ctx *MsgContext) bool {
|
func (bot *Bot[T]) handleMessage(update *tgapi.Update, ctx *MsgContext) bool {
|
||||||
var msg *tgapi.Message
|
text, ok := messageText(update)
|
||||||
if update.Message != nil {
|
if !ok {
|
||||||
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 false
|
||||||
}
|
}
|
||||||
|
|
||||||
prefix, cmd, args := bot.parseCommand(text)
|
prefix, cmd, args := bot.parseCommand(text)
|
||||||
if cmd == "" {
|
if cmd == "" {
|
||||||
return false
|
return bot.handleFallback(update, ctx)
|
||||||
}
|
}
|
||||||
ctx.Prefix = prefix
|
ctx.Prefix = prefix
|
||||||
|
|
||||||
@@ -99,9 +86,100 @@ func (bot *Bot[T]) handleMessage(update *tgapi.Update, ctx *MsgContext) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return bot.handleFallback(update, ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (bot *Bot[T]) handleFallback(update *tgapi.Update, ctx *MsgContext) bool {
|
||||||
|
text, ok := messageText(update)
|
||||||
|
if !ok {
|
||||||
return false
|
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 {
|
func (bot *Bot[T]) handleCallback(update *tgapi.Update, ctx *MsgContext) bool {
|
||||||
data, err := bot.decodePayload(update.CallbackQuery.Data)
|
data, err := bot.decodePayload(update.CallbackQuery.Data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ type HandlerEventKind string
|
|||||||
const (
|
const (
|
||||||
// HandlerCommandKind identifies a command handler.
|
// HandlerCommandKind identifies a command handler.
|
||||||
HandlerCommandKind HandlerEventKind = "command"
|
HandlerCommandKind HandlerEventKind = "command"
|
||||||
|
// HandlerMessageKind identifies a message fallback handler.
|
||||||
|
HandlerMessageKind HandlerEventKind = "message"
|
||||||
// HandlerPayloadKind identifies a callback payload handler.
|
// HandlerPayloadKind identifies a callback payload handler.
|
||||||
HandlerPayloadKind HandlerEventKind = "payload"
|
HandlerPayloadKind HandlerEventKind = "payload"
|
||||||
// HandlerUpdateKind identifies a generic update handler.
|
// HandlerUpdateKind identifies a generic update handler.
|
||||||
|
|||||||
+14
-6
@@ -171,6 +171,7 @@ type Plugin[T AppData] struct {
|
|||||||
skipAutoCmd bool // If true, all commands in this plugin are excluded from auto-help
|
skipAutoCmd bool // If true, all commands in this plugin are excluded from auto-help
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
|
|
||||||
|
messageFallback CommandExecutor[T]
|
||||||
handlers map[tgapi.UpdateType]CommandExecutor[T]
|
handlers map[tgapi.UpdateType]CommandExecutor[T]
|
||||||
|
|
||||||
onClose func() error
|
onClose func() error
|
||||||
@@ -243,12 +244,6 @@ 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)
|
||||||
@@ -257,6 +252,12 @@ func (p *Plugin[T]) NewScene(name string) *Scene[T] {
|
|||||||
return scene
|
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.
|
// AddUpdateHandler registers a handler for a non-command update type.
|
||||||
// Message, channel post, and callback query updates stay on the command/payload flow.
|
// 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] {
|
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
|
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
|
// Close releases plugin-owned resources such as its logger and optional
|
||||||
// OnClose callback.
|
// OnClose callback.
|
||||||
func (p *Plugin[T]) Close() error {
|
func (p *Plugin[T]) Close() error {
|
||||||
|
|||||||
+2
-2
@@ -2,7 +2,7 @@ package utils
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
// VersionString is the module version string.
|
// 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 is the module major version.
|
||||||
VersionMajor = 1
|
VersionMajor = 1
|
||||||
// VersionMinor is the module minor version.
|
// VersionMinor is the module minor version.
|
||||||
@@ -10,5 +10,5 @@ const (
|
|||||||
// VersionPatch is the module patch version.
|
// VersionPatch is the module patch version.
|
||||||
VersionPatch = 0
|
VersionPatch = 0
|
||||||
// VersionBeta is the prerelease counter for the current version.
|
// VersionBeta is the prerelease counter for the current version.
|
||||||
VersionBeta = 14
|
VersionBeta = 15
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user