fix(feishu): enrich reply context for card and file replies (#2144)

* fix(feishu): enrich reply context for card and file replies

* refactor(feishu): extract reply functions to feishu_reply.go

- Move reply-related functions to new feishu_reply.go
- Move corresponding tests to feishu_reply_test.go
- Extract magic number 600 to maxReplyContextLen constant
- Unify replyTargetID/replyTargetFromMessage (prefer parent_id, fallback root_id)
- Add source comment for containsFeishuUpgradePlaceholder

* fix(feishu): skip API fallback for non-thread messages, prepend replied media refs

- resolveReplyTargetMessageID: only call fetchMessageByID fallback when
  ThreadId is set, avoiding unnecessary API calls for non-reply messages
- prependReplyContext: prepend replied media refs before current media refs
  to maintain correct ordering

* fix(feishu): add message cache for fetchMessageByID to avoid repeated downloads

- Add messageCache (sync.Map) to FeishuChannel struct
- Cache fetched messages with 30s TTL to avoid re-downloading attachments
  when multiple users reply to the same parent message in a thread
- Cleanup expired entries on read access (no background goroutine needed)

* fix(feishu): early-return for non-reply messages, add cache and fetchMessageByID comment

* fix: remove duplicate test and fix gci import order

* fix(feishu): remove duplicate prependReplyContext call
This commit is contained in:
ywj
2026-04-08 14:26:17 +08:00
committed by GitHub
parent 7d16764674
commit 8b3e502690
3 changed files with 549 additions and 18 deletions
+22 -18
View File
@@ -14,6 +14,7 @@ import (
"strings"
"sync"
"sync/atomic"
"time"
lark "github.com/larksuite/oapi-sdk-go/v3"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
@@ -42,12 +43,18 @@ type FeishuChannel struct {
wsClient *larkws.Client
tokenCache *tokenCache // custom cache that supports invalidation
botOpenID atomic.Value // stores string; populated lazily for @mention detection
botOpenID atomic.Value // stores string; populated lazily for @mention detection
messageCache sync.Map // caches fetched messages (messageID -> *larkim.Message)
mu sync.Mutex
cancel context.CancelFunc
}
type cachedMessage struct {
msg *larkim.Message
expiry time.Time
}
func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChannel, error) {
base := channels.NewBaseChannel("feishu", cfg, bus, cfg.AllowFrom,
channels.WithGroupTrigger(cfg.GroupTrigger),
@@ -436,24 +443,8 @@ func (c *FeishuChannel) handleMessageReceive(ctx context.Context, event *larkim.
// Append media tags to content (like Telegram does)
content = appendMediaTags(content, messageType, mediaRefs)
if content == "" {
content = "[empty message]"
}
metadata := map[string]string{}
if messageID != "" {
metadata["message_id"] = messageID
}
if messageType != "" {
metadata["message_type"] = messageType
}
chatType := stringValue(message.ChatType)
if chatType != "" {
metadata["chat_type"] = chatType
}
if sender != nil && sender.TenantKey != nil {
metadata["tenant_key"] = *sender.TenantKey
}
metadata := buildInboundMetadata(message, sender)
var peer bus.Peer
if chatType == "p2p" {
@@ -477,12 +468,25 @@ func (c *FeishuChannel) handleMessageReceive(ctx context.Context, event *larkim.
content = cleaned
}
if replyTargetID(message) != "" || stringValue(message.ThreadId) != "" {
content, mediaRefs = c.prependReplyContext(ctx, message, chatID, content, mediaRefs)
}
if content == "" {
content = "[empty message]"
}
logger.InfoCF("feishu", "Feishu message received", map[string]any{
"sender_id": senderID,
"chat_id": chatID,
"message_id": messageID,
"preview": utils.Truncate(content, 80),
})
logger.InfoCF("feishu", "Feishu reply linkage", map[string]any{
"message_id": messageID,
"parent_id": stringValue(message.ParentId),
"root_id": stringValue(message.RootId),
"thread_id": stringValue(message.ThreadId),
})
c.HandleMessage(ctx, peer, messageID, senderID, chatID, content, mediaRefs, metadata, senderInfo)
return nil
+298
View File
@@ -0,0 +1,298 @@
//go:build amd64 || arm64 || riscv64 || mips64 || ppc64
package feishu
import (
"context"
"fmt"
"strings"
"time"
larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/utils"
)
const messageCacheTTL = 30 * time.Second
const (
maxReplyContextLen = 600
)
func (c *FeishuChannel) prependReplyContext(
ctx context.Context,
message *larkim.EventMessage,
chatID string,
content string,
mediaRefs []string,
) (string, []string) {
if message == nil {
return content, mediaRefs
}
lookupCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
targetMessageID := c.resolveReplyTargetMessageID(lookupCtx, message)
if targetMessageID == "" {
logger.DebugCF("feishu", "No reply target resolved; skip reply context", map[string]any{
"message_id": stringValue(message.MessageId),
"parent_id": stringValue(message.ParentId),
"root_id": stringValue(message.RootId),
"thread_id": stringValue(message.ThreadId),
})
return content, mediaRefs
}
repliedMessage, err := c.fetchMessageByID(lookupCtx, targetMessageID)
if err != nil {
logger.DebugCF("feishu", "Failed to fetch replied message context", map[string]any{
"target_message_id": targetMessageID,
"error": err.Error(),
})
return content, mediaRefs
}
messageType := stringValue(repliedMessage.MsgType)
rawContent := ""
if repliedMessage.Body != nil {
rawContent = stringValue(repliedMessage.Body.Content)
}
var repliedMediaRefs []string
if store := c.GetMediaStore(); store != nil {
repliedMediaRefs = c.downloadInboundMedia(lookupCtx, chatID, targetMessageID, messageType, rawContent, store)
if messageType == larkim.MsgTypeInteractive {
_, externalURLs := extractCardImageKeys(rawContent)
if len(externalURLs) > 0 {
repliedMediaRefs = append(repliedMediaRefs, externalURLs...)
}
}
}
repliedContent := normalizeRepliedContent(messageType, rawContent, repliedMediaRefs)
if len(repliedMediaRefs) > 0 {
mediaRefs = append(repliedMediaRefs, mediaRefs...)
}
return formatReplyContext(targetMessageID, repliedContent, content), mediaRefs
}
func (c *FeishuChannel) resolveReplyTargetMessageID(ctx context.Context, message *larkim.EventMessage) string {
if targetID := replyTargetID(message); targetID != "" {
logger.DebugCF("feishu", "Resolved reply target from event payload", map[string]any{
"message_id": stringValue(message.MessageId),
"parent_id": stringValue(message.ParentId),
"root_id": stringValue(message.RootId),
"target_id": targetID,
})
return targetID
}
currentMessageID := stringValue(message.MessageId)
if currentMessageID == "" {
return ""
}
if stringValue(message.ThreadId) == "" {
logger.DebugCF("feishu", "No reply target found; message is not in a thread", map[string]any{
"message_id": stringValue(message.MessageId),
})
return ""
}
msg, err := c.fetchMessageByID(ctx, currentMessageID)
if err != nil {
logger.DebugCF("feishu", "Failed to query current message detail for reply info", map[string]any{
"message_id": currentMessageID,
"error": err.Error(),
})
return ""
}
targetID := replyTargetIDFromMessage(msg)
if targetID != "" {
logger.DebugCF("feishu", "Resolved reply target from message detail", map[string]any{
"message_id": currentMessageID,
"parent_id": stringValue(msg.ParentId),
"root_id": stringValue(msg.RootId),
"target_id": targetID,
})
}
return targetID
}
func (c *FeishuChannel) fetchMessageByID(ctx context.Context, messageID string) (*larkim.Message, error) {
if cached, ok := c.messageCache.Load(messageID); ok {
cm := cached.(*cachedMessage)
if time.Now().Before(cm.expiry) {
return cm.msg, nil
}
c.messageCache.Delete(messageID)
}
req := larkim.NewGetMessageReqBuilder().
MessageId(messageID).
Build()
resp, err := c.client.Im.V1.Message.Get(ctx, req)
if err != nil {
return nil, fmt.Errorf("feishu get message: %w", err)
}
if !resp.Success() {
c.invalidateTokenOnAuthError(resp.Code)
return nil, fmt.Errorf("feishu get message api error (code=%d msg=%s)", resp.Code, resp.Msg)
}
if resp.Data == nil || len(resp.Data.Items) == 0 || resp.Data.Items[0] == nil {
return nil, fmt.Errorf("feishu get message: empty response")
}
// Items[0] contains the target message - the Feishu API returns a list
// but we request a single message by ID, so the list always has at most one item.
msg := resp.Data.Items[0]
c.messageCache.Store(messageID, &cachedMessage{msg: msg, expiry: time.Now().Add(messageCacheTTL)})
return msg, nil
}
func replyTargetID(message *larkim.EventMessage) string {
if message == nil {
return ""
}
if parentID := stringValue(message.ParentId); parentID != "" {
return parentID
}
return stringValue(message.RootId)
}
func replyTargetIDFromMessage(message *larkim.Message) string {
if message == nil {
return ""
}
if parentID := stringValue(message.ParentId); parentID != "" {
return parentID
}
return stringValue(message.RootId)
}
func buildInboundMetadata(message *larkim.EventMessage, sender *larkim.EventSender) map[string]string {
metadata := map[string]string{}
if message == nil {
return metadata
}
messageID := stringValue(message.MessageId)
if messageID != "" {
metadata["message_id"] = messageID
}
messageType := stringValue(message.MessageType)
if messageType != "" {
metadata["message_type"] = messageType
}
chatType := stringValue(message.ChatType)
if chatType != "" {
metadata["chat_type"] = chatType
}
parentID := stringValue(message.ParentId)
if parentID != "" {
metadata["parent_id"] = parentID
}
rootID := stringValue(message.RootId)
if rootID != "" {
metadata["root_id"] = rootID
}
if replyTo := replyTargetID(message); replyTo != "" {
metadata["reply_to_message_id"] = replyTo
}
threadID := stringValue(message.ThreadId)
if threadID != "" {
metadata["thread_id"] = threadID
}
if sender != nil && sender.TenantKey != nil && *sender.TenantKey != "" {
metadata["tenant_key"] = *sender.TenantKey
}
return metadata
}
func normalizeRepliedContent(messageType, rawContent string, mediaRefs []string) string {
content := extractContent(messageType, rawContent)
if containsFeishuUpgradePlaceholder(rawContent) || containsFeishuUpgradePlaceholder(content) {
content = ""
}
content = appendMediaTags(content, messageType, mediaRefs)
if strings.TrimSpace(content) != "" {
return content
}
switch messageType {
case larkim.MsgTypeImage:
return "[replied image]"
case larkim.MsgTypeFile:
return "[replied file]"
case larkim.MsgTypeAudio:
return "[replied audio]"
case larkim.MsgTypeMedia:
return "[replied video]"
case larkim.MsgTypeInteractive:
return "[replied interactive card]"
default:
return "[replied message content unavailable]"
}
}
func containsFeishuUpgradePlaceholder(s string) bool {
upgradePrompt := "\u8bf7\u5347\u7ea7\u81f3\u6700\u65b0\u7248\u672c\u5ba2\u6237\u7aef"
upgradePromptEscaped := "\\u8bf7\\u5347\\u7ea7\\u81f3\\u6700\\u65b0\\u7248\\u672c\\u5ba2\\u6237\\u7aef"
return strings.Contains(s, upgradePrompt) || strings.Contains(s, upgradePromptEscaped)
}
func formatReplyContext(parentID, repliedContent, content string) string {
parentID = strings.TrimSpace(parentID)
repliedContent = strings.TrimSpace(repliedContent)
content = strings.TrimSpace(content)
if parentID == "" || repliedContent == "" {
return content
}
repliedContent = utils.Truncate(repliedContent, maxReplyContextLen)
repliedContent = sanitizeReplyContextContent(repliedContent)
content = sanitizeReplyContextContent(content)
header := fmt.Sprintf("[replied_message id=%q]", parentID)
footer := "[/replied_message]"
if content == "" {
return header + "\n" + repliedContent + "\n" + footer
}
if hasLeadingCommandPrefix(content) {
return content + "\n\n" + header + "\n" + repliedContent + "\n" + footer
}
return header + "\n" + repliedContent + "\n" + footer + "\n\n[current_message]\n" + content + "\n[/current_message]"
}
func hasLeadingCommandPrefix(s string) bool {
tokens := strings.Fields(strings.TrimSpace(s))
if len(tokens) == 0 {
return false
}
first := tokens[0]
return strings.HasPrefix(first, "/") || strings.HasPrefix(first, "!")
}
func sanitizeReplyContextContent(s string) string {
tagEscaper := strings.NewReplacer(
"[replied_message", `\[replied_message`,
"[/replied_message]", `\[/replied_message]`,
"[current_message]", `\[current_message]`,
"[/current_message]", `\[/current_message]`,
)
return tagEscaper.Replace(s)
}
+229
View File
@@ -0,0 +1,229 @@
//go:build amd64 || arm64 || riscv64 || mips64 || ppc64
package feishu
import (
"strings"
"testing"
larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
)
func TestBuildInboundMetadata(t *testing.T) {
strPtr := func(s string) *string { return &s }
t.Run("includes basic and reply fields", func(t *testing.T) {
message := &larkim.EventMessage{
MessageId: strPtr("om_msg_1"),
MessageType: strPtr("text"),
ChatType: strPtr("group"),
ParentId: strPtr("om_parent_1"),
RootId: strPtr("om_root_1"),
ThreadId: strPtr("omt_thread_1"),
}
sender := &larkim.EventSender{TenantKey: strPtr("tenant_x")}
got := buildInboundMetadata(message, sender)
if got["message_id"] != "om_msg_1" {
t.Fatalf("message_id = %q, want %q", got["message_id"], "om_msg_1")
}
if got["message_type"] != "text" {
t.Fatalf("message_type = %q, want %q", got["message_type"], "text")
}
if got["chat_type"] != "group" {
t.Fatalf("chat_type = %q, want %q", got["chat_type"], "group")
}
if got["parent_id"] != "om_parent_1" {
t.Fatalf("parent_id = %q, want %q", got["parent_id"], "om_parent_1")
}
if got["reply_to_message_id"] != "om_parent_1" {
t.Fatalf("reply_to_message_id = %q, want %q", got["reply_to_message_id"], "om_parent_1")
}
if got["root_id"] != "om_root_1" {
t.Fatalf("root_id = %q, want %q", got["root_id"], "om_root_1")
}
if got["thread_id"] != "omt_thread_1" {
t.Fatalf("thread_id = %q, want %q", got["thread_id"], "omt_thread_1")
}
if got["tenant_key"] != "tenant_x" {
t.Fatalf("tenant_key = %q, want %q", got["tenant_key"], "tenant_x")
}
})
t.Run("falls back reply_to_message_id to root_id", func(t *testing.T) {
message := &larkim.EventMessage{
MessageId: strPtr("om_msg_3"),
RootId: strPtr("om_root_3"),
}
got := buildInboundMetadata(message, nil)
if got["root_id"] != "om_root_3" {
t.Fatalf("root_id = %q, want %q", got["root_id"], "om_root_3")
}
if got["reply_to_message_id"] != "om_root_3" {
t.Fatalf("reply_to_message_id = %q, want %q", got["reply_to_message_id"], "om_root_3")
}
})
t.Run("omits empty values", func(t *testing.T) {
message := &larkim.EventMessage{
MessageId: strPtr("om_msg_2"),
}
got := buildInboundMetadata(message, nil)
if got["message_id"] != "om_msg_2" {
t.Fatalf("message_id = %q, want %q", got["message_id"], "om_msg_2")
}
if _, ok := got["parent_id"]; ok {
t.Fatalf("parent_id should be absent, got %q", got["parent_id"])
}
if _, ok := got["reply_to_message_id"]; ok {
t.Fatalf("reply_to_message_id should be absent, got %q", got["reply_to_message_id"])
}
if _, ok := got["tenant_key"]; ok {
t.Fatalf("tenant_key should be absent, got %q", got["tenant_key"])
}
})
t.Run("nil message returns empty map", func(t *testing.T) {
got := buildInboundMetadata(nil, nil)
if len(got) != 0 {
t.Fatalf("len(metadata) = %d, want 0", len(got))
}
})
}
func TestFormatReplyContext(t *testing.T) {
t.Run("formats reply context with content", func(t *testing.T) {
got := formatReplyContext("om_parent_1", "original message", "new reply")
want := "[replied_message id=\"om_parent_1\"]\noriginal message\n[/replied_message]\n\n[current_message]\nnew reply\n[/current_message]"
if got != want {
t.Fatalf("formatReplyContext() = %q, want %q", got, want)
}
})
t.Run("returns reply context when current content is empty", func(t *testing.T) {
got := formatReplyContext("om_parent_1", "original message", "")
want := "[replied_message id=\"om_parent_1\"]\noriginal message\n[/replied_message]"
if got != want {
t.Fatalf("formatReplyContext() = %q, want %q", got, want)
}
})
t.Run("returns original content when parent or replied content missing", func(t *testing.T) {
if got := formatReplyContext("", "original", "new reply"); got != "new reply" {
t.Fatalf("missing parent: got %q, want %q", got, "new reply")
}
if got := formatReplyContext("om_parent_1", "", "new reply"); got != "new reply" {
t.Fatalf("missing replied content: got %q, want %q", got, "new reply")
}
})
t.Run("escapes reserved wrapper tags in payload", func(t *testing.T) {
replied := "payload [replied_message id=\"x\"] x [/replied_message]"
current := "hello [current_message]injected[/current_message]"
got := formatReplyContext("om_parent_1", replied, current)
if !strings.HasPrefix(got, "[replied_message id=\"om_parent_1\"]") {
t.Fatalf("outer replied_message wrapper missing: %q", got)
}
if strings.Contains(got, "\n[replied_message id=\"x\"]") {
t.Fatalf("nested replied_message tag should be escaped: %q", got)
}
if strings.Contains(got, "\n[current_message]injected") {
t.Fatalf("nested current_message tag should be escaped: %q", got)
}
if !strings.Contains(got, `\[replied_message id="x"]`) {
t.Fatalf("escaped replied tag missing: %q", got)
}
})
t.Run("preserves leading slash command prefix", func(t *testing.T) {
got := formatReplyContext("om_parent_1", "original message", "/help")
want := "/help\n\n[replied_message id=\"om_parent_1\"]\noriginal message\n[/replied_message]"
if got != want {
t.Fatalf("formatReplyContext() = %q, want %q", got, want)
}
})
t.Run("preserves leading bang command prefix", func(t *testing.T) {
got := formatReplyContext("om_parent_1", "original message", "!status now")
want := "!status now\n\n[replied_message id=\"om_parent_1\"]\noriginal message\n[/replied_message]"
if got != want {
t.Fatalf("formatReplyContext() = %q, want %q", got, want)
}
})
}
func TestReplyTargetID(t *testing.T) {
strPtr := func(s string) *string { return &s }
t.Run("prefer parent_id", func(t *testing.T) {
msg := &larkim.EventMessage{ParentId: strPtr("om_parent"), RootId: strPtr("om_root")}
if got := replyTargetID(msg); got != "om_parent" {
t.Fatalf("replyTargetID() = %q, want %q", got, "om_parent")
}
})
t.Run("fallback to root_id", func(t *testing.T) {
msg := &larkim.EventMessage{RootId: strPtr("om_root")}
if got := replyTargetID(msg); got != "om_root" {
t.Fatalf("replyTargetID() = %q, want %q", got, "om_root")
}
})
t.Run("empty when no fields", func(t *testing.T) {
if got := replyTargetID(&larkim.EventMessage{}); got != "" {
t.Fatalf("replyTargetID() = %q, want empty", got)
}
})
}
func TestNormalizeRepliedContent(t *testing.T) {
t.Run("filters feishu upgrade placeholder for interactive", func(t *testing.T) {
raw := `{"text":"\u8bf7\u5347\u7ea7\u81f3\u6700\u65b0\u7248\u672c\u5ba2\u6237\u7aef\uff0c\u4ee5\u67e5\u770b\u5185\u5bb9"}`
got := normalizeRepliedContent("interactive", raw, nil)
if got != "[replied interactive card]" {
t.Fatalf("normalizeRepliedContent() = %q, want %q", got, "[replied interactive card]")
}
})
t.Run("keeps filename and file tag for replied file", func(t *testing.T) {
got := normalizeRepliedContent("file", `{"file_key":"file_xxx","file_name":"doc.pdf"}`, []string{"media://r1"})
if got != "doc.pdf [file]" {
t.Fatalf("normalizeRepliedContent() = %q, want %q", got, "doc.pdf [file]")
}
})
t.Run("falls back when file content missing", func(t *testing.T) {
got := normalizeRepliedContent("file", `{"file_key":"file_xxx"}`, nil)
if got != "[replied file]" {
t.Fatalf("normalizeRepliedContent() = %q, want %q", got, "[replied file]")
}
})
}
func TestHasLeadingCommandPrefix(t *testing.T) {
tests := []struct {
name string
input string
want bool
}{
{name: "slash command", input: "/help", want: true},
{name: "bang command", input: "!status", want: true},
{name: "leading spaces slash", input: " /ping arg", want: true},
{name: "normal text", input: "hello /help", want: false},
{name: "empty", input: "", want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := hasLeadingCommandPrefix(tt.input); got != tt.want {
t.Fatalf("hasLeadingCommandPrefix(%q) = %v, want %v", tt.input, got, tt.want)
}
})
}
}