From 15a1e7afdfc648848c820f76d01c51dc15377160 Mon Sep 17 00:00:00 2001 From: mingmxren Date: Sun, 1 Mar 2026 16:34:30 +0800 Subject: [PATCH] fix(telegram): allow group slash commands through mention filter --- pkg/channels/telegram/telegram.go | 96 ++++++++---- .../telegram_group_command_filter_test.go | 147 ++++++++++++++++++ 2 files changed, 215 insertions(+), 28 deletions(-) create mode 100644 pkg/channels/telegram/telegram_group_command_filter_test.go diff --git a/pkg/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go index 7d19e0e7c..cfcde789e 100644 --- a/pkg/channels/telegram/telegram.go +++ b/pkg/channels/telegram/telegram.go @@ -41,14 +41,14 @@ var ( type TelegramChannel struct { *channels.BaseChannel - bot *telego.Bot - bh *telegohandler.BotHandler - commands TelegramCommander + bot *telego.Bot + bh *telegohandler.BotHandler + commands TelegramCommander dispatcher commands.Dispatching - config *config.Config - chatIDs map[string]int64 - ctx context.Context - cancel context.CancelFunc + config *config.Config + chatIDs map[string]int64 + ctx context.Context + cancel context.CancelFunc registerFunc func(context.Context, []commands.Definition) error commandRegCancel context.CancelFunc @@ -659,34 +659,34 @@ func escapeHTML(text string) string { // isBotMentioned checks if the bot is mentioned in the message via entities. func (c *TelegramChannel) isBotMentioned(message *telego.Message) bool { - botUsername := c.bot.Username() - if botUsername == "" { + text, entities := telegramEntityTextAndList(message) + if text == "" || len(entities) == 0 { return false } - entities := message.Entities - if entities == nil { - entities = message.CaptionEntities + botUsername := "" + if c.bot != nil { + botUsername = c.bot.Username() } + runes := []rune(text) for _, entity := range entities { - if entity.Type == "mention" { - // Extract the mention text from the message - text := message.Text - if text == "" { - text = message.Caption - } - runes := []rune(text) - end := entity.Offset + entity.Length - if end <= len(runes) { - mention := string(runes[entity.Offset:end]) - if strings.EqualFold(mention, "@"+botUsername) { - return true - } - } + entityText, ok := telegramEntityText(runes, entity) + if !ok { + continue } - if entity.Type == "text_mention" && entity.User != nil { - if entity.User.Username == botUsername { + + switch entity.Type { + case telego.EntityTypeMention: + if botUsername != "" && strings.EqualFold(entityText, "@"+botUsername) { + return true + } + case telego.EntityTypeTextMention: + if botUsername != "" && entity.User != nil && strings.EqualFold(entity.User.Username, botUsername) { + return true + } + case telego.EntityTypeBotCommand: + if isBotCommandEntityForThisBot(entityText, botUsername) { return true } } @@ -694,6 +694,46 @@ func (c *TelegramChannel) isBotMentioned(message *telego.Message) bool { return false } +func telegramEntityTextAndList(message *telego.Message) (string, []telego.MessageEntity) { + if message.Text != "" { + return message.Text, message.Entities + } + return message.Caption, message.CaptionEntities +} + +func telegramEntityText(runes []rune, entity telego.MessageEntity) (string, bool) { + if entity.Offset < 0 || entity.Length <= 0 { + return "", false + } + end := entity.Offset + entity.Length + if entity.Offset >= len(runes) || end > len(runes) { + return "", false + } + return string(runes[entity.Offset:end]), true +} + +func isBotCommandEntityForThisBot(entityText, botUsername string) bool { + if !strings.HasPrefix(entityText, "/") { + return false + } + command := strings.TrimPrefix(entityText, "/") + if command == "" { + return false + } + + at := strings.IndexRune(command, '@') + if at == -1 { + // A bare /command delivered to this bot is intended for this bot. + return true + } + + mentionUsername := command[at+1:] + if mentionUsername == "" || botUsername == "" { + return false + } + return strings.EqualFold(mentionUsername, botUsername) +} + // stripBotMention removes the @bot mention from the content. func (c *TelegramChannel) stripBotMention(content string) string { botUsername := c.bot.Username() diff --git a/pkg/channels/telegram/telegram_group_command_filter_test.go b/pkg/channels/telegram/telegram_group_command_filter_test.go new file mode 100644 index 000000000..0d5b985fe --- /dev/null +++ b/pkg/channels/telegram/telegram_group_command_filter_test.go @@ -0,0 +1,147 @@ +package telegram + +import ( + "context" + "fmt" + "strings" + "testing" + "time" + + "github.com/mymmrac/telego" + ta "github.com/mymmrac/telego/telegoapi" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/config" +) + +type getMeCaller struct { + username string +} + +func (c getMeCaller) Call(_ context.Context, url string, _ *ta.RequestData) (*ta.Response, error) { + if strings.HasSuffix(url, "/getMe") { + result := fmt.Sprintf(`{"id":1,"is_bot":true,"first_name":"bot","username":%q}`, c.username) + return &ta.Response{Ok: true, Result: []byte(result)}, nil + } + return &ta.Response{Ok: true, Result: []byte("true")}, nil +} + +func newTestTelegramBot(t *testing.T, username string) *telego.Bot { + t.Helper() + + token := "123456:" + strings.Repeat("a", 35) + bot, err := telego.NewBot(token, + telego.WithAPICaller(getMeCaller{username: username}), + telego.WithDiscardLogger(), + ) + if err != nil { + t.Fatalf("NewBot error: %v", err) + } + return bot +} + +func newGroupMentionOnlyChannel(t *testing.T, botUsername string) (*TelegramChannel, *bus.MessageBus) { + t.Helper() + + messageBus := bus.NewMessageBus() + ch := &TelegramChannel{ + BaseChannel: channels.NewBaseChannel("telegram", nil, messageBus, nil, + channels.WithGroupTrigger(config.GroupTriggerConfig{MentionOnly: true}), + ), + bot: newTestTelegramBot(t, botUsername), + chatIDs: make(map[string]int64), + ctx: context.Background(), + } + return ch, messageBus +} + +func TestHandleMessage_GroupMentionOnly_BotCommandEntity(t *testing.T) { + tests := []struct { + name string + text string + wantForwarded bool + wantContent string + }{ + { + name: "command with bot username", + text: "/new@testbot", + wantForwarded: true, + wantContent: "/new", + }, + { + name: "bare command", + text: "/new", + wantForwarded: true, + wantContent: "/new", + }, + { + name: "command for another bot", + text: "/new@otherbot", + wantForwarded: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + ch, messageBus := newGroupMentionOnlyChannel(t, "testbot") + + msg := &telego.Message{ + Text: tc.text, + Entities: []telego.MessageEntity{{ + Type: telego.EntityTypeBotCommand, + Offset: 0, + Length: len([]rune(tc.text)), + }}, + MessageID: 42, + Chat: telego.Chat{ + ID: 123, + Type: "group", + }, + From: &telego.User{ + ID: 7, + FirstName: "Alice", + }, + } + + if err := ch.handleMessage(context.Background(), msg); err != nil { + t.Fatalf("handleMessage error: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond) + defer cancel() + + inbound, ok := messageBus.ConsumeInbound(ctx) + if tc.wantForwarded { + if !ok { + t.Fatal("expected inbound message to be forwarded") + } + if inbound.Content != tc.wantContent { + t.Fatalf("content=%q want=%q", inbound.Content, tc.wantContent) + } + return + } + + if ok { + t.Fatalf("expected message to be filtered, got content=%q", inbound.Content) + } + }) + } +} + +func TestIsBotMentioned_MentionEntityUnaffected(t *testing.T) { + ch, _ := newGroupMentionOnlyChannel(t, "testbot") + + msg := &telego.Message{ + Text: "@testbot hello", + Entities: []telego.MessageEntity{{ + Type: telego.EntityTypeMention, + Offset: 0, + Length: len("@testbot"), + }}, + } + + if !ch.isBotMentioned(msg) { + t.Fatal("expected mention entity to be treated as bot mention") + } +}