diff --git a/pkg/channels/feishu/feishu_64.go b/pkg/channels/feishu/feishu_64.go index 1db1bf669..6e3d88503 100644 --- a/pkg/channels/feishu/feishu_64.go +++ b/pkg/channels/feishu/feishu_64.go @@ -6,6 +6,8 @@ import ( "context" "encoding/json" "fmt" + "os" + "path/filepath" "sync" "time" @@ -19,6 +21,7 @@ import ( "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/identity" "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/media" "github.com/sipeed/picoclaw/pkg/utils" ) @@ -151,11 +154,6 @@ func (c *FeishuChannel) handleMessageReceive(ctx context.Context, event *larkim. senderID = "unknown" } - content := extractFeishuMessageContent(message) - if content == "" { - content = "[empty message]" - } - metadata := map[string]string{} messageID := "" if mid := stringValue(message.MessageId); mid != "" { @@ -171,6 +169,40 @@ func (c *FeishuChannel) handleMessageReceive(ctx context.Context, event *larkim. metadata["tenant_key"] = *sender.TenantKey } + // 处理媒体消息 + scope := messageID + + storeMedia := func(localPath, filename string) string { + if store := c.GetMediaStore(); store != nil { + ref, err := store.Store(localPath, media.MediaMeta{ + Filename: filename, + Source: "feishu", + }, scope) + if err == nil { + return ref + } + } + return localPath + } + + content, mediaPaths, err := extractFeishuMessageContent(ctx, c, message, scope, storeMedia) + if err != nil { + logger.ErrorCF("feishu", "Failed to extract message content", map[string]any{ + "sender_id": senderID, + "chat_id": chatID, + "error": err.Error(), + }) + c.Send(ctx, bus.OutboundMessage{ + ChatID: chatID, + Content: fmt.Sprintf("消息处理失败: %s", err.Error()), + }) + return nil + } + + if content == "" { + content = "[empty message]" + } + chatType := stringValue(message.ChatType) var peer bus.Peer if chatType == "p2p" { @@ -201,7 +233,7 @@ func (c *FeishuChannel) handleMessageReceive(ctx context.Context, event *larkim. return nil } - c.HandleMessage(ctx, peer, messageID, senderID, chatID, content, nil, metadata, senderInfo) + c.HandleMessage(ctx, peer, messageID, senderID, chatID, content, mediaPaths, metadata, senderInfo) return nil } @@ -223,19 +255,370 @@ func extractFeishuSenderID(sender *larkim.EventSender) string { return "" } -func extractFeishuMessageContent(message *larkim.EventMessage) string { +func extractFeishuMessageContent(ctx context.Context, c *FeishuChannel, message *larkim.EventMessage, scope string, storeMedia func(string, string) string) (string, []string, error) { if message == nil || message.Content == nil || *message.Content == "" { - return "" + return "", nil, fmt.Errorf("empty message") } - if message.MessageType != nil && *message.MessageType == larkim.MsgTypeText { + var content string + var mediaPaths []string + + if message.MessageType == nil { + return *message.Content, nil, nil + } + + msgType := *message.MessageType + + switch msgType { + case larkim.MsgTypeText: var textPayload struct { Text string `json:"text"` } if err := json.Unmarshal([]byte(*message.Content), &textPayload); err == nil { - return textPayload.Text + content = textPayload.Text + } else { + content = *message.Content + } + + case larkim.MsgTypePost: + if message.MessageId == nil { + return "", nil, fmt.Errorf("消息ID为空") + } + var err error + content, err = extractPostContent(ctx, c, *message.MessageId, *message.Content, &mediaPaths, storeMedia) + if err != nil { + return "", nil, fmt.Errorf("解析富文本失败: %w", err) + } + if content == "" { + content = "[post]" + } + + case larkim.MsgTypeImage: + var imagePayload struct { + ImageKey string `json:"image_key"` + } + if err := json.Unmarshal([]byte(*message.Content), &imagePayload); err != nil { + return "", nil, fmt.Errorf("解析图片消息失败: %w", err) + } + if imagePayload.ImageKey == "" { + return "", nil, fmt.Errorf("图片key为空") + } + if message.MessageId == nil { + return "", nil, fmt.Errorf("消息ID为空") + } + imagePath, err := c.downloadImage(ctx, *message.MessageId, imagePayload.ImageKey) + if err != nil { + return "", nil, fmt.Errorf("下载图片失败: %w", err) + } + if imagePath == "" { + return "", nil, fmt.Errorf("图片下载失败: 下载结果为空") + } + mediaPaths = append(mediaPaths, storeMedia(imagePath, "image.jpg")) + content = "[image]" + + case larkim.MsgTypeAudio: + var audioPayload struct { + FileKey string `json:"file_key"` + } + if err := json.Unmarshal([]byte(*message.Content), &audioPayload); err != nil { + return "", nil, fmt.Errorf("解析语音消息失败: %w", err) + } + if audioPayload.FileKey == "" { + return "", nil, fmt.Errorf("语音文件key为空") + } + if message.MessageId == nil { + return "", nil, fmt.Errorf("消息ID为空") + } + audioPath, err := c.downloadFile(ctx, *message.MessageId, audioPayload.FileKey) + if err != nil { + return "", nil, fmt.Errorf("下载语音失败: %w", err) + } + if audioPath == "" { + return "", nil, fmt.Errorf("语音下载失败: 下载结果为空") + } + mediaPaths = append(mediaPaths, storeMedia(audioPath, "audio.amr")) + content = "[audio]" + + case larkim.MsgTypeFile: + var filePayload struct { + FileKey string `json:"file_key"` + } + if err := json.Unmarshal([]byte(*message.Content), &filePayload); err != nil { + return "", nil, fmt.Errorf("解析文件消息失败: %w", err) + } + if filePayload.FileKey == "" { + return "", nil, fmt.Errorf("文件key为空") + } + if message.MessageId == nil { + return "", nil, fmt.Errorf("消息ID为空") + } + filePath, err := c.downloadFile(ctx, *message.MessageId, filePayload.FileKey) + if err != nil { + return "", nil, fmt.Errorf("下载文件失败: %w", err) + } + if filePath == "" { + return "", nil, fmt.Errorf("文件下载失败: 下载结果为空") + } + mediaPaths = append(mediaPaths, storeMedia(filePath, "file")) + content = "[file]" + + case larkim.MsgTypeMedia: + var mediaPayload struct { + FileKey string `json:"file_key"` + ImageKey string `json:"image_key"` + } + if err := json.Unmarshal([]byte(*message.Content), &mediaPayload); err != nil { + return "", nil, fmt.Errorf("解析视频消息失败: %w", err) + } + if mediaPayload.FileKey == "" && mediaPayload.ImageKey == "" { + return "", nil, fmt.Errorf("视频文件key和图片key都为空") + } + if message.MessageId == nil { + return "", nil, fmt.Errorf("消息ID为空") + } + messageId := *message.MessageId + if mediaPayload.FileKey != "" { + videoPath, err := c.downloadFile(ctx, messageId, mediaPayload.FileKey) + if err != nil { + return "", nil, fmt.Errorf("下载视频失败: %w", err) + } + if videoPath == "" { + return "", nil, fmt.Errorf("视频下载失败: 下载结果为空") + } + mediaPaths = append(mediaPaths, storeMedia(videoPath, "video.mp4")) + } + if mediaPayload.ImageKey != "" { + imagePath, err := c.downloadImage(ctx, messageId, mediaPayload.ImageKey) + if err != nil { + return "", nil, fmt.Errorf("下载视频封面失败: %w", err) + } + if imagePath == "" { + return "", nil, fmt.Errorf("视频封面下载失败: 下载结果为空") + } + mediaPaths = append(mediaPaths, storeMedia(imagePath, "video_cover.jpg")) + } + content = "[video]" + + default: + content = *message.Content + } + + return content, mediaPaths, nil +} + +func extractPostContent(ctx context.Context, c *FeishuChannel, messageId string, contentStr string, mediaPaths *[]string, storeMedia func(string, string) string) (string, error) { + var postPayload struct { + Title string `json:"title"` + Content [][]map[string]any `json:"content"` + } + + if err := json.Unmarshal([]byte(contentStr), &postPayload); err != nil { + return "", fmt.Errorf("解析富文本内容失败: %w", err) + } + + var textContent string + + if postPayload.Title != "" { + textContent += postPayload.Title + "\n" + } + + for _, paragraph := range postPayload.Content { + paragraphText := "" + for _, element := range paragraph { + tag, ok := element["tag"].(string) + if !ok { + continue + } + switch tag { + case "text": + if text, ok := element["text"].(string); ok { + paragraphText += text + } + case "a": + if text, ok := element["text"].(string); ok { + if href, ok := element["href"].(string); ok { + paragraphText += "[" + text + "](" + href + ")" + } + } + case "at": + if name, ok := element["name"].(string); ok { + paragraphText += "@" + name + } + case "img": + if imageKey, ok := element["image_key"].(string); ok { + imagePath, err := c.downloadImage(ctx, messageId, imageKey) + if err != nil { + return "", fmt.Errorf("下载富文本图片失败: %w", err) + } + if imagePath != "" { + *mediaPaths = append(*mediaPaths, storeMedia(imagePath, "post_image.jpg")) + } + paragraphText += "[image]" + } + case "media": + if fileKey, ok := element["file_key"].(string); ok { + if fileKey != "" { + videoPath, err := c.downloadFile(ctx, messageId, fileKey) + if err != nil { + return "", fmt.Errorf("下载富文本视频失败: %w", err) + } + if videoPath != "" { + *mediaPaths = append(*mediaPaths, storeMedia(videoPath, "post_video.mp4")) + } + } + } + if imageKey, ok := element["image_key"].(string); ok { + if imageKey != "" { + imagePath, err := c.downloadImage(ctx, messageId, imageKey) + if err != nil { + return "", fmt.Errorf("下载富文本视频封面失败: %w", err) + } + if imagePath != "" { + *mediaPaths = append(*mediaPaths, storeMedia(imagePath, "post_video_cover.jpg")) + } + } + } + paragraphText += "[video]" + case "emotion": + if emoji, ok := element["emoji"].(string); ok { + paragraphText += emoji + } + case "code_block": + if lang, ok := element["lang"].(string); ok { + if code, ok := element["code"].(string); ok { + paragraphText += "```" + lang + "\n" + code + "\n```" + } + } + case "hr": + paragraphText += "---" + } + } + if paragraphText != "" { + textContent += paragraphText + "\n" } } - return *message.Content + return textContent, nil +} + +func (c *FeishuChannel) downloadImage(ctx context.Context, messageId, imageKey string) (string, error) { + if imageKey == "" { + return "", fmt.Errorf("图片key为空") + } + if messageId == "" { + return "", fmt.Errorf("消息ID为空") + } + + logger.InfoCF("feishu", "Starting to download image", map[string]any{ + "message_id": messageId, + "image_key": imageKey, + }) + + ext := ".jpg" + filename := filepath.Join("/tmp/picoclaw_media", "feishu_image_"+imageKey+ext) + + if err := os.MkdirAll(filepath.Dir(filename), 0755); err != nil { + return "", fmt.Errorf("创建媒体目录失败: %w", err) + } + + req := larkim.NewGetMessageResourceReqBuilder(). + MessageId(messageId). + FileKey(imageKey). + Type("image"). + Build() + + resp, err := c.client.Im.MessageResource.Get(ctx, req) + if err != nil { + return "", fmt.Errorf("下载图片请求失败: %w", err) + } + + if !resp.Success() { + errorMsg := fmt.Sprintf("下载图片失败 (code=%d msg=%s)", resp.Code, resp.Msg) + + switch resp.Code { + case 234001: + errorMsg = fmt.Sprintf("下载图片失败: 请求参数无效,请检查message_id和image_key是否匹配 (code=%d msg=%s)", resp.Code, resp.Msg) + case 234003: + errorMsg = fmt.Sprintf("下载图片失败: 该资源不属于当前消息 (code=%d msg=%s)", resp.Code, resp.Msg) + case 234004: + errorMsg = fmt.Sprintf("下载图片失败: 应用不在消息所在的群组中 (code=%d msg=%s)", resp.Code, resp.Msg) + case 234005: + errorMsg = fmt.Sprintf("下载图片失败: 图片已被删除 (code=%d msg=%s)", resp.Code, resp.Msg) + } + + return "", fmt.Errorf("%s", errorMsg) + } + + if err := resp.WriteFile(filename); err != nil { + return "", fmt.Errorf("写入图片文件失败: %w", err) + } + + logger.InfoCF("feishu", "Image downloaded successfully", map[string]any{ + "message_id": messageId, + "image_key": imageKey, + "path": filename, + }) + + return filename, nil +} + +func (c *FeishuChannel) downloadFile(ctx context.Context, messageId, fileKey string) (string, error) { + if fileKey == "" { + return "", fmt.Errorf("文件key为空") + } + if messageId == "" { + return "", fmt.Errorf("消息ID为空") + } + + logger.InfoCF("feishu", "Starting to download file", map[string]any{ + "message_id": messageId, + "file_key": fileKey, + }) + + ext := ".file" + filename := filepath.Join("/tmp/picoclaw_media", "feishu_file_"+fileKey+ext) + + if err := os.MkdirAll(filepath.Dir(filename), 0755); err != nil { + return "", fmt.Errorf("创建媒体目录失败: %w", err) + } + + req := larkim.NewGetMessageResourceReqBuilder(). + MessageId(messageId). + FileKey(fileKey). + Type("file"). + Build() + + resp, err := c.client.Im.MessageResource.Get(ctx, req) + if err != nil { + return "", fmt.Errorf("下载文件请求失败: %w", err) + } + + if !resp.Success() { + errorMsg := fmt.Sprintf("下载文件失败 (code=%d msg=%s)", resp.Code, resp.Msg) + + switch resp.Code { + case 234001: + errorMsg = fmt.Sprintf("下载文件失败: 请求参数无效,请检查message_id和file_key是否匹配 (code=%d msg=%s)", resp.Code, resp.Msg) + case 234003: + errorMsg = fmt.Sprintf("下载文件失败: 该资源不属于当前消息 (code=%d msg=%s)", resp.Code, resp.Msg) + case 234004: + errorMsg = fmt.Sprintf("下载文件失败: 应用不在消息所在的群组中 (code=%d msg=%s)", resp.Code, resp.Msg) + case 234005: + errorMsg = fmt.Sprintf("下载文件失败: 文件已被删除 (code=%d msg=%s)", resp.Code, resp.Msg) + } + + return "", fmt.Errorf("%s", errorMsg) + } + + if err := resp.WriteFile(filename); err != nil { + return "", fmt.Errorf("写入文件失败: %w", err) + } + + logger.InfoCF("feishu", "File downloaded successfully", map[string]any{ + "message_id": messageId, + "file_key": fileKey, + "path": filename, + }) + + return filename, nil }