mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 02:14:39 +00:00
* 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>
269 lines
6.3 KiB
Go
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
|
|
}
|