Files
Duc Nguyenandntduc d460c90403 fix(feishu): gate group pairing by target bot (#1291)
* fix(feishu): require exact bot_open_id match for mention detection

- Previously, when bot_open_id was empty, ALL mentions were treated as bot mentions
- This caused multiple agents in same group to all respond to any mention
- Now requires bot_open_id to be set AND match exactly for mentionedBot=true
- Fixes issue where CPPAI PM would respond even when other agent was mentioned

* fix(feishu): gate group pairing by target bot

---------

Co-authored-by: ntduc <ntduc@cpp.ai.vn>
2026-06-28 00:25:49 +07:00

269 lines
6.3 KiB
Go

package feishu
import (
"encoding/json"
"fmt"
"strings"
)
func (c *Channel) parseMessageEvent(event *MessageEvent) *messageContext {
msg := &event.Event.Message
sender := &event.Event.Sender
chatID := msg.ChatID
messageID := msg.MessageID
chatType := msg.ChatType
contentType := msg.MessageType
rootID := msg.RootID
parentID := msg.ParentID
threadID := msg.ThreadID
senderID := ""
if sender != nil {
senderID = sender.SenderID.OpenID
}
// Parse content
content := parseMessageContent(msg.Content, contentType)
// Parse mentions
var mentions []mentionInfo
mentionedBot := false
for _, m := range msg.Mentions {
mi := mentionInfo{
Key: m.Key,
OpenID: m.ID.OpenID,
UserID: m.ID.UserID,
UnionID: m.ID.UnionID,
Name: m.Name,
TenantKey: m.TenantKey,
}
mentions = append(mentions, mi)
// Check if this bot is mentioned. Unknown bot identity must fail closed
// so multi-agent groups do not all respond to another bot's mention.
if c.botOpenID != "" && mi.OpenID == c.botOpenID {
mentionedBot = true
}
}
// Replace mention placeholders with readable names.
// Bot mention is stripped entirely; other user mentions become @Name.
content = resolveMentions(content, mentions, c.botOpenID)
return &messageContext{
ChatID: chatID,
MessageID: messageID,
SenderID: senderID,
ChatType: chatType,
Content: content,
ContentType: contentType,
MentionedBot: mentionedBot,
RootID: rootID,
ParentID: parentID,
ThreadID: threadID,
Mentions: mentions,
}
}
// --- Content parsing ---
func parseMessageContent(rawContent, messageType string) string {
if rawContent == "" {
return ""
}
switch messageType {
case "text":
var textMsg struct {
Text string `json:"text"`
}
if err := json.Unmarshal([]byte(rawContent), &textMsg); err == nil {
return textMsg.Text
}
return rawContent
case "post":
return parsePostContent(rawContent)
case "image":
return "[image]"
case "file":
var fileMsg struct {
FileName string `json:"file_name"`
}
if err := json.Unmarshal([]byte(rawContent), &fileMsg); err == nil {
return fmt.Sprintf("[file: %s]", fileMsg.FileName)
}
return "[file]"
default:
return fmt.Sprintf("[%s message]", messageType)
}
}
// resolvePostElements extracts the flattened element list from a Lark post message JSON.
// Handles two formats:
// 1. Language-wrapped: {"zh_cn": {"title":"...", "content": [[...]]}}
// 2. Flat (no language key): {"title":"...", "content": [[...]]}
func resolvePostElements(rawContent string) []any {
var post map[string]any
if err := json.Unmarshal([]byte(rawContent), &post); err != nil {
return nil
}
// Format 2: flat — "content" at top level (no language wrapper).
if contentArr, ok := post["content"].([]any); ok {
return contentArr
}
// Format 1: language-wrapped — try known locales, then first map value.
var langContent any
for _, lang := range []string{"zh_cn", "en_us"} {
if lc, ok := post[lang]; ok {
langContent = lc
break
}
}
if langContent == nil {
for _, v := range post {
if _, ok := v.(map[string]any); ok {
langContent = v
break
}
}
}
if langContent == nil {
return nil
}
langMap, ok := langContent.(map[string]any)
if !ok {
return nil
}
contentArr, ok := langMap["content"].([]any)
if !ok {
return nil
}
return contentArr
}
func parsePostContent(rawContent string) string {
contentArr := resolvePostElements(rawContent)
if contentArr == nil {
return rawContent
}
var textParts []string
for _, para := range contentArr {
paraArr, ok := para.([]any)
if !ok {
continue
}
var lineParts []string
for _, elem := range paraArr {
elemMap, ok := elem.(map[string]any)
if !ok {
continue
}
tag, _ := elemMap["tag"].(string)
switch tag {
case "text":
if t, ok := elemMap["text"].(string); ok {
lineParts = append(lineParts, t)
}
case "md":
if t, ok := elemMap["text"].(string); ok {
lineParts = append(lineParts, t)
}
case "at":
if name, ok := elemMap["user_name"].(string); ok {
lineParts = append(lineParts, "@"+name)
}
case "a":
if href, ok := elemMap["href"].(string); ok {
text, _ := elemMap["text"].(string)
if text != "" {
lineParts = append(lineParts, fmt.Sprintf("[%s](%s)", text, href))
} else {
lineParts = append(lineParts, href)
}
}
case "img":
lineParts = append(lineParts, "[image]")
}
}
if len(lineParts) > 0 {
textParts = append(textParts, strings.Join(lineParts, ""))
}
}
return strings.Join(textParts, "\n")
}
// extractPostImageKeys parses post content JSON and returns deduplicated image_key
// values from embedded img tags. Used to download images inline in post messages.
func extractPostImageKeys(rawContent string) []string {
contentArr := resolvePostElements(rawContent)
if contentArr == nil {
return nil
}
seen := make(map[string]struct{})
var keys []string
for _, para := range contentArr {
paraArr, ok := para.([]any)
if !ok {
continue
}
for _, elem := range paraArr {
elemMap, ok := elem.(map[string]any)
if !ok {
continue
}
tag, _ := elemMap["tag"].(string)
if tag == "img" {
if key, ok := elemMap["image_key"].(string); ok && key != "" {
if _, dup := seen[key]; !dup {
seen[key] = struct{}{}
keys = append(keys, key)
}
}
}
}
}
return keys
}
// resolveMentions replaces mention placeholders (@_user_1, @_user_2, etc.) in content.
// Bot mention is stripped entirely; other user mentions become @Name.
func resolveMentions(text string, mentions []mentionInfo, botOpenID string) string {
for _, m := range mentions {
if m.Key == "" {
continue
}
if botOpenID != "" && m.OpenID == botOpenID {
// Strip bot mention
text = strings.ReplaceAll(text, m.Key, "")
} else if m.Name != "" {
// Replace with @Name
text = strings.ReplaceAll(text, m.Key, "@"+m.Name)
}
}
return strings.TrimSpace(text)
}
func formatMentionInfos(mentions []mentionInfo) []string {
if len(mentions) == 0 {
return nil
}
out := make([]string, 0, len(mentions))
for _, m := range mentions {
out = append(out, fmt.Sprintf("key=%s open_id=%s user_id=%s union_id=%s name=%s tenant=%s",
m.Key, m.OpenID, m.UserID, m.UnionID, m.Name, m.TenantKey))
}
return out
}