feat(feishu): implement thread isolation and reply-in-thread

This commit is contained in:
Badgerbees 2026-03-17 16:31:02 +07:00
parent c68b4f3903
commit aa80042e4e

View file

@ -11,6 +11,7 @@ import (
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"sync" "sync"
"sync/atomic" "sync/atomic"
@ -172,10 +173,35 @@ func (c *FeishuChannel) SendPlaceholder(ctx context.Context, chatID string) (str
return "", fmt.Errorf("feishu placeholder: card build failed: %w", err) return "", fmt.Errorf("feishu placeholder: card build failed: %w", err)
} }
actualChatID, rootID := c.splitChatID(chatID)
if rootID != "" {
req := larkim.NewReplyMessageReqBuilder().
MessageId(rootID).
Body(larkim.NewReplyMessageReqBodyBuilder().
MsgType(larkim.MsgTypeInteractive).
Content(cardContent).
ReplyInThread(true).
Build()).
Build()
replyResp, replyErr := c.client.Im.V1.Message.Reply(ctx, req)
if replyErr != nil {
return "", fmt.Errorf("feishu placeholder reply: %w", replyErr)
}
if !replyResp.Success() {
return "", fmt.Errorf("feishu placeholder reply api error (code=%d msg=%s)", replyResp.Code, replyResp.Msg)
}
if replyResp.Data != nil && replyResp.Data.MessageId != nil {
return *replyResp.Data.MessageId, nil
}
return "", nil
}
req := larkim.NewCreateMessageReqBuilder(). req := larkim.NewCreateMessageReqBuilder().
ReceiveIdType(larkim.ReceiveIdTypeChatId). ReceiveIdType(larkim.ReceiveIdTypeChatId).
Body(larkim.NewCreateMessageReqBodyBuilder(). Body(larkim.NewCreateMessageReqBodyBuilder().
ReceiveId(chatID). ReceiveId(actualChatID).
MsgType(larkim.MsgTypeInteractive). MsgType(larkim.MsgTypeInteractive).
Content(cardContent). Content(cardContent).
Build()). Build()).
@ -367,19 +393,7 @@ func (c *FeishuChannel) handleMessageReceive(ctx context.Context, event *larkim.
// Extract content based on message type // Extract content based on message type
content := extractContent(messageType, rawContent) content := extractContent(messageType, rawContent)
// Handle media messages (download and store) // Initialize metadata
var mediaRefs []string
if store := c.GetMediaStore(); store != nil && messageID != "" {
mediaRefs = c.downloadInboundMedia(ctx, chatID, messageID, messageType, rawContent, store)
}
// Append media tags to content (like Telegram does)
content = appendMediaTags(content, messageType, mediaRefs)
if content == "" {
content = "[empty message]"
}
metadata := map[string]string{} metadata := map[string]string{}
if messageID != "" { if messageID != "" {
metadata["message_id"] = messageID metadata["message_id"] = messageID
@ -387,19 +401,43 @@ func (c *FeishuChannel) handleMessageReceive(ctx context.Context, event *larkim.
if messageType != "" { if messageType != "" {
metadata["message_type"] = messageType metadata["message_type"] = messageType
} }
if sender != nil && sender.TenantKey != nil {
metadata["tenant_key"] = *sender.TenantKey
}
rootID := stringValue(message.RootId)
parentID := stringValue(message.ParentId)
if rootID != "" {
metadata["root_id"] = rootID
}
if parentID != "" {
metadata["parent_id"] = parentID
}
chatType := stringValue(message.ChatType) chatType := stringValue(message.ChatType)
if chatType != "" { if chatType != "" {
metadata["chat_type"] = chatType metadata["chat_type"] = chatType
} }
if sender != nil && sender.TenantKey != nil {
metadata["tenant_key"] = *sender.TenantKey compositeChatID := chatID
if chatType == "group" && rootID != "" {
compositeChatID = fmt.Sprintf("%s/%s", chatID, rootID)
} }
// Handle media messages (download and store) using the composite scope
var mediaRefs []string
if store := c.GetMediaStore(); store != nil && messageID != "" {
mediaRefs = c.downloadInboundMedia(ctx, compositeChatID, messageID, messageType, rawContent, store)
}
// Append media tags to content (like Telegram does)
content = appendMediaTags(content, messageType, mediaRefs)
var peer bus.Peer var peer bus.Peer
if chatType == "p2p" { if chatType == "p2p" {
peer = bus.Peer{Kind: "direct", ID: senderID} peer = bus.Peer{Kind: "direct", ID: senderID}
} else { } else {
peer = bus.Peer{Kind: "group", ID: chatID} peer = bus.Peer{Kind: "group", ID: compositeChatID}
// Check if bot was mentioned // Check if bot was mentioned
isMentioned := c.isBotMentioned(message) isMentioned := c.isBotMentioned(message)
@ -419,12 +457,13 @@ func (c *FeishuChannel) handleMessageReceive(ctx context.Context, event *larkim.
logger.InfoCF("feishu", "Feishu message received", map[string]any{ logger.InfoCF("feishu", "Feishu message received", map[string]any{
"sender_id": senderID, "sender_id": senderID,
"chat_id": chatID, "chat_id": compositeChatID,
"message_id": messageID, "message_id": messageID,
"root_id": rootID,
"preview": utils.Truncate(content, 80), "preview": utils.Truncate(content, 80),
}) })
c.HandleMessage(ctx, peer, messageID, senderID, chatID, content, mediaRefs, metadata, senderInfo) c.HandleMessage(ctx, peer, messageID, senderID, compositeChatID, content, mediaRefs, metadata, senderInfo)
return nil return nil
} }
@ -688,12 +727,48 @@ func appendMediaTags(content, messageType string, mediaRefs []string) string {
return content + " " + tag return content + " " + tag
} }
func (c *FeishuChannel) splitChatID(chatID string) (string, string) {
if chat, root, ok := strings.Cut(chatID, "/"); ok {
return chat, root
}
return chatID, ""
}
// sendCard sends an interactive card message to a chat. // sendCard sends an interactive card message to a chat.
func (c *FeishuChannel) sendCard(ctx context.Context, chatID, cardContent string) error { func (c *FeishuChannel) sendCard(ctx context.Context, chatID, cardContent string) error {
actualChatID, rootID := c.splitChatID(chatID)
if rootID != "" {
// If we have a rootID, we use the Reply API to stay in the thread.
// Replying to any message in the thread with ReplyInThread=true attaches it to the thread.
req := larkim.NewReplyMessageReqBuilder().
MessageId(rootID).
Body(larkim.NewReplyMessageReqBodyBuilder().
MsgType(larkim.MsgTypeInteractive).
Content(cardContent).
ReplyInThread(true).
Build()).
Build()
resp, err := c.client.Im.V1.Message.Reply(ctx, req)
if err != nil {
return fmt.Errorf("feishu reply thread: %w", channels.ErrTemporary)
}
if !resp.Success() {
return fmt.Errorf("feishu reply api error (code=%d msg=%s): %w", resp.Code, resp.Msg, channels.ErrTemporary)
}
logger.DebugCF("feishu", "Feishu thread reply sent", map[string]any{
"chat_id": actualChatID,
"root_id": rootID,
})
return nil
}
// Default: Create a new message in the chat.
req := larkim.NewCreateMessageReqBuilder(). req := larkim.NewCreateMessageReqBuilder().
ReceiveIdType(larkim.ReceiveIdTypeChatId). ReceiveIdType(larkim.ReceiveIdTypeChatId).
Body(larkim.NewCreateMessageReqBodyBuilder(). Body(larkim.NewCreateMessageReqBodyBuilder().
ReceiveId(chatID). ReceiveId(actualChatID).
MsgType(larkim.MsgTypeInteractive). MsgType(larkim.MsgTypeInteractive).
Content(cardContent). Content(cardContent).
Build()). Build()).
@ -709,7 +784,7 @@ func (c *FeishuChannel) sendCard(ctx context.Context, chatID, cardContent string
} }
logger.DebugCF("feishu", "Feishu card message sent", map[string]any{ logger.DebugCF("feishu", "Feishu card message sent", map[string]any{
"chat_id": chatID, "chat_id": actualChatID,
}) })
return nil return nil
@ -739,11 +814,33 @@ func (c *FeishuChannel) sendImage(ctx context.Context, chatID string, file *os.F
imageKey := *uploadResp.Data.ImageKey imageKey := *uploadResp.Data.ImageKey
// Send image message // Send image message
actualChatID, rootID := c.splitChatID(chatID)
content, _ := json.Marshal(map[string]string{"image_key": imageKey}) content, _ := json.Marshal(map[string]string{"image_key": imageKey})
if rootID != "" {
req := larkim.NewReplyMessageReqBuilder().
MessageId(rootID).
Body(larkim.NewReplyMessageReqBodyBuilder().
MsgType(larkim.MsgTypeImage).
Content(string(content)).
ReplyInThread(true).
Build()).
Build()
replyResp, replyErr := c.client.Im.V1.Message.Reply(ctx, req)
if replyErr != nil {
return fmt.Errorf("feishu image reply: %w", replyErr)
}
if !replyResp.Success() {
return fmt.Errorf("feishu image reply api error (code=%d msg=%s)", replyResp.Code, replyResp.Msg)
}
return nil
}
req := larkim.NewCreateMessageReqBuilder(). req := larkim.NewCreateMessageReqBuilder().
ReceiveIdType(larkim.ReceiveIdTypeChatId). ReceiveIdType(larkim.ReceiveIdTypeChatId).
Body(larkim.NewCreateMessageReqBodyBuilder(). Body(larkim.NewCreateMessageReqBodyBuilder().
ReceiveId(chatID). ReceiveId(actualChatID).
MsgType(larkim.MsgTypeImage). MsgType(larkim.MsgTypeImage).
Content(string(content)). Content(string(content)).
Build()). Build()).
@ -761,13 +858,16 @@ func (c *FeishuChannel) sendImage(ctx context.Context, chatID string, file *os.F
// sendFile uploads a file and sends it as a message. // sendFile uploads a file and sends it as a message.
func (c *FeishuChannel) sendFile(ctx context.Context, chatID string, file *os.File, filename, fileType string) error { func (c *FeishuChannel) sendFile(ctx context.Context, chatID string, file *os.File, filename, fileType string) error {
// Map part type to Feishu file type // Map part type to Feishu upload file type and IM message type
feishuFileType := "stream" feishuFileType := "stream"
msgType := larkim.MsgTypeFile
switch fileType { switch fileType {
case "audio": case "audio":
feishuFileType = "opus" feishuFileType = "opus"
msgType = larkim.MsgTypeAudio
case "video": case "video":
feishuFileType = "mp4" feishuFileType = "mp4"
msgType = larkim.MsgTypeMedia
} }
// Upload file to get file_key // Upload file to get file_key
@ -793,12 +893,34 @@ func (c *FeishuChannel) sendFile(ctx context.Context, chatID string, file *os.Fi
fileKey := *uploadResp.Data.FileKey fileKey := *uploadResp.Data.FileKey
// Send file message // Send file message
actualChatID, rootID := c.splitChatID(chatID)
content, _ := json.Marshal(map[string]string{"file_key": fileKey}) content, _ := json.Marshal(map[string]string{"file_key": fileKey})
if rootID != "" {
req := larkim.NewReplyMessageReqBuilder().
MessageId(rootID).
Body(larkim.NewReplyMessageReqBodyBuilder().
MsgType(msgType).
Content(string(content)).
ReplyInThread(true).
Build()).
Build()
replyResp, replyErr := c.client.Im.V1.Message.Reply(ctx, req)
if replyErr != nil {
return fmt.Errorf("feishu file reply: %w", replyErr)
}
if !replyResp.Success() {
return fmt.Errorf("feishu file reply api error (code=%d msg=%s)", replyResp.Code, replyResp.Msg)
}
return nil
}
req := larkim.NewCreateMessageReqBuilder(). req := larkim.NewCreateMessageReqBuilder().
ReceiveIdType(larkim.ReceiveIdTypeChatId). ReceiveIdType(larkim.ReceiveIdTypeChatId).
Body(larkim.NewCreateMessageReqBodyBuilder(). Body(larkim.NewCreateMessageReqBodyBuilder().
ReceiveId(chatID). ReceiveId(actualChatID).
MsgType(larkim.MsgTypeFile). MsgType(msgType).
Content(string(content)). Content(string(content)).
Build()). Build()).
Build() Build()