diff --git a/internal/channel/adapters/discord/discord.go b/internal/channel/adapters/discord/discord.go index 2bd59891..b1538aae 100644 --- a/internal/channel/adapters/discord/discord.go +++ b/internal/channel/adapters/discord/discord.go @@ -167,6 +167,7 @@ func (a *DiscordAdapter) Connect(ctx context.Context, cfg channel.ChannelConfig, return } + rawText := text attachments := a.collectAttachments(m.Message) chatType := "direct" if m.GuildID != "" { @@ -223,6 +224,7 @@ func (a *DiscordAdapter) Connect(ctx context.Context, cfg channel.ChannelConfig, "guild_id": m.GuildID, "is_mentioned": isMentioned, "is_reply_to_bot": isReplyToBot, + "raw_text": rawText, }, } diff --git a/internal/channel/adapters/feishu/quoted_message.go b/internal/channel/adapters/feishu/quoted_message.go index d3c59955..77369c63 100644 --- a/internal/channel/adapters/feishu/quoted_message.go +++ b/internal/channel/adapters/feishu/quoted_message.go @@ -104,6 +104,12 @@ func (a *FeishuAdapter) enrichQuotedMessage(ctx context.Context, cfg channel.Cha } current := strings.TrimSpace(msg.Message.Text) + if msg.Metadata == nil { + msg.Metadata = map[string]any{} + } + if _, exists := msg.Metadata["raw_text"]; !exists { + msg.Metadata["raw_text"] = current + } if current != "" { msg.Message.Text = quotedText + "\n" + current } else { diff --git a/internal/channel/adapters/telegram/telegram.go b/internal/channel/adapters/telegram/telegram.go index 50a57f21..b6a0017b 100644 --- a/internal/channel/adapters/telegram/telegram.go +++ b/internal/channel/adapters/telegram/telegram.go @@ -493,6 +493,7 @@ func (a *TelegramAdapter) toInboundTelegramMessage( if text == "" && len(attachments) == 0 { return channel.InboundMessage{}, false } + rawText := text // Prepend quoted message context so the AI can see what is being replied to, // and include quoted attachments so the LLM can see the actual media. if raw.ReplyToMessage != nil { @@ -530,6 +531,7 @@ func (a *TelegramAdapter) toInboundTelegramMessage( meta := map[string]any{ "is_mentioned": isMentioned, "is_reply_to_bot": isReplyToBot, + "raw_text": rawText, } for key, value := range metadata { meta[key] = value @@ -1270,7 +1272,9 @@ func isTelegramBotMentioned(msg *tgbotapi.Message, botUsername string) bool { entities = append(entities, msg.CaptionEntities...) for _, entity := range entities { if entity.Type == "text_mention" && entity.User != nil && entity.User.IsBot { - return true + if normalizedBot != "" && strings.EqualFold(entity.User.UserName, normalizedBot) { + return true + } } } return false diff --git a/internal/channel/adapters/telegram/telegram_test.go b/internal/channel/adapters/telegram/telegram_test.go index 0a4ae41e..bc5714fe 100644 --- a/internal/channel/adapters/telegram/telegram_test.go +++ b/internal/channel/adapters/telegram/telegram_test.go @@ -48,21 +48,36 @@ func TestIsTelegramBotMentioned(t *testing.T) { } }) - t.Run("entity text mention", func(t *testing.T) { + t.Run("entity text mention matching bot", func(t *testing.T) { t.Parallel() msg := &tgbotapi.Message{ Entities: []tgbotapi.MessageEntity{ { Type: "text_mention", - User: &tgbotapi.User{IsBot: true}, + User: &tgbotapi.User{IsBot: true, UserName: "memohbot"}, }, }, } - if !isTelegramBotMentioned(msg, "") { + if !isTelegramBotMentioned(msg, "memohbot") { t.Fatalf("expected bot mention from text_mention entity") } }) + t.Run("entity text mention other bot", func(t *testing.T) { + t.Parallel() + msg := &tgbotapi.Message{ + Entities: []tgbotapi.MessageEntity{ + { + Type: "text_mention", + User: &tgbotapi.User{IsBot: true, UserName: "otherbot"}, + }, + }, + } + if isTelegramBotMentioned(msg, "memohbot") { + t.Fatalf("expected no mention for different bot") + } + }) + t.Run("not mentioned", func(t *testing.T) { t.Parallel() msg := &tgbotapi.Message{ diff --git a/internal/channel/inbound/channel.go b/internal/channel/inbound/channel.go index ee4f6aaf..ee47a562 100644 --- a/internal/channel/inbound/channel.go +++ b/internal/channel/inbound/channel.go @@ -208,8 +208,14 @@ func (p *ChannelInboundProcessor) HandleInbound(ctx context.Context, cfg channel identity := state.Identity // Intercept slash commands before they reach the LLM. - if p.commandHandler != nil && p.commandHandler.IsCommand(text) { - reply, err := p.commandHandler.Execute(ctx, strings.TrimSpace(identity.BotID), strings.TrimSpace(identity.ChannelIdentityID), text) + // Use raw_text (without prepended quote/forward context) so that + // quoted content like "[Reply to Bot: /fs list]\n hello" doesn't + // accidentally match a command. + // In group chats, only process if the message is directed at this bot + // (via @mention or reply) to avoid all bots responding to the same command. + cmdText := rawTextForCommand(msg, text) + if p.commandHandler != nil && p.commandHandler.IsCommand(cmdText) && isDirectedAtBot(msg) { + reply, err := p.commandHandler.Execute(ctx, strings.TrimSpace(identity.BotID), strings.TrimSpace(identity.ChannelIdentityID), cmdText) if err != nil { reply = "Error: " + err.Error() } @@ -594,7 +600,29 @@ func shouldTriggerAssistantResponse(msg channel.InboundMessage) bool { if metadataBool(msg.Metadata, "is_reply_to_bot") { return true } - return hasCommandPrefix(msg.Message.PlainText(), msg.Metadata) + return false +} + +// isDirectedAtBot reports whether the message is explicitly directed at this bot, +// either because it's a direct conversation, the bot is @mentioned, or it's a reply +// to this bot's message. +func isDirectedAtBot(msg channel.InboundMessage) bool { + if isDirectConversationType(msg.Conversation.Type) { + return true + } + return metadataBool(msg.Metadata, "is_mentioned") || metadataBool(msg.Metadata, "is_reply_to_bot") +} + +// rawTextForCommand returns the original user text (without prepended +// quote/forward context) for slash-command detection. Adapters store the +// undecorated text as metadata["raw_text"]; this helper falls back to the +// full decorated text when the key is absent (e.g. direct messages or +// adapters that don't prepend context). +func rawTextForCommand(msg channel.InboundMessage, fallback string) string { + if raw, ok := msg.Metadata["raw_text"].(string); ok && strings.TrimSpace(raw) != "" { + return raw + } + return fallback } func isDirectConversationType(conversationType string) bool { @@ -602,59 +630,6 @@ func isDirectConversationType(conversationType string) bool { return ct == "" || ct == "p2p" || ct == "private" || ct == "direct" } -func hasCommandPrefix(text string, metadata map[string]any) bool { - trimmed := strings.TrimSpace(text) - if trimmed == "" { - return false - } - prefixes := []string{"/"} - if metadata != nil { - if raw, ok := metadata["command_prefix"]; ok { - if value := strings.TrimSpace(fmt.Sprint(raw)); value != "" { - prefixes = []string{value} - } - } - if raw, ok := metadata["command_prefixes"]; ok { - if parsed := parseCommandPrefixes(raw); len(parsed) > 0 { - prefixes = parsed - } - } - } - for _, prefix := range prefixes { - if strings.HasPrefix(trimmed, prefix) { - return true - } - } - return false -} - -func parseCommandPrefixes(raw any) []string { - if items, ok := raw.([]string); ok { - result := make([]string, 0, len(items)) - for _, item := range items { - value := strings.TrimSpace(item) - if value == "" { - continue - } - result = append(result, value) - } - return result - } - items, ok := raw.([]any) - if !ok { - return nil - } - result := make([]string, 0, len(items)) - for _, item := range items { - value := strings.TrimSpace(fmt.Sprint(item)) - if value == "" { - continue - } - result = append(result, value) - } - return result -} - func metadataBool(metadata map[string]any, key string) bool { if metadata == nil { return false