From 072d98a856c4b0eb2872a0a54ed11f01d1a28854 Mon Sep 17 00:00:00 2001 From: viettranx Date: Mon, 23 Mar 2026 15:56:11 +0700 Subject: [PATCH] fix(channels): extract and download images from Lark post messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Post messages with embedded img tags were only producing [image] placeholder text — the actual image_key was discarded and never downloaded. Now extractPostImageKeys() collects image keys from post JSON, and resolvePostImages() downloads them via the existing Lark resource API. Also handles reply-to-post with images. Refactored: shared resolvePostElements() helper for language variant lookup, mediaMaxBytes() helper, and dedup via seen map. --- internal/channels/feishu/bot.go | 22 +++++++--- internal/channels/feishu/bot_parse.go | 52 +++++++++++++++++++++-- internal/channels/feishu/media_inbound.go | 40 +++++++++++++++-- 3 files changed, 100 insertions(+), 14 deletions(-) diff --git a/internal/channels/feishu/bot.go b/internal/channels/feishu/bot.go index e03829b0..38206db2 100644 --- a/internal/channels/feishu/bot.go +++ b/internal/channels/feishu/bot.go @@ -182,6 +182,10 @@ func (c *Channel) handleMessageEvent(ctx context.Context, event *MessageEvent) { switch mc.ContentType { case "image", "file", "audio", "video", "sticker": mediaList = append(mediaList, c.resolveMediaFromMessage(ctx, mc.MessageID, mc.ContentType, msg.Content)...) + case "post": + if imageKeys := extractPostImageKeys(msg.Content); len(imageKeys) > 0 { + mediaList = append(mediaList, c.resolvePostImages(ctx, mc.MessageID, imageKeys)...) + } } // 11. Process media: STT transcription, document extraction, build tags @@ -305,19 +309,23 @@ func (c *Channel) fetchReplyContext(ctx context.Context, parentID string) (strin replyCtx = fmt.Sprintf("[Replying to %s]\n%s\n[/Replying]", senderName, body) } - // Download media from parent message (image, file, audio, video, sticker). + // Download media from parent message (image, file, audio, video, sticker, post). var replyMedia []media.MediaInfo switch item.MsgType { case "image", "file", "audio", "video", "sticker": replyMedia = c.resolveMediaFromMessage(ctx, parentID, item.MsgType, item.Body.Content) - for i := range replyMedia { - replyMedia[i].FromReply = true - } - if len(replyMedia) > 0 { - slog.Debug("feishu: resolved media from replied message", - "parent_id", parentID, "media_count", len(replyMedia)) + case "post": + if imageKeys := extractPostImageKeys(item.Body.Content); len(imageKeys) > 0 { + replyMedia = c.resolvePostImages(ctx, parentID, imageKeys) } } + for i := range replyMedia { + replyMedia[i].FromReply = true + } + if len(replyMedia) > 0 { + slog.Debug("feishu: resolved media from replied message", + "parent_id", parentID, "media_count", len(replyMedia)) + } return replyCtx, replyMedia } diff --git a/internal/channels/feishu/bot_parse.go b/internal/channels/feishu/bot_parse.go index 92c4610a..c8aa2d6a 100644 --- a/internal/channels/feishu/bot_parse.go +++ b/internal/channels/feishu/bot_parse.go @@ -99,10 +99,12 @@ func parseMessageContent(rawContent, messageType string) string { } } -func parsePostContent(rawContent string) string { +// resolvePostElements extracts the flattened element list from a Lark post message JSON. +// Handles language variant lookup (zh_cn → en_us → any first key). +func resolvePostElements(rawContent string) []any { var post map[string]any if err := json.Unmarshal([]byte(rawContent), &post); err != nil { - return rawContent + return nil } var langContent any @@ -119,16 +121,24 @@ func parsePostContent(rawContent string) string { } } if langContent == nil { - return rawContent + return nil } langMap, ok := langContent.(map[string]any) if !ok { - return rawContent + return nil } contentArr, ok := langMap["content"].([]any) if !ok { + return nil + } + return contentArr +} + +func parsePostContent(rawContent string) string { + contentArr := resolvePostElements(rawContent) + if contentArr == nil { return rawContent } @@ -179,6 +189,40 @@ func parsePostContent(rawContent string) string { return strings.Join(textParts, "\n") } +// extractPostImageKeys parses post content JSON and returns deduplicated image_key +// values from embedded img tags. Used to download images inline in post messages. +func extractPostImageKeys(rawContent string) []string { + contentArr := resolvePostElements(rawContent) + if contentArr == nil { + return nil + } + + seen := make(map[string]struct{}) + var keys []string + for _, para := range contentArr { + paraArr, ok := para.([]any) + if !ok { + continue + } + for _, elem := range paraArr { + elemMap, ok := elem.(map[string]any) + if !ok { + continue + } + tag, _ := elemMap["tag"].(string) + if tag == "img" { + if key, ok := elemMap["image_key"].(string); ok && key != "" { + if _, dup := seen[key]; !dup { + seen[key] = struct{}{} + keys = append(keys, key) + } + } + } + } + } + return keys +} + // resolveMentions replaces mention placeholders (@_user_1, @_user_2, etc.) in content. // Bot mention is stripped entirely; other user mentions become @Name. func resolveMentions(text string, mentions []mentionInfo, botOpenID string) string { diff --git a/internal/channels/feishu/media_inbound.go b/internal/channels/feishu/media_inbound.go index 5f6f8e03..e56a2490 100644 --- a/internal/channels/feishu/media_inbound.go +++ b/internal/channels/feishu/media_inbound.go @@ -12,13 +12,19 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/channels/media" ) -// resolveMediaFromMessage extracts and downloads media from a Feishu message. -// Returns a list of MediaInfo for each media item found. -func (c *Channel) resolveMediaFromMessage(ctx context.Context, messageID, messageType, rawContent string) []media.MediaInfo { +// mediaMaxBytes returns the configured per-file size limit in bytes. +func (c *Channel) mediaMaxBytes() int64 { maxBytes := int64(c.cfg.MediaMaxMB) * 1024 * 1024 if maxBytes <= 0 { maxBytes = int64(defaultMediaMaxMB) * 1024 * 1024 } + return maxBytes +} + +// resolveMediaFromMessage extracts and downloads media from a Feishu message. +// Returns a list of MediaInfo for each media item found. +func (c *Channel) resolveMediaFromMessage(ctx context.Context, messageID, messageType, rawContent string) []media.MediaInfo { + maxBytes := c.mediaMaxBytes() var results []media.MediaInfo @@ -140,6 +146,34 @@ func (c *Channel) resolveMediaFromMessage(ctx context.Context, messageID, messag return results } +// resolvePostImages downloads images embedded in post messages by their image_key. +// Uses continue-on-error so one failed image doesn't skip the rest. +func (c *Channel) resolvePostImages(ctx context.Context, messageID string, imageKeys []string) []media.MediaInfo { + maxBytes := c.mediaMaxBytes() + + var results []media.MediaInfo + for _, key := range imageKeys { + data, _, err := c.downloadMessageResource(ctx, messageID, key, "image") + if err != nil { + slog.Debug("feishu download post image failed", "message_id", messageID, "image_key", key, "error", err) + continue + } + if int64(len(data)) > maxBytes { + slog.Debug("feishu post image too large", "size", len(data), "max", maxBytes) + continue + } + path, err := saveMediaToTemp(data, "img", ".png") + if err != nil { + slog.Debug("feishu save post image failed", "error", err) + continue + } + results = append(results, media.MediaInfo{ + Type: media.TypeImage, FilePath: path, ContentType: "image/png", + }) + } + return results +} + // saveMediaToTemp writes media bytes to a temp file and returns the path. func saveMediaToTemp(data []byte, prefix, ext string) (string, error) { if ext == "" {