diff --git a/internal/channels/feishu/bot.go b/internal/channels/feishu/bot.go index 87ce4b0a0..0b7964b6d 100644 --- a/internal/channels/feishu/bot.go +++ b/internal/channels/feishu/bot.go @@ -119,8 +119,15 @@ func (c *Channel) handleMessageEvent(ctx context.Context, event *MessageEvent) { // 8. Topic session chatID := mc.ChatID + var threadHistory string if mc.RootID != "" && c.cfg.TopicSessionMode == "enabled" { chatID = fmt.Sprintf("%s:topic:%s", mc.ChatID, mc.RootID) + // Fetch full thread history for better context + if history, err := c.fetchFeishuThreadHistory(ctx, mc.RootID); err != nil { + slog.Warn("feishu fetch thread history failed", "root_id", mc.RootID, "error", err) + } else if history != "" { + threadHistory = history + } } slog.Debug("feishu message received", @@ -155,19 +162,21 @@ func (c *Channel) handleMessageEvent(ctx context.Context, event *MessageEvent) { metadata["sender_open_id"] = sender.SenderID.OpenID } - // Annotate content with sender identity so the agent knows who is messaging. - if senderName != "" { - if mc.ChatType == "group" { - annotated := fmt.Sprintf("[From: %s]\n%s", senderName, content) - if c.historyLimit > 0 { - content = c.groupHistory.BuildContext(chatID, annotated, c.historyLimit) - } else { - content = annotated - } + // Build final content with group context (pending history + thread history + sender annotation). + if mc.ChatType == "group" && senderName != "" { + annotated := fmt.Sprintf("[From: %s]\n%s", senderName, content) + if threadHistory != "" { + // Prepend thread history if available (Topic session mode) + content = fmt.Sprintf("--- Topic Thread History ---\n%s\n---------------------------\n%s", threadHistory, annotated) + } else if c.historyLimit > 0 { + // Regular group history context + content = c.groupHistory.BuildContext(chatID, annotated, c.historyLimit) } else { - // DM: annotate with sender identity so the agent knows who is messaging. - content = fmt.Sprintf("[From: %s]\n%s", senderName, content) + content = annotated } + } else if mc.ChatType == "p2p" && senderName != "" { + // DM: annotate with sender identity so the agent knows who is messaging. + content = fmt.Sprintf("[From: %s]\n%s", senderName, content) } // 10. Resolve inbound media (image, file, audio, video, sticker) @@ -260,7 +269,6 @@ func (c *Channel) handleMessageEvent(ctx context.Context, event *MessageEvent) { Metadata: metadata, }) - // Clear pending history after sending to agent. if mc.ChatType == "group" { c.groupHistory.Clear(chatID) } @@ -276,20 +284,19 @@ func (c *Channel) fetchReplyContext(ctx context.Context, parentID string) string slog.Debug("feishu: failed to fetch parent message", "parent_id", parentID, "error", err) return "" } - if len(resp.Items) == 0 { + if resp == nil { return "" } - item := &resp.Items[0] - body := parseMessageContent(item.Body.Content, item.MsgType) + body := parseMessageContent(resp.Body.Content, resp.MsgType) if body == "" { return "" } // Resolve sender name senderName := "unknown" - if item.Sender.ID != "" { - if name := c.resolveSenderName(ctx, item.Sender.ID); name != "" { + if resp.Sender.ID != "" { + if name := c.resolveSenderName(ctx, resp.Sender.ID); name != "" { senderName = name } } @@ -297,3 +304,36 @@ func (c *Channel) fetchReplyContext(ctx context.Context, parentID string) string body = channels.Truncate(body, replyContextMaxLen) return fmt.Sprintf("[Replying to %s]\n%s\n[/Replying]", senderName, body) } +// fetchFeishuThreadHistory retrieves all messages in a thread starting from rootID. +func (c *Channel) fetchFeishuThreadHistory(ctx context.Context, rootID string) (string, error) { + var threadID string + + // 1. Check cache for thread_id (omt_xxx) + if val, ok := c.threadIDCache.Load(rootID); ok { + threadID = val.(string) + } else { + // Get the root message to find the thread_id + rootMsg, err := c.client.GetMessage(ctx, rootID) + if err != nil { + return "", err + } + threadID = rootMsg.ThreadID + if threadID != "" { + c.threadIDCache.Store(rootID, threadID) + } + } + + if threadID == "" { + // Fallback: if there's no thread_id, we can't fetch the whole thread efficiently + return "", nil + } + + // 2. List all messages in the thread + items, _, err := c.client.ListMessages(ctx, "thread", threadID, 20, "") + if err != nil { + return "", err + } + + // 3. Convert to readable text + return c.buildThreadHistoryContent(ctx, items), nil +} diff --git a/internal/channels/feishu/bot_parse.go b/internal/channels/feishu/bot_parse.go index 92c4610a0..969b6f10d 100644 --- a/internal/channels/feishu/bot_parse.go +++ b/internal/channels/feishu/bot_parse.go @@ -1,6 +1,7 @@ package feishu import ( + "context" "encoding/json" "fmt" "strings" @@ -196,3 +197,52 @@ func resolveMentions(text string, mentions []mentionInfo, botOpenID string) stri } return strings.TrimSpace(text) } + +// buildThreadHistoryContent converts a list of MessageItem to a readable history block. +func (c *Channel) buildThreadHistoryContent(ctx context.Context, items []MessageItem) string { + if len(items) == 0 { + return "" + } + var lines []string + for _, item := range items { + if item.Deleted { + continue + } + + // Resolve sender name + senderName := "" + if item.Sender.SenderType == "app" { + senderName = "Bot" + } else { + senderName = c.resolveSenderName(ctx, item.Sender.ID) + if senderName == "" { + senderName = item.Sender.ID // Fallback to raw ID if resolution fails + } + } + + content := parseMessageContent(item.Body.Content, item.MsgType) + if content != "" { + // Resolve mentions in history content + var mentions []mentionInfo + for _, m := range item.Mentions { + name := "" + if c.botOpenID != "" && m.ID == c.botOpenID { + // Bot mention - resolveMentions will strip it if we pass the ID + } else { + name = c.resolveSenderName(ctx, m.ID) + } + mentions = append(mentions, mentionInfo{ + Key: m.Key, + OpenID: m.ID, + Name: name, + }) + } + content = resolveMentions(content, mentions, c.botOpenID) + + if content != "" { + lines = append(lines, fmt.Sprintf("[%s]: %s", senderName, content)) + } + } + } + return strings.Join(lines, "\n") +} diff --git a/internal/channels/feishu/feishu.go b/internal/channels/feishu/feishu.go index 1c5e2d767..ca10d5b38 100644 --- a/internal/channels/feishu/feishu.go +++ b/internal/channels/feishu/feishu.go @@ -44,6 +44,7 @@ type Channel struct { groupAllowList []string groupHistory *channels.PendingHistory historyLimit int + threadIDCache sync.Map // root_id (om_xxx) → thread_id (omt_xxx) stopCh chan struct{} httpServer *http.Server wsClient *WSClient diff --git a/internal/channels/feishu/larkclient_messaging.go b/internal/channels/feishu/larkclient_messaging.go index f5ecf3009..e3d72ee8a 100644 --- a/internal/channels/feishu/larkclient_messaging.go +++ b/internal/channels/feishu/larkclient_messaging.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "log/slog" + "net/url" "strconv" ) @@ -31,11 +32,38 @@ func (c *LarkClient) SendMessage(ctx context.Context, receiveIDType, receiveID, } var data SendMessageResp if err := json.Unmarshal(resp.Data, &data); err != nil { - return nil, fmt.Errorf("unmarshal response: %w", err) + return nil, fmt.Errorf("unmarshal SendMessageResp: %w", err) } return &data, nil } +type MessageItem struct { + MessageID string `json:"message_id"` + RootID string `json:"root_id"` + ParentID string `json:"parent_id"` + ThreadID string `json:"thread_id"` + MsgType string `json:"msg_type"` + CreateTime string `json:"create_time"` + UpdateTime string `json:"update_time"` + Deleted bool `json:"deleted"` + Updated bool `json:"updated"` + ChatID string `json:"chat_id"` + Sender struct { + ID string `json:"id"` + IDType string `json:"id_type"` + SenderType string `json:"sender_type"` + TenantKey string `json:"tenant_key"` + } `json:"sender"` + Body struct { + Content string `json:"content"` + } `json:"body"` + Mentions []struct { + Key string `json:"key"` + ID string `json:"id"` + } `json:"mentions"` +} + + // --- IM API: Images --- func (c *LarkClient) DownloadImage(ctx context.Context, imageKey string) ([]byte, error) { @@ -58,7 +86,7 @@ func (c *LarkClient) UploadImage(ctx context.Context, data io.Reader) (string, e ImageKey string `json:"image_key"` } if err := json.Unmarshal(resp.Data, &result); err != nil { - return "", fmt.Errorf("unmarshal response: %w", err) + return "", fmt.Errorf("unmarshal UploadImage response: %w", err) } return result.ImageKey, nil } @@ -84,33 +112,17 @@ func (c *LarkClient) UploadFile(ctx context.Context, data io.Reader, fileName, f FileKey string `json:"file_key"` } if err := json.Unmarshal(resp.Data, &result); err != nil { - return "", fmt.Errorf("unmarshal response: %w", err) + return "", fmt.Errorf("unmarshal UploadFile response: %w", err) } return result.FileKey, nil } // --- IM API: Get Message --- -// GetMessageResp holds the response from GET /open-apis/im/v1/messages/{message_id}. -type GetMessageResp struct { - Items []struct { - MessageID string `json:"message_id"` - MsgType string `json:"msg_type"` - Body struct { - Content string `json:"content"` - } `json:"body"` - Sender struct { - ID string `json:"id"` - IDType string `json:"id_type"` - SenderType string `json:"sender_type"` - } `json:"sender"` - } `json:"items"` -} - // GetMessage retrieves a message by ID. // Lark API: GET /open-apis/im/v1/messages/{message_id} -func (c *LarkClient) GetMessage(ctx context.Context, messageID string) (*GetMessageResp, error) { - path := fmt.Sprintf("/open-apis/im/v1/messages/%s", messageID) +func (c *LarkClient) GetMessage(ctx context.Context, messageID string) (*MessageItem, error) { + path := "/open-apis/im/v1/messages/" + messageID resp, err := c.doJSON(ctx, "GET", path, nil) if err != nil { return nil, err @@ -118,13 +130,49 @@ func (c *LarkClient) GetMessage(ctx context.Context, messageID string) (*GetMess if resp.Code != 0 { return nil, fmt.Errorf("get message: code=%d msg=%s", resp.Code, resp.Msg) } - var data GetMessageResp + var data struct { + Items []MessageItem `json:"items"` + } if err := json.Unmarshal(resp.Data, &data); err != nil { - return nil, fmt.Errorf("unmarshal get message: %w", err) + return nil, fmt.Errorf("unmarshal MessageItem: %w", err) } - return &data, nil + if len(data.Items) == 0 { + return nil, fmt.Errorf("message %s not found", messageID) + } + return &data.Items[0], nil +} + +// ListMessages retrieves all messages from a container (e.g. "thread"). +// Lark API: GET /open-apis/im/v1/messages +func (c *LarkClient) ListMessages(ctx context.Context, containerIDType, containerID string, pageSize int, pageToken string) ([]MessageItem, string, error) { + path := fmt.Sprintf("/open-apis/im/v1/messages?container_id_type=%s&container_id=%s", + url.QueryEscape(containerIDType), url.QueryEscape(containerID)) + if pageSize > 0 { + path += fmt.Sprintf("&page_size=%d", pageSize) + } + if pageToken != "" { + path += "&page_token=" + pageToken + } + + resp, err := c.doJSON(ctx, "GET", path, nil) + if err != nil { + return nil, "", err + } + if resp.Code != 0 { + return nil, "", fmt.Errorf("list messages: code=%d msg=%s", resp.Code, resp.Msg) + } + var data struct { + Items []MessageItem `json:"items"` + PageToken string `json:"page_token"` + HasMore bool `json:"has_more"` + } + if err := json.Unmarshal(resp.Data, &data); err != nil { + return nil, "", fmt.Errorf("unmarshal ListMessages response: %w", err) + } + return data.Items, data.PageToken, nil } + // --- IM API: Message Resources --- func (c *LarkClient) DownloadMessageResource(ctx context.Context, messageID, fileKey, resourceType string) ([]byte, string, error) { @@ -149,7 +197,7 @@ func (c *LarkClient) CreateCard(ctx context.Context, cardType, data string) (str CardID string `json:"card_id"` } if err := json.Unmarshal(resp.Data, &result); err != nil { - return "", fmt.Errorf("unmarshal response: %w", err) + return "", fmt.Errorf("unmarshal CreateCard response: %w", err) } return result.CardID, nil } @@ -210,7 +258,7 @@ func (c *LarkClient) AddMessageReaction(ctx context.Context, messageID, emojiTyp ReactionID string `json:"reaction_id"` } if err := json.Unmarshal(resp.Data, &result); err != nil { - return "", fmt.Errorf("unmarshal response: %w", err) + return "", fmt.Errorf("unmarshal AddMessageReaction response: %w", err) } return result.ReactionID, nil } @@ -247,7 +295,7 @@ func (c *LarkClient) GetBotInfo(ctx context.Context) (string, error) { } `json:"bot"` } if err := json.Unmarshal(resp.Data, &result); err != nil { - return "", fmt.Errorf("unmarshal response: %w", err) + return "", fmt.Errorf("unmarshal GetBotInfo response: %w", err) } return result.Bot.OpenID, nil } @@ -269,7 +317,7 @@ func (c *LarkClient) GetUser(ctx context.Context, userID, userIDType string) (st } `json:"user"` } if err := json.Unmarshal(resp.Data, &result); err != nil { - return "", fmt.Errorf("unmarshal response: %w", err) + return "", fmt.Errorf("unmarshal GetUser response: %w", err) } return result.User.Name, nil }