mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-25 07:00:48 +09:00
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:
@@ -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{
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user