fix(channels): extract and download images from Lark post messages

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.
This commit is contained in:
viettranx committed 2026-03-23 15:56:11 +07:00
1 parent 9aaec59527
commit 072d98a856
3 files changed
+100 -14

No files matched your search

+15 -7
View File
@@ -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
}
+48 -4
View File
@@ -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 {
+37 -3
View File
@@ -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 == "" {