Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 57 additions & 17 deletions internal/channels/feishu/bot.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
}
Expand All @@ -276,24 +284,56 @@ 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
}
}

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
}
50 changes: 50 additions & 0 deletions internal/channels/feishu/bot_parse.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package feishu

import (
"context"
"encoding/json"
"fmt"
"strings"
Expand Down Expand Up @@ -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")
}
1 change: 1 addition & 0 deletions internal/channels/feishu/feishu.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
104 changes: 76 additions & 28 deletions internal/channels/feishu/larkclient_messaging.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"
"io"
"log/slog"
"net/url"
"strconv"
)

Expand All @@ -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) {
Expand All @@ -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
}
Expand All @@ -84,47 +112,67 @@ 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
}
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) {
Expand All @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand All @@ -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
}
Loading