mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
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:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user