mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-25 07:00:48 +09:00
feat(feishu): keep mention(at) target
This commit is contained in:
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 := ""
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user