From 830c521f11dce9b3e9e7f3a4ee54062fd18d1f61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=99=A8=E8=8B=92?= <16112591+chen-ran@users.noreply.github.com> Date: Mon, 6 Apr 2026 06:18:03 +0800 Subject: [PATCH] feat(feishu): keep mention(at) target --- internal/channel/adapters/feishu/directory.go | 7 - .../channel/adapters/feishu/feishu_test.go | 58 +++++ internal/channel/adapters/feishu/inbound.go | 237 ++---------------- .../adapters/feishu/inbound_mentions.go | 231 +++++++++++++++++ .../channel/adapters/feishu/inbound_post.go | 130 ++++++++++ internal/channel/inbound/channel.go | 50 ++-- internal/conversation/flow/resolver.go | 23 +- internal/conversation/flow/user_header.go | 57 ++++- .../conversation/flow/user_header_test.go | 45 ++++ internal/conversation/types.go | 2 + 10 files changed, 561 insertions(+), 279 deletions(-) create mode 100644 internal/channel/adapters/feishu/inbound_mentions.go create mode 100644 internal/channel/adapters/feishu/inbound_post.go create mode 100644 internal/conversation/flow/user_header_test.go diff --git a/internal/channel/adapters/feishu/directory.go b/internal/channel/adapters/feishu/directory.go index b4023091..f7828985 100644 --- a/internal/channel/adapters/feishu/directory.go +++ b/internal/channel/adapters/feishu/directory.go @@ -282,13 +282,6 @@ func feishuMemberToEntry(m *larkim.ListMember) channel.DirectoryEntry { } } -func ptrStr(s *string) string { - if s == nil { - return "" - } - return strings.TrimSpace(*s) -} - func feishuAvatarURL(avatar *larkcontact.AvatarInfo) string { if avatar == nil || avatar.Avatar72 == nil { return "" diff --git a/internal/channel/adapters/feishu/feishu_test.go b/internal/channel/adapters/feishu/feishu_test.go index e8e8f4ae..f3d5fffb 100644 --- a/internal/channel/adapters/feishu/feishu_test.go +++ b/internal/channel/adapters/feishu/feishu_test.go @@ -452,6 +452,64 @@ func TestExtractFeishuInboundMentionBotMatched(t *testing.T) { } } +func TestExtractFeishuInboundMentionKeyRewriteAndTargets(t *testing.T) { + t.Parallel() + + text := `{"text":"@_user_1 hello @_user_2"}` + msgType := larkim.MsgTypeText + chatType := "group" + chatID := "oc_mention_rewrite" + + openID1 := "ou_user_1" + name1 := "Alice" + mention1 := larkim.NewMentionEventBuilder(). + Key("@_user_1"). + Name(name1). + Id(larkim.NewUserIdBuilder().OpenId(openID1).Build()). + Build() + + userID2 := "u_user_2" + name2 := "Bob" + mention2 := larkim.NewMentionEventBuilder(). + Key("@_user_2"). + Name(name2). + Id(larkim.NewUserIdBuilder().UserId(userID2).Build()). + Build() + + event := &larkim.P2MessageReceiveV1{ + Event: &larkim.P2MessageReceiveV1Data{ + Message: &larkim.EventMessage{ + MessageType: &msgType, + Content: &text, + ChatType: &chatType, + ChatId: &chatID, + Mentions: []*larkim.MentionEvent{mention1, mention2}, + }, + }, + } + + got := extractFeishuInbound(event, "ou_bot_123") + if got.Message.PlainText() != "@Alice hello @Bob" { + t.Fatalf("unexpected rewritten text: %q", got.Message.PlainText()) + } + + targets, ok := got.Metadata["mentioned_targets"].([]string) + if !ok { + t.Fatalf("expected mentioned_targets to be []string, got %#v", got.Metadata["mentioned_targets"]) + } + if len(targets) != 2 || targets[0] != "open_id:ou_user_1" || targets[1] != "user_id:u_user_2" { + t.Fatalf("unexpected mentioned_targets: %#v", targets) + } + + mentions, ok := got.Metadata["mentions"].([]map[string]any) + if !ok || len(mentions) != 2 { + t.Fatalf("expected mentions metadata with 2 entries, got %#v", got.Metadata["mentions"]) + } + if mentions[0]["target"] != "open_id:ou_user_1" || mentions[1]["target"] != "user_id:u_user_2" { + t.Fatalf("unexpected mention targets in metadata: %#v", mentions) + } +} + func TestExtractFeishuInboundMentionOtherUserIgnored(t *testing.T) { t.Parallel() diff --git a/internal/channel/adapters/feishu/inbound.go b/internal/channel/adapters/feishu/inbound.go index e1ad217b..047dbd03 100644 --- a/internal/channel/adapters/feishu/inbound.go +++ b/internal/channel/adapters/feishu/inbound.go @@ -3,7 +3,6 @@ package feishu import ( "encoding/json" "errors" - "fmt" "log/slog" "strings" "time" @@ -38,13 +37,14 @@ func extractFeishuInbound(event *larkim.P2MessageReceiveV1, botOpenID string, lo } } } - isMentioned := isFeishuBotMentioned(contentMap, message.Mentions, botOpenID) + mentions := normalizeFeishuMentions(message.Mentions) + isMentioned := isFeishuBotMentioned(contentMap, mentions, botOpenID) if message.MessageType != nil { switch *message.MessageType { case larkim.MsgTypeText: if txt, ok := contentMap["text"].(string); ok { - msg.Text = txt + msg.Text = rewriteFeishuMentionKeys(txt, mentions) } case larkim.MsgTypePost: postText := extractFeishuPostText(contentMap) @@ -153,230 +153,14 @@ func extractFeishuInbound(event *larkim.P2MessageReceiveV1, botOpenID string, lo ReceivedAt: time.Now().UTC(), Source: "feishu", Metadata: map[string]any{ - "is_mentioned": isMentioned, - "raw_chat_type": chatTypeRaw, + "is_mentioned": isMentioned, + "raw_chat_type": chatTypeRaw, + "mentions": feishuMentionsMetadata(mentions), + "mentioned_targets": feishuMentionTargets(mentions), }, } } -// isFeishuBotMentioned checks whether the bot itself is mentioned in the message. -// When botOpenID is provided, only mentions matching the bot's open_id count. -// When botOpenID is empty (fallback), any mention is treated as a bot mention. -func isFeishuBotMentioned(contentMap map[string]any, mentions []*larkim.MentionEvent, botOpenID string) bool { - botOpenID = strings.TrimSpace(botOpenID) - if botOpenID == "" { - return hasAnyFeishuMention(contentMap, mentions) - } - for _, m := range mentions { - if m == nil || m.Id == nil || m.Id.OpenId == nil { - continue - } - if strings.TrimSpace(*m.Id.OpenId) == botOpenID { - return true - } - } - return matchFeishuContentMention(contentMap, botOpenID) -} - -// hasAnyFeishuMention is the fallback when the bot's open_id is unknown. -func hasAnyFeishuMention(contentMap map[string]any, mentions []*larkim.MentionEvent) bool { - if len(mentions) > 0 { - return true - } - if len(contentMap) == 0 { - return false - } - if raw, ok := contentMap["mentions"]; ok { - switch values := raw.(type) { - case []any: - if len(values) > 0 { - return true - } - case []map[string]any: - if len(values) > 0 { - return true - } - } - } - if text, ok := contentMap["text"].(string); ok { - normalized := strings.ToLower(strings.TrimSpace(text)) - if strings.Contains(normalized, "@_user_") || strings.Contains(normalized, "") { - return true - } - } - return hasFeishuAtTag(contentMap) -} - -// matchFeishuContentMention checks rich-text at tags for the bot's open_id. -func matchFeishuContentMention(raw any, botOpenID string) bool { - switch value := raw.(type) { - case map[string]any: - if tag, ok := value["tag"].(string); ok && strings.EqualFold(strings.TrimSpace(tag), "at") { - if uid, ok := value["user_id"].(string); ok && strings.TrimSpace(uid) == botOpenID { - return true - } - if uid, ok := value["open_id"].(string); ok && strings.TrimSpace(uid) == botOpenID { - return true - } - } - for _, child := range value { - if matchFeishuContentMention(child, botOpenID) { - return true - } - } - case []any: - for _, child := range value { - if matchFeishuContentMention(child, botOpenID) { - return true - } - } - } - return false -} - -func hasFeishuAtTag(raw any) bool { - switch value := raw.(type) { - case map[string]any: - if tag, ok := value["tag"].(string); ok && strings.EqualFold(strings.TrimSpace(tag), "at") { - return true - } - for _, child := range value { - if hasFeishuAtTag(child) { - return true - } - } - case []any: - for _, child := range value { - if hasFeishuAtTag(child) { - return true - } - } - } - return false -} - -// getFeishuPostContentLines returns content lines from post message. -// Feishu event payload uses root-level content: {"title":"","content":[[...],[...]]}. -func getFeishuPostContentLines(contentMap map[string]any) []any { - if lines, ok := contentMap["content"].([]any); ok { - return lines - } - return nil -} - -// extractFeishuPostAttachments extracts image/file attachments from post content (e.g. img elements). -func extractFeishuPostAttachments(contentMap map[string]any, messageID string) []channel.Attachment { - var result []channel.Attachment - linesRaw := getFeishuPostContentLines(contentMap) - if linesRaw == nil { - return result - } - for _, rawLine := range linesRaw { - line, ok := rawLine.([]any) - if !ok { - continue - } - for _, rawPart := range line { - part, ok := rawPart.(map[string]any) - if !ok { - continue - } - tag := strings.ToLower(strings.TrimSpace(stringValue(part["tag"]))) - if tag == "img" { - if key, ok := part["image_key"].(string); ok && strings.TrimSpace(key) != "" { - mime := strings.TrimSpace(stringValue(part["mime_type"])) - result = append(result, channel.NormalizeInboundChannelAttachment(channel.Attachment{ - Type: channel.AttachmentImage, - PlatformKey: strings.TrimSpace(key), - SourcePlatform: Type.String(), - Mime: mime, - Metadata: map[string]any{"message_id": messageID}, - })) - } - } - if tag == "file" { - if key, ok := part["file_key"].(string); ok && strings.TrimSpace(key) != "" { - name := strings.TrimSpace(stringValue(part["file_name"])) - mime := strings.TrimSpace(stringValue(part["mime_type"])) - result = append(result, channel.NormalizeInboundChannelAttachment(channel.Attachment{ - Type: channel.AttachmentFile, - PlatformKey: strings.TrimSpace(key), - SourcePlatform: Type.String(), - Name: name, - Mime: mime, - Metadata: map[string]any{"message_id": messageID}, - })) - } - } - } - } - return result -} - -func extractFeishuPostText(contentMap map[string]any) string { - linesRaw := getFeishuPostContentLines(contentMap) - if linesRaw == nil { - return "" - } - parts := make([]string, 0, 8) - for _, rawLine := range linesRaw { - line, ok := rawLine.([]any) - if !ok { - continue - } - for _, rawPart := range line { - part, ok := rawPart.(map[string]any) - if !ok { - continue - } - tag := strings.ToLower(strings.TrimSpace(stringValue(part["tag"]))) - switch tag { - case "text", "a": - text := strings.TrimSpace(stringValue(part["text"])) - if text != "" { - parts = append(parts, text) - } - case "at": - name := strings.TrimSpace(stringValue(part["text"])) - if name == "" { - name = strings.TrimSpace(stringValue(part["name"])) - } - if name == "" { - name = strings.TrimSpace(stringValue(part["user_name"])) - } - if name == "" { - parts = append(parts, "@") - continue - } - if !strings.HasPrefix(name, "@") { - name = "@" + name - } - parts = append(parts, name) - default: - text := strings.TrimSpace(stringValue(part["text"])) - if text != "" { - parts = append(parts, text) - } - } - } - } - if len(parts) == 0 { - return "" - } - return strings.Join(parts, " ") -} - -func stringValue(raw any) string { - if raw == nil { - return "" - } - value, ok := raw.(string) - if ok { - return value - } - return fmt.Sprint(raw) -} - func normalizeFeishuConversationType(chatType string) string { switch strings.ToLower(strings.TrimSpace(chatType)) { case "p2p": @@ -404,3 +188,10 @@ func resolveFeishuReceiveID(raw string) (string, string, error) { } return raw, larkim.ReceiveIdTypeOpenId, nil } + +func ptrStr(s *string) string { + if s == nil { + return "" + } + return strings.TrimSpace(*s) +} diff --git a/internal/channel/adapters/feishu/inbound_mentions.go b/internal/channel/adapters/feishu/inbound_mentions.go new file mode 100644 index 00000000..0f5a44da --- /dev/null +++ b/internal/channel/adapters/feishu/inbound_mentions.go @@ -0,0 +1,231 @@ +package feishu + +import ( + "sort" + "strings" + + larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1" +) + +type feishuMention struct { + Key string + Name string + OpenID string + UserID string + UnionID string + TenantKey string +} + +func normalizeFeishuMentions(mentions []*larkim.MentionEvent) []feishuMention { + result := make([]feishuMention, 0, len(mentions)) + for _, m := range mentions { + if m == nil { + continue + } + item := feishuMention{ + Key: strings.TrimSpace(ptrStr(m.Key)), + Name: strings.TrimSpace(ptrStr(m.Name)), + } + if m.Id != nil { + item.OpenID = strings.TrimSpace(ptrStr(m.Id.OpenId)) + item.UserID = strings.TrimSpace(ptrStr(m.Id.UserId)) + item.UnionID = strings.TrimSpace(ptrStr(m.Id.UnionId)) + } + item.TenantKey = strings.TrimSpace(ptrStr(m.TenantKey)) + result = append(result, item) + } + return result +} + +func feishuMentionDisplayName(m feishuMention) string { + name := strings.TrimSpace(m.Name) + if name != "" { + if strings.HasPrefix(name, "@") { + return name + } + return "@" + name + } + if strings.TrimSpace(m.OpenID) != "" { + return "@open_id:" + strings.TrimSpace(m.OpenID) + } + if strings.TrimSpace(m.UserID) != "" { + return "@user_id:" + strings.TrimSpace(m.UserID) + } + return "@user" +} + +// rewriteFeishuMentionKeys converts Feishu text placeholders (e.g. @_user_1) +// into stable mention labels so downstream logic can identify who is mentioned. +func rewriteFeishuMentionKeys(text string, mentions []feishuMention) string { + if strings.TrimSpace(text) == "" || len(mentions) == 0 { + return text + } + type kv struct { + key string + value string + } + replacements := make([]kv, 0, len(mentions)) + for _, mention := range mentions { + if mention.Key == "" { + continue + } + replacements = append(replacements, kv{ + key: mention.Key, + value: feishuMentionDisplayName(mention), + }) + } + // Replace longer keys first to avoid partial replacement (@_user_1 vs @_user_10). + sort.Slice(replacements, func(i, j int) bool { + return len(replacements[i].key) > len(replacements[j].key) + }) + rewritten := text + for _, item := range replacements { + rewritten = strings.ReplaceAll(rewritten, item.key, item.value) + } + return rewritten +} + +func feishuMentionsMetadata(mentions []feishuMention) []map[string]any { + if len(mentions) == 0 { + return nil + } + result := make([]map[string]any, 0, len(mentions)) + for _, mention := range mentions { + entry := map[string]any{ + "key": mention.Key, + "name": mention.Name, + "open_id": mention.OpenID, + "user_id": mention.UserID, + "union_id": mention.UnionID, + "tenant_key": mention.TenantKey, + } + if target := feishuMentionTarget(mention); target != "" { + entry["target"] = target + } + result = append(result, entry) + } + return result +} + +func feishuMentionTarget(mention feishuMention) string { + if strings.TrimSpace(mention.OpenID) != "" { + return "open_id:" + strings.TrimSpace(mention.OpenID) + } + if strings.TrimSpace(mention.UserID) != "" { + return "user_id:" + strings.TrimSpace(mention.UserID) + } + return "" +} + +func feishuMentionTargets(mentions []feishuMention) []string { + if len(mentions) == 0 { + return nil + } + seen := make(map[string]struct{}, len(mentions)) + result := make([]string, 0, len(mentions)) + for _, mention := range mentions { + target := feishuMentionTarget(mention) + if target == "" { + continue + } + if _, ok := seen[target]; ok { + continue + } + seen[target] = struct{}{} + result = append(result, target) + } + return result +} + +// isFeishuBotMentioned checks whether the bot itself is mentioned in the message. +// When botOpenID is provided, only mentions matching the bot's open_id count. +// When botOpenID is empty (fallback), any mention is treated as a bot mention. +func isFeishuBotMentioned(contentMap map[string]any, mentions []feishuMention, botOpenID string) bool { + botOpenID = strings.TrimSpace(botOpenID) + if botOpenID == "" { + return hasAnyFeishuMention(contentMap, len(mentions)) + } + for _, m := range mentions { + if strings.TrimSpace(m.OpenID) == botOpenID { + return true + } + } + return matchFeishuContentMention(contentMap, botOpenID) +} + +// hasAnyFeishuMention is the fallback when the bot's open_id is unknown. +func hasAnyFeishuMention(contentMap map[string]any, mentionCount int) bool { + if mentionCount > 0 { + return true + } + if len(contentMap) == 0 { + return false + } + if raw, ok := contentMap["mentions"]; ok { + switch values := raw.(type) { + case []any: + if len(values) > 0 { + return true + } + case []map[string]any: + if len(values) > 0 { + return true + } + } + } + if text, ok := contentMap["text"].(string); ok { + normalized := strings.ToLower(strings.TrimSpace(text)) + if strings.Contains(normalized, "@_user_") || strings.Contains(normalized, "") { + return true + } + } + return hasFeishuAtTag(contentMap) +} + +// matchFeishuContentMention checks rich-text at tags for the bot's open_id. +func matchFeishuContentMention(raw any, botOpenID string) bool { + switch value := raw.(type) { + case map[string]any: + if tag, ok := value["tag"].(string); ok && strings.EqualFold(strings.TrimSpace(tag), "at") { + if uid, ok := value["user_id"].(string); ok && strings.TrimSpace(uid) == botOpenID { + return true + } + if uid, ok := value["open_id"].(string); ok && strings.TrimSpace(uid) == botOpenID { + return true + } + } + for _, child := range value { + if matchFeishuContentMention(child, botOpenID) { + return true + } + } + case []any: + for _, child := range value { + if matchFeishuContentMention(child, botOpenID) { + return true + } + } + } + return false +} + +func hasFeishuAtTag(raw any) bool { + switch value := raw.(type) { + case map[string]any: + if tag, ok := value["tag"].(string); ok && strings.EqualFold(strings.TrimSpace(tag), "at") { + return true + } + for _, child := range value { + if hasFeishuAtTag(child) { + return true + } + } + case []any: + for _, child := range value { + if hasFeishuAtTag(child) { + return true + } + } + } + return false +} diff --git a/internal/channel/adapters/feishu/inbound_post.go b/internal/channel/adapters/feishu/inbound_post.go new file mode 100644 index 00000000..273c8191 --- /dev/null +++ b/internal/channel/adapters/feishu/inbound_post.go @@ -0,0 +1,130 @@ +package feishu + +import ( + "fmt" + "strings" + + "github.com/memohai/memoh/internal/channel" +) + +// getFeishuPostContentLines returns content lines from post message. +// Feishu event payload uses root-level content: {"title":"","content":[[...],[...]]}. +func getFeishuPostContentLines(contentMap map[string]any) []any { + if lines, ok := contentMap["content"].([]any); ok { + return lines + } + return nil +} + +// extractFeishuPostAttachments extracts image/file attachments from post content (e.g. img elements). +func extractFeishuPostAttachments(contentMap map[string]any, messageID string) []channel.Attachment { + var result []channel.Attachment + linesRaw := getFeishuPostContentLines(contentMap) + if linesRaw == nil { + return result + } + for _, rawLine := range linesRaw { + line, ok := rawLine.([]any) + if !ok { + continue + } + for _, rawPart := range line { + part, ok := rawPart.(map[string]any) + if !ok { + continue + } + tag := strings.ToLower(strings.TrimSpace(stringValue(part["tag"]))) + if tag == "img" { + if key, ok := part["image_key"].(string); ok && strings.TrimSpace(key) != "" { + mime := strings.TrimSpace(stringValue(part["mime_type"])) + result = append(result, channel.NormalizeInboundChannelAttachment(channel.Attachment{ + Type: channel.AttachmentImage, + PlatformKey: strings.TrimSpace(key), + SourcePlatform: Type.String(), + Mime: mime, + Metadata: map[string]any{"message_id": messageID}, + })) + } + } + if tag == "file" { + if key, ok := part["file_key"].(string); ok && strings.TrimSpace(key) != "" { + name := strings.TrimSpace(stringValue(part["file_name"])) + mime := strings.TrimSpace(stringValue(part["mime_type"])) + result = append(result, channel.NormalizeInboundChannelAttachment(channel.Attachment{ + Type: channel.AttachmentFile, + PlatformKey: strings.TrimSpace(key), + SourcePlatform: Type.String(), + Name: name, + Mime: mime, + Metadata: map[string]any{"message_id": messageID}, + })) + } + } + } + } + return result +} + +func extractFeishuPostText(contentMap map[string]any) string { + linesRaw := getFeishuPostContentLines(contentMap) + if linesRaw == nil { + return "" + } + parts := make([]string, 0, 8) + for _, rawLine := range linesRaw { + line, ok := rawLine.([]any) + if !ok { + continue + } + for _, rawPart := range line { + part, ok := rawPart.(map[string]any) + if !ok { + continue + } + tag := strings.ToLower(strings.TrimSpace(stringValue(part["tag"]))) + switch tag { + case "text", "a": + text := strings.TrimSpace(stringValue(part["text"])) + if text != "" { + parts = append(parts, text) + } + case "at": + name := strings.TrimSpace(stringValue(part["text"])) + if name == "" { + name = strings.TrimSpace(stringValue(part["name"])) + } + if name == "" { + name = strings.TrimSpace(stringValue(part["user_name"])) + } + if name == "" { + parts = append(parts, "@") + continue + } + if !strings.HasPrefix(name, "@") { + name = "@" + name + } + parts = append(parts, name) + default: + text := strings.TrimSpace(stringValue(part["text"])) + if text != "" { + parts = append(parts, text) + } + } + } + } + if len(parts) == 0 { + return "" + } + return strings.Join(parts, " ") +} + +func stringValue(raw any) string { + if raw == nil { + return "" + } + value, ok := raw.(string) + if ok { + return value + } + return fmt.Sprint(raw) +} diff --git a/internal/channel/inbound/channel.go b/internal/channel/inbound/channel.go index b385aed9..e735d226 100644 --- a/internal/channel/inbound/channel.go +++ b/internal/channel/inbound/channel.go @@ -400,18 +400,16 @@ func (p *ChannelInboundProcessor) HandleInbound(ctx context.Context, cfg channel // short-circuit here instead of starting a new stream. if p.dispatcher != nil && !isLocalChannelType(msg.Channel) && inboundMode != ModeParallel { if p.dispatcher.IsActive(routeID) { - headerifiedText := flow.FormatUserHeader( - strings.TrimSpace(msg.Message.ID), - strings.TrimSpace(identity.ChannelIdentityID), - strings.TrimSpace(identity.DisplayName), - msg.Channel.String(), - strings.TrimSpace(msg.Conversation.Type), - strings.TrimSpace(msg.Conversation.Name), - collectAttachmentPaths(attachments), - time.Now().UTC(), - "", - text, - ) + headerifiedText := flow.FormatUserHeader(flow.UserMessageHeaderInput{ + MessageID: strings.TrimSpace(msg.Message.ID), + ChannelIdentityID: strings.TrimSpace(identity.ChannelIdentityID), + DisplayName: strings.TrimSpace(identity.DisplayName), + Channel: msg.Channel.String(), + ConversationType: strings.TrimSpace(msg.Conversation.Type), + ConversationName: strings.TrimSpace(msg.Conversation.Name), + AttachmentPaths: collectAttachmentPaths(attachments), + Time: time.Now().UTC(), + }, text) switch inboundMode { case ModeInject: @@ -958,18 +956,16 @@ func (p *ChannelInboundProcessor) persistPassiveMessage( } } - headerifiedText := flow.FormatUserHeader( - strings.TrimSpace(msg.Message.ID), - strings.TrimSpace(ident.ChannelIdentityID), - strings.TrimSpace(ident.DisplayName), - msg.Channel.String(), - strings.TrimSpace(msg.Conversation.Type), - strings.TrimSpace(msg.Conversation.Name), - attachmentPaths, - time.Now().UTC(), - "", - trimmedText, - ) + headerifiedText := flow.FormatUserHeader(flow.UserMessageHeaderInput{ + MessageID: strings.TrimSpace(msg.Message.ID), + ChannelIdentityID: strings.TrimSpace(ident.ChannelIdentityID), + DisplayName: strings.TrimSpace(ident.DisplayName), + Channel: msg.Channel.String(), + ConversationType: strings.TrimSpace(msg.Conversation.Type), + ConversationName: strings.TrimSpace(msg.Conversation.Name), + AttachmentPaths: attachmentPaths, + Time: time.Now().UTC(), + }, trimmedText) modelMsg := conversation.ModelMessage{Role: "user", Content: conversation.NewTextContent(headerifiedText)} serialized, err := json.Marshal(modelMsg) @@ -2396,6 +2392,12 @@ func buildRouteMetadata(msg channel.InboundMessage, identity InboundIdentity) ma m["sender_username"] = v } } + if mentions, ok := msg.Metadata["mentions"]; ok && mentions != nil { + m["mentions"] = mentions + } + if targets, ok := msg.Metadata["mentioned_targets"]; ok && targets != nil { + m["mentioned_targets"] = targets + } return m } diff --git a/internal/conversation/flow/resolver.go b/internal/conversation/flow/resolver.go index 3d5bdb2c..12b04d35 100644 --- a/internal/conversation/flow/resolver.go +++ b/internal/conversation/flow/resolver.go @@ -221,18 +221,17 @@ func (r *Resolver) resolve(ctx context.Context, req conversation.ChatRequest) (r displayName := r.resolveDisplayName(ctx, req) mergedAttachments := r.routeAndMergeAttachments(ctx, chatModel, req) - headerifiedQuery := FormatUserHeader( - strings.TrimSpace(req.ExternalMessageID), - strings.TrimSpace(req.SourceChannelIdentityID), - displayName, - req.CurrentChannel, - strings.TrimSpace(req.ConversationType), - strings.TrimSpace(req.ConversationName), - extractAttachmentPaths(mergedAttachments), - time.Now().In(userClockLocation), - userTimezoneName, - req.Query, - ) + headerifiedQuery := FormatUserHeader(UserMessageHeaderInput{ + MessageID: strings.TrimSpace(req.ExternalMessageID), + ChannelIdentityID: strings.TrimSpace(req.SourceChannelIdentityID), + DisplayName: displayName, + Channel: req.CurrentChannel, + ConversationType: strings.TrimSpace(req.ConversationType), + ConversationName: strings.TrimSpace(req.ConversationName), + AttachmentPaths: extractAttachmentPaths(mergedAttachments), + Time: time.Now().In(userClockLocation), + Timezone: userTimezoneName, + }, req.Query) inlineImages := extractNativeImageParts(mergedAttachments) reasoningEffort := "" diff --git a/internal/conversation/flow/user_header.go b/internal/conversation/flow/user_header.go index b695444f..08411f0f 100644 --- a/internal/conversation/flow/user_header.go +++ b/internal/conversation/flow/user_header.go @@ -19,27 +19,58 @@ type UserMessageMeta struct { AttachmentPaths []string `json:"attachments"` } -// BuildUserMessageMeta constructs a UserMessageMeta from the inbound parameters. -func BuildUserMessageMeta(messageID, channelIdentityID, displayName, channel, conversationType, conversationName string, attachmentPaths []string) UserMessageMeta { +// UserMessageHeaderInput is the unified input for building user message headers. +// Keeping this as a struct avoids long positional argument lists and makes +// future metadata extension backward-compatible for call sites. +type UserMessageHeaderInput struct { + MessageID string + ChannelIdentityID string + DisplayName string + Channel string + ConversationType string + ConversationName string + AttachmentPaths []string + Time time.Time + Timezone string +} + +// BuildUserMessageMetaFromInput constructs metadata from one cohesive input. +func BuildUserMessageMetaFromInput(input UserMessageHeaderInput) UserMessageMeta { + attachmentPaths := input.AttachmentPaths if attachmentPaths == nil { attachmentPaths = []string{} } - return UserMessageMeta{ + meta := UserMessageMeta{ + MessageID: input.MessageID, + ChannelIdentityID: input.ChannelIdentityID, + DisplayName: input.DisplayName, + Channel: input.Channel, + ConversationType: input.ConversationType, + ConversationName: input.ConversationName, + Time: time.Now().UTC().Format(time.RFC3339), + Timezone: strings.TrimSpace(input.Timezone), + AttachmentPaths: attachmentPaths, + } + if !input.Time.IsZero() { + meta.Time = input.Time.Format(time.RFC3339) + } + return meta +} + +// BuildUserMessageMetaWithTime constructs metadata with an explicit timestamp +// and timezone label for user-facing prompts. +func BuildUserMessageMetaWithTime(messageID, channelIdentityID, displayName, channel, conversationType, conversationName string, attachmentPaths []string, now time.Time, timezone string) UserMessageMeta { + meta := BuildUserMessageMetaFromInput(UserMessageHeaderInput{ MessageID: messageID, ChannelIdentityID: channelIdentityID, DisplayName: displayName, Channel: channel, ConversationType: conversationType, ConversationName: conversationName, - Time: time.Now().UTC().Format(time.RFC3339), AttachmentPaths: attachmentPaths, - } -} - -// BuildUserMessageMetaWithTime constructs metadata with an explicit timestamp -// and timezone label for user-facing prompts. -func BuildUserMessageMetaWithTime(messageID, channelIdentityID, displayName, channel, conversationType, conversationName string, attachmentPaths []string, now time.Time, timezone string) UserMessageMeta { - meta := BuildUserMessageMeta(messageID, channelIdentityID, displayName, channel, conversationType, conversationName, attachmentPaths) + Time: now, + Timezone: timezone, + }) if !now.IsZero() { meta.Time = now.Format(time.RFC3339) } @@ -74,8 +105,8 @@ func (m UserMessageMeta) ToMap() map[string]any { // the LLM sees structured context (sender, channel, time, attachments) // alongside the raw message. This must be the single source of truth for // user-message formatting — the agent gateway must NOT add its own header. -func FormatUserHeader(messageID, channelIdentityID, displayName, channel, conversationType, conversationName string, attachmentPaths []string, now time.Time, timezone, query string) string { - meta := BuildUserMessageMetaWithTime(messageID, channelIdentityID, displayName, channel, conversationType, conversationName, attachmentPaths, now, timezone) +func FormatUserHeader(input UserMessageHeaderInput, query string) string { + meta := BuildUserMessageMetaFromInput(input) return FormatUserHeaderFromMeta(meta, query) } diff --git a/internal/conversation/flow/user_header_test.go b/internal/conversation/flow/user_header_test.go new file mode 100644 index 00000000..326d6f21 --- /dev/null +++ b/internal/conversation/flow/user_header_test.go @@ -0,0 +1,45 @@ +package flow + +import ( + "strings" + "testing" + "time" +) + +func TestFormatUserHeaderIncludesAttachments(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 4, 6, 10, 0, 0, 0, time.UTC) + header := FormatUserHeader(UserMessageHeaderInput{ + MessageID: "msg_1", + ChannelIdentityID: "cid_1", + DisplayName: "Alice", + Channel: "feishu", + ConversationType: "group", + ConversationName: "Team Chat", + AttachmentPaths: []string{"/tmp/a.txt"}, + Time: now, + Timezone: "UTC", + }, "hello") + + if !strings.Contains(header, "attachments:\n - /tmp/a.txt\n") { + t.Fatalf("expected attachment path in header: %s", header) + } +} + +func TestFormatUserHeaderWithoutAttachmentsUsesEmptyList(t *testing.T) { + t.Parallel() + + header := FormatUserHeader(UserMessageHeaderInput{ + ChannelIdentityID: "cid_1", + DisplayName: "Alice", + Channel: "feishu", + ConversationType: "group", + ConversationName: "Team Chat", + Time: time.Now().UTC(), + }, "hello") + + if !strings.Contains(header, "attachments: []\n") { + t.Fatalf("expected empty attachments list in header: %s", header) + } +} diff --git a/internal/conversation/types.go b/internal/conversation/types.go index 03b75ed1..ea2117b0 100644 --- a/internal/conversation/types.go +++ b/internal/conversation/types.go @@ -236,6 +236,8 @@ type ChatRequest struct { ConversationType string `json:"-"` ConversationName string `json:"-"` UserMessagePersisted bool `json:"-"` + EventID string `json:"-"` + RawQuery string `json:"-"` // OutboundAssetCollector returns asset refs accumulated during outbound streaming. // Set by the inbound channel processor; called by the resolver at persist time.