fix: slash commands in group chats trigger all bots instead of targeted one

- In group chats, only process slash commands when the message is
  directed at this bot (via @mention or reply-to-bot), preventing
  all bots from responding to the same command.
- Use raw_text metadata (before quote/forward context prepending)
  for command detection so quoted content like "/fs" doesn't
  accidentally match a command.
- Fix isTelegramBotMentioned text_mention entity check to verify
  the mentioned bot matches the current bot, not just any bot.
This commit is contained in:
Acbox
2026-03-12 20:08:55 +08:00
parent e9059fddda
commit 9b771acaa8
5 changed files with 62 additions and 60 deletions
@@ -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,
},
}
@@ -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 {
@@ -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
@@ -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{
+31 -56
View File
@@ -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