feat(feishu): keep mention(at) target

This commit is contained in:
晨苒
2026-04-06 06:18:03 +08:00
parent aa39ea3357
commit 830c521f11
10 changed files with 561 additions and 279 deletions
@@ -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 ""
@@ -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()
+14 -223
View File
@@ -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, "<at ") || strings.Contains(normalized, "</at>") {
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)
}
@@ -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, "<at ") || strings.Contains(normalized, "</at>") {
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
}
@@ -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)
}
+26 -24
View File
@@ -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
}
+11 -12
View File
@@ -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 := ""
+44 -13
View File
@@ -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)
}
@@ -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)
}
}
+2
View File
@@ -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.