fix(feishu): fix image download with API fallback and post image support (#2708)

* fix(feishu): fix image download with API fallback and post image support

- Add Image.Get API fallback when MessageResource.Get fails (different
  permission scope: im:resource vs im:message:readonly)
- Extract and download images from post (rich text) messages
- Extract images from interactive card messages
- Deduplicate post image keys across locales
- Add comprehensive tests for new helpers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(media): add image path tags alongside base64 for LLM file access

Images are still base64-encoded into msg.Media for multimodal LLMs,
but now also get [image:path] tags injected into message content so
the LLM knows the local file path for save/forward operations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(media): only auto-inject images for tool results, not user messages

Channel-received images (role=user) now get path tags only, letting
the LLM decide whether to view via load_image or just operate on
the file. Tool result images (role=tool, e.g. load_image) are
base64-encoded into a synthetic user message appended after the tool
message, since many LLM APIs don't support image_url in tool messages.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(media): preserve tool-message ordering for multi-tool-call scenarios

Move synthetic user message (carrying base64 tool images) to after the
entire contiguous tool-message block instead of immediately after each
tool message. This preserves the assistant→tool→tool ordering required
by OpenAI-compatible APIs.

Also fix load_image to use generic [image: photo] placeholder so
injectPathTags can properly replace it with the actual path.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(test): update load_image test for [image: photo] placeholder

The test was checking ForLLM for the media:// ref, but load_image now
emits the generic [image: photo] placeholder instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(media): match all channel image placeholders in injectPathTags

Different channels emit different placeholder formats — Telegram/Feishu
use [image: photo], WeCom/WeChat/Line use bare [image], QQ/Discord use
[image: <filename>]. The previous string-match code only handled
[image: photo], so for the other channels the path tag was appended as
a duplicate, producing content like "[image] [image:/path]".

Switch to per-type regex that matches all generic placeholder shapes
while leaving path tags ([image:/path]) untouched. Also fixes the same
issue for [audio], [video], [file] tags. Added test coverage for the
various placeholder shapes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(media): skip path tag append for JSON content (Feishu cards/posts)

When content is structured JSON (interactive cards, post messages),
injectPathTags now skips the fallback append — only placeholder
replacement is attempted. This prevents corrupting JSON payloads
like {"schema":"2.0",...} with appended [image:/path] tags.

Adds looksLikeJSON() helper and three test cases covering JSON
objects, arrays, and an end-to-end resolveMediaRefs scenario with
Feishu card content.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(media): prepend path tags for JSON content, narrow looksLikeJSON

Two fixes from code review:

1. looksLikeJSON now only checks for '{' prefix (not '['), avoiding
   false positives on regular text like "[update] see attached".

2. For JSON content (Feishu cards/posts), path tags are prepended
   before the JSON instead of being silently dropped. This ensures
   the LLM can discover attached images via the path tag while the
   JSON payload stays valid for downstream parsing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Guoguo 2026-04-30 11:08:00 +08:00 committed by GitHub
parent a36472b55f
commit cb1e1a3595
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 625 additions and 122 deletions

View file

@ -11,6 +11,7 @@ import (
"encoding/base64" "encoding/base64"
"io" "io"
"os" "os"
"regexp"
"strings" "strings"
"github.com/h2non/filetype" "github.com/h2non/filetype"
@ -20,24 +21,59 @@ import (
"github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/providers"
) )
// genericPlaceholderRegex matches generic media placeholders emitted by various
// channels: [image], [image: photo], [image: filename.jpg] — but NOT path tags
// like [image:/path/to/file] (path tags have no space after the colon).
var (
imagePlaceholderRegex = regexp.MustCompile(`\[image(:\s+[^\]]*)?\]`)
audioPlaceholderRegex = regexp.MustCompile(`\[audio(:\s+[^\]]*)?\]`)
videoPlaceholderRegex = regexp.MustCompile(`\[video(:\s+[^\]]*)?\]`)
filePlaceholderRegex = regexp.MustCompile(`\[file(:\s+[^\]]*)?\]`)
)
// resolveMediaRefs resolves media:// refs in messages. // resolveMediaRefs resolves media:// refs in messages.
// Images are base64-encoded into the Media array for multimodal LLMs. // For user messages: images get path tags only ([image:/path]) so the LLM
// Non-image files (documents, audio, video) have their local path injected // can decide whether to view them via load_image or operate on the file.
// into Content so the agent can access them via file tools like read_file. // For tool messages: images are base64-encoded and appended as a synthetic
// user message only after the contiguous tool-message block ends, so we don't
// break the tool-results-must-immediately-follow-assistant constraint that
// LLM APIs enforce.
// Non-image files always get path tags regardless of role.
// Returns a new slice; original messages are not mutated. // Returns a new slice; original messages are not mutated.
func resolveMediaRefs(messages []providers.Message, store media.MediaStore, maxSize int) []providers.Message { func resolveMediaRefs(messages []providers.Message, store media.MediaStore, maxSize int) []providers.Message {
if store == nil { if store == nil {
return messages return messages
} }
result := make([]providers.Message, len(messages)) result := make([]providers.Message, 0, len(messages))
copy(result, messages) var pendingToolImages []string
for idx, m := range messages {
// When leaving a tool-message block, flush any accumulated images
// as a synthetic user message.
if m.Role != "tool" && len(pendingToolImages) > 0 {
result = append(result, providers.Message{
Role: "user",
Content: "[Loaded image from tool result above]",
Media: pendingToolImages,
})
pendingToolImages = nil
}
for i, m := range result {
if len(m.Media) == 0 { if len(m.Media) == 0 {
result = append(result, m)
if idx == len(messages)-1 && len(pendingToolImages) > 0 {
result = append(result, providers.Message{
Role: "user",
Content: "[Loaded image from tool result above]",
Media: pendingToolImages,
})
pendingToolImages = nil
}
continue continue
} }
msg := m
resolved := make([]string, 0, len(m.Media)) resolved := make([]string, 0, len(m.Media))
var pathTags []string var pathTags []string
@ -66,27 +102,77 @@ func resolveMediaRefs(messages []providers.Message, store media.MediaStore, maxS
} }
mime := detectMIME(localPath, meta) mime := detectMIME(localPath, meta)
pathTags = append(pathTags, buildPathTag(mime, localPath))
if strings.HasPrefix(mime, "image/") { if m.Role == "tool" && strings.HasPrefix(mime, "image/") {
dataURL := encodeImageToDataURL(localPath, mime, info, maxSize) dataURL := encodeImageToDataURL(localPath, mime, info, maxSize)
if dataURL != "" { if dataURL != "" {
resolved = append(resolved, dataURL) pendingToolImages = append(pendingToolImages, dataURL)
} }
continue
} }
pathTags = append(pathTags, buildPathTag(mime, localPath))
} }
result[i].Media = resolved msg.Media = resolved
if len(pathTags) > 0 { if len(pathTags) > 0 {
result[i].Content = injectPathTags(result[i].Content, pathTags) msg.Content = injectPathTags(msg.Content, pathTags)
}
result = append(result, msg)
// If this is the last message and we have pending images, flush them.
if idx == len(messages)-1 && len(pendingToolImages) > 0 {
result = append(result, providers.Message{
Role: "user",
Content: "[Loaded image from tool result above]",
Media: pendingToolImages,
})
pendingToolImages = nil
} }
} }
return result return result
} }
// encodeImageToDataURL base64-encodes an image file into a data URL.
// Returns empty string if the file exceeds maxSize or encoding fails.
func encodeImageToDataURL(localPath, mime string, info os.FileInfo, maxSize int) string {
if info.Size() > int64(maxSize) {
logger.WarnCF("agent", "Media file too large, skipping", map[string]any{
"path": localPath,
"size": info.Size(),
"max_size": maxSize,
})
return ""
}
f, err := os.Open(localPath)
if err != nil {
logger.WarnCF("agent", "Failed to open media file", map[string]any{
"path": localPath,
"error": err.Error(),
})
return ""
}
defer f.Close()
prefix := "data:" + mime + ";base64,"
encodedLen := base64.StdEncoding.EncodedLen(int(info.Size()))
var buf bytes.Buffer
buf.Grow(len(prefix) + encodedLen)
buf.WriteString(prefix)
encoder := base64.NewEncoder(base64.StdEncoding, &buf)
if _, err := io.Copy(encoder, f); err != nil {
logger.WarnCF("agent", "Failed to encode media file", map[string]any{
"path": localPath,
"error": err.Error(),
})
return ""
}
encoder.Close()
return buf.String()
}
func buildArtifactTags(store media.MediaStore, refs []string) []string { func buildArtifactTags(store media.MediaStore, refs []string) []string {
if store == nil || len(refs) == 0 { if store == nil || len(refs) == 0 {
return nil return nil
@ -137,51 +223,12 @@ func detectMIME(localPath string, meta media.MediaMeta) string {
return kind.MIME.Value return kind.MIME.Value
} }
// encodeImageToDataURL base64-encodes an image file into a data URL.
// Returns empty string if the file exceeds maxSize or encoding fails.
func encodeImageToDataURL(localPath, mime string, info os.FileInfo, maxSize int) string {
if info.Size() > int64(maxSize) {
logger.WarnCF("agent", "Media file too large, skipping", map[string]any{
"path": localPath,
"size": info.Size(),
"max_size": maxSize,
})
return ""
}
f, err := os.Open(localPath)
if err != nil {
logger.WarnCF("agent", "Failed to open media file", map[string]any{
"path": localPath,
"error": err.Error(),
})
return ""
}
defer f.Close()
prefix := "data:" + mime + ";base64,"
encodedLen := base64.StdEncoding.EncodedLen(int(info.Size()))
var buf bytes.Buffer
buf.Grow(len(prefix) + encodedLen)
buf.WriteString(prefix)
encoder := base64.NewEncoder(base64.StdEncoding, &buf)
if _, err := io.Copy(encoder, f); err != nil {
logger.WarnCF("agent", "Failed to encode media file", map[string]any{
"path": localPath,
"error": err.Error(),
})
return ""
}
encoder.Close()
return buf.String()
}
// buildPathTag creates a structured tag exposing the local file path. // buildPathTag creates a structured tag exposing the local file path.
// Tag type is derived from MIME: [audio:/path], [video:/path], or [file:/path]. // Tag type is derived from MIME: [image:/path], [audio:/path], [video:/path], or [file:/path].
func buildPathTag(mime, localPath string) string { func buildPathTag(mime, localPath string) string {
switch { switch {
case strings.HasPrefix(mime, "image/"):
return "[image:" + localPath + "]"
case strings.HasPrefix(mime, "audio/"): case strings.HasPrefix(mime, "audio/"):
return "[audio:" + localPath + "]" return "[audio:" + localPath + "]"
case strings.HasPrefix(mime, "video/"): case strings.HasPrefix(mime, "video/"):
@ -192,22 +239,41 @@ func buildPathTag(mime, localPath string) string {
} }
// injectPathTags replaces generic media tags in content with path-bearing versions, // injectPathTags replaces generic media tags in content with path-bearing versions,
// or appends if no matching generic tag is found. // or appends if no matching generic tag is found. Channels emit a few different
// placeholder formats — [image], [image: photo], [image: filename.jpg] — so we
// match all of them via regex while leaving path tags ([image:/path]) untouched.
//
// When content is structured data (e.g., JSON from Feishu interactive cards or
// post messages), tags are only injected via placeholder replacement — never
// appended — to avoid corrupting the payload.
func injectPathTags(content string, tags []string) string { func injectPathTags(content string, tags []string) string {
isStructured := looksLikeJSON(content)
for _, tag := range tags { for _, tag := range tags {
var generic string var pattern *regexp.Regexp
switch { switch {
case strings.HasPrefix(tag, "[image:"):
pattern = imagePlaceholderRegex
case strings.HasPrefix(tag, "[audio:"): case strings.HasPrefix(tag, "[audio:"):
generic = "[audio]" pattern = audioPlaceholderRegex
case strings.HasPrefix(tag, "[video:"): case strings.HasPrefix(tag, "[video:"):
generic = "[video]" pattern = videoPlaceholderRegex
case strings.HasPrefix(tag, "[file:"): case strings.HasPrefix(tag, "[file:"):
generic = "[file]" pattern = filePlaceholderRegex
} }
if generic != "" && strings.Contains(content, generic) { if pattern != nil {
content = strings.Replace(content, generic, tag, 1) if loc := pattern.FindStringIndex(content); loc != nil {
} else if content == "" { content = content[:loc[0]] + tag + content[loc[1]:]
continue
}
}
if isStructured {
content = tag + "\n" + content
continue
}
if content == "" {
content = tag content = tag
} else { } else {
content += " " + tag content += " " + tag
@ -215,3 +281,8 @@ func injectPathTags(content string, tags []string) string {
} }
return content return content
} }
func looksLikeJSON(s string) bool {
s = strings.TrimSpace(s)
return len(s) > 1 && s[0] == '{'
}

View file

@ -1781,17 +1781,22 @@ func (m *artifactThenSendProvider) Chat(
if messages[i].Role != "tool" { if messages[i].Role != "tool" {
continue continue
} }
start := strings.Index(messages[i].Content, "[file:") for _, prefix := range []string{"[image:", "[file:", "[audio:", "[video:"} {
if start < 0 { start := strings.Index(messages[i].Content, prefix)
continue if start < 0 {
continue
}
rest := messages[i].Content[start+len(prefix):]
end := strings.Index(rest, "]")
if end < 0 {
continue
}
artifactPath = rest[:end]
break
} }
rest := messages[i].Content[start+len("[file:"):] if artifactPath != "" {
end := strings.Index(rest, "]") break
if end < 0 {
continue
} }
artifactPath = rest[:end]
break
} }
if artifactPath == "" { if artifactPath == "" {
return nil, fmt.Errorf("provider did not receive artifact path in tool result") return nil, fmt.Errorf("provider did not receive artifact path in tool result")
@ -4656,7 +4661,7 @@ func TestRun_PicoToolFeedbackSuppressesDuplicateInterimAssistantContent(t *testi
} }
} }
func TestResolveMediaRefs_ResolvesToBase64(t *testing.T) { func TestResolveMediaRefs_ImageInjectsPathTag(t *testing.T) {
store := media.NewFileMediaStore() store := media.NewFileMediaStore()
dir := t.TempDir() dir := t.TempDir()
@ -4684,15 +4689,110 @@ func TestResolveMediaRefs_ResolvesToBase64(t *testing.T) {
} }
result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize) result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize)
if len(result[0].Media) != 1 { if len(result[0].Media) != 0 {
t.Fatalf("expected 1 resolved media, got %d", len(result[0].Media)) t.Fatalf("expected 0 media (images use path tags), got %d", len(result[0].Media))
} }
if !strings.HasPrefix(result[0].Media[0], "data:image/png;base64,") { localPath, _, _ := store.ResolveWithMeta(ref)
t.Fatalf("expected data:image/png;base64, prefix, got %q", result[0].Media[0][:40]) expectedContent := "describe this [image:" + localPath + "]"
if result[0].Content != expectedContent {
t.Fatalf("expected content %q, got %q", expectedContent, result[0].Content)
} }
} }
func TestResolveMediaRefs_SkipsOversizedFile(t *testing.T) { func TestResolveMediaRefs_ToolRoleImageAppendedAsUserMessage(t *testing.T) {
store := media.NewFileMediaStore()
dir := t.TempDir()
pngPath := filepath.Join(dir, "tool-result.png")
pngHeader := []byte{
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature
0x00, 0x00, 0x00, 0x0D, // IHDR length
0x49, 0x48, 0x44, 0x52, // "IHDR"
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, // 1x1 RGB
0x00, 0x00, 0x00, // no interlace
0x90, 0x77, 0x53, 0xDE, // CRC
}
if err := os.WriteFile(pngPath, pngHeader, 0o644); err != nil {
t.Fatal(err)
}
ref, _ := store.Store(pngPath, media.MediaMeta{}, "test")
messages := []providers.Message{
{Role: "tool", Content: "Image loaded", Media: []string{ref}},
}
result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize)
// Tool message should have path tag but no base64
if len(result[0].Media) != 0 {
t.Fatalf("expected 0 media in tool message, got %d", len(result[0].Media))
}
localPath, _, _ := store.ResolveWithMeta(ref)
if !strings.Contains(result[0].Content, "[image:"+localPath+"]") {
t.Fatalf("expected image path tag in tool content, got %q", result[0].Content)
}
// A synthetic user message with base64 should follow
if len(result) != 2 {
t.Fatalf("expected 2 messages (tool + synthetic user), got %d", len(result))
}
if result[1].Role != "user" {
t.Fatalf("expected synthetic message role=user, got %q", result[1].Role)
}
if len(result[1].Media) != 1 {
t.Fatalf("expected 1 base64 media in synthetic user message, got %d", len(result[1].Media))
}
if !strings.HasPrefix(result[1].Media[0], "data:image/png;base64,") {
t.Fatalf("expected data:image/png;base64, prefix, got %q", result[1].Media[0][:40])
}
}
func TestResolveMediaRefs_MultiToolCallPreservesOrdering(t *testing.T) {
store := media.NewFileMediaStore()
dir := t.TempDir()
// Create image for tool #1
pngPath := filepath.Join(dir, "loaded.png")
pngHeader := []byte{
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature
0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, // IHDR
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02,
0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xDE,
}
os.WriteFile(pngPath, pngHeader, 0o644)
imgRef, _ := store.Store(pngPath, media.MediaMeta{}, "test")
// Simulate: assistant called load_image + read_file, two tool results follow
messages := []providers.Message{
{Role: "assistant", Content: "Let me load the image and read the file."},
{Role: "tool", Content: "Image loaded [image: photo]", Media: []string{imgRef}},
{Role: "tool", Content: "file contents here"},
}
result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize)
// assistant, tool#1, tool#2 must remain contiguous — no user in between
if result[0].Role != "assistant" {
t.Fatalf("result[0] expected assistant, got %q", result[0].Role)
}
if result[1].Role != "tool" {
t.Fatalf("result[1] expected tool, got %q", result[1].Role)
}
if result[2].Role != "tool" {
t.Fatalf("result[2] expected tool, got %q", result[2].Role)
}
// Synthetic user message should come AFTER the tool block
if len(result) != 4 {
t.Fatalf("expected 4 messages (assistant + 2 tool + synthetic user), got %d", len(result))
}
if result[3].Role != "user" {
t.Fatalf("result[3] expected user, got %q", result[3].Role)
}
if len(result[3].Media) != 1 || !strings.HasPrefix(result[3].Media[0], "data:image/png;base64,") {
t.Fatal("expected synthetic user message to contain base64 image")
}
}
func TestResolveMediaRefs_OversizedImageSkipsBase64KeepsPathTag(t *testing.T) {
store := media.NewFileMediaStore() store := media.NewFileMediaStore()
dir := t.TempDir() dir := t.TempDir()
@ -4714,6 +4814,11 @@ func TestResolveMediaRefs_SkipsOversizedFile(t *testing.T) {
if len(result[0].Media) != 0 { if len(result[0].Media) != 0 {
t.Fatalf("expected 0 media (oversized), got %d", len(result[0].Media)) t.Fatalf("expected 0 media (oversized), got %d", len(result[0].Media))
} }
localPath, _, _ := store.ResolveWithMeta(ref)
expected := "hi [image:" + localPath + "]"
if result[0].Content != expected {
t.Fatalf("expected content %q, got %q", expected, result[0].Content)
}
} }
func TestResolveMediaRefs_UnknownTypeInjectsPath(t *testing.T) { func TestResolveMediaRefs_UnknownTypeInjectsPath(t *testing.T) {
@ -4791,11 +4896,13 @@ func TestResolveMediaRefs_UsesMetaContentType(t *testing.T) {
} }
result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize) result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize)
if len(result[0].Media) != 1 { if len(result[0].Media) != 0 {
t.Fatalf("expected 1 media, got %d", len(result[0].Media)) t.Fatalf("expected 0 media (images use path tags), got %d", len(result[0].Media))
} }
if !strings.HasPrefix(result[0].Media[0], "data:image/jpeg;base64,") { localPath, _, _ := store.ResolveWithMeta(ref)
t.Fatalf("expected jpeg prefix, got %q", result[0].Media[0][:30]) expectedContent := "hi [image:" + localPath + "]"
if result[0].Content != expectedContent {
t.Fatalf("expected content %q, got %q", expectedContent, result[0].Content)
} }
} }
@ -4885,6 +4992,98 @@ func TestResolveMediaRefs_NoGenericTagAppendsPath(t *testing.T) {
} }
} }
func TestInjectPathTags_HandlesVariousChannelPlaceholders(t *testing.T) {
cases := []struct {
name string
content string
tag string
want string
}{
// Telegram / Feishu format
{"image_photo", "[image: photo]", "[image:/tmp/p.png]", "[image:/tmp/p.png]"},
// WeCom / WeChat / Line format
{"bare_image", "[image]", "[image:/tmp/p.png]", "[image:/tmp/p.png]"},
// QQ / Discord format with filename
{"image_filename", "[image: pic.jpg]", "[image:/tmp/p.png]", "[image:/tmp/p.png]"},
{"audio_with_filename", "[audio: voice.m4a]", "[audio:/tmp/a.m4a]", "[audio:/tmp/a.m4a]"},
{"bare_audio", "[audio]", "[audio:/tmp/a.m4a]", "[audio:/tmp/a.m4a]"},
{"bare_video", "[video]", "[video:/tmp/v.mp4]", "[video:/tmp/v.mp4]"},
{"bare_file", "[file]", "[file:/tmp/f.pdf]", "[file:/tmp/f.pdf]"},
// Mixed surrounding text
{
"with_text",
"hello [image] world",
"[image:/tmp/p.png]",
"hello [image:/tmp/p.png] world",
},
// No placeholder — append
{"no_placeholder", "hello world", "[image:/tmp/p.png]", "hello world [image:/tmp/p.png]"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := injectPathTags(tc.content, []string{tc.tag})
if got != tc.want {
t.Errorf("expected %q, got %q", tc.want, got)
}
})
}
}
func TestInjectPathTags_DoesNotReplacePathTag(t *testing.T) {
// If content already contains a path tag, we must not touch it.
content := "see [image:/already/placed.png] thanks"
got := injectPathTags(content, []string{"[image:/new/path.png]"})
want := "see [image:/already/placed.png] thanks [image:/new/path.png]"
if got != want {
t.Fatalf("expected %q, got %q", want, got)
}
}
func TestInjectPathTags_PrependsForJSONContent(t *testing.T) {
jsonContent := `{"schema":"2.0","body":{"elements":[{"tag":"img","img_key":"img_123"}]}}`
got := injectPathTags(jsonContent, []string{"[image:/tmp/photo.png]"})
want := "[image:/tmp/photo.png]\n" + jsonContent
if got != want {
t.Fatalf("expected tag prepended to JSON, got %q", got)
}
}
func TestInjectPathTags_BracketTextNotTreatedAsJSON(t *testing.T) {
content := "[update] see attached report"
got := injectPathTags(content, []string{"[file:/tmp/report.pdf]"})
want := "[update] see attached report [file:/tmp/report.pdf]"
if got != want {
t.Fatalf("expected tag appended to bracket text, got %q", got)
}
}
func TestResolveMediaRefs_JSONContentPrependsPathTag(t *testing.T) {
store := media.NewFileMediaStore()
dir := t.TempDir()
pngPath := filepath.Join(dir, "card_img.png")
pngHeader := []byte{
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A,
0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52,
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02,
0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xDE,
}
os.WriteFile(pngPath, pngHeader, 0o644)
ref, _ := store.Store(pngPath, media.MediaMeta{ContentType: "image/png"}, "test")
jsonContent := `{"schema":"2.0","body":{"elements":[{"tag":"img","img_key":"img_123"}]}}`
messages := []providers.Message{
{Role: "user", Content: jsonContent, Media: []string{ref}},
}
result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize)
want := "[image:" + pngPath + "]\n" + jsonContent
if result[0].Content != want {
t.Fatalf("expected path tag prepended to JSON content, got %q", result[0].Content)
}
}
func TestResolveMediaRefs_EmptyContentGetsPathTag(t *testing.T) { func TestResolveMediaRefs_EmptyContentGetsPathTag(t *testing.T) {
store := media.NewFileMediaStore() store := media.NewFileMediaStore()
dir := t.TempDir() dir := t.TempDir()
@ -4928,13 +5127,12 @@ func TestResolveMediaRefs_MixedImageAndFile(t *testing.T) {
} }
result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize) result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize)
if len(result[0].Media) != 1 { if len(result[0].Media) != 0 {
t.Fatalf("expected 1 media (image only), got %d", len(result[0].Media)) t.Fatalf("expected 0 media (all types use path tags), got %d", len(result[0].Media))
} }
if !strings.HasPrefix(result[0].Media[0], "data:image/png;base64,") { imgLocalPath, _, _ := store.ResolveWithMeta(imgRef)
t.Fatal("expected image to be base64 encoded") pdfLocalPath, _, _ := store.ResolveWithMeta(fileRef)
} expectedContent := "check these [file:" + pdfLocalPath + "] [image:" + imgLocalPath + "]"
expectedContent := "check these [file:" + pdfPath + "]"
if result[0].Content != expectedContent { if result[0].Content != expectedContent {
t.Fatalf("expected content %q, got %q", expectedContent, result[0].Content) t.Fatalf("expected content %q, got %q", expectedContent, result[0].Content)
} }

View file

@ -1051,16 +1051,16 @@ func TestAgentLoop_Continue_PreservesSteeringMedia(t *testing.T) {
foundResolvedMedia := false foundResolvedMedia := false
for _, msg := range msgs { for _, msg := range msgs {
if msg.Role != "user" || msg.Content != "describe this image" || len(msg.Media) != 1 { if msg.Role != "user" || !strings.Contains(msg.Content, "describe this image") {
continue continue
} }
if strings.HasPrefix(msg.Media[0], "data:image/png;base64,") { if strings.Contains(msg.Content, "[image:") {
foundResolvedMedia = true foundResolvedMedia = true
break break
} }
} }
if !foundResolvedMedia { if !foundResolvedMedia {
t.Fatal("expected continue path to inject steering media into the provider request") t.Fatal("expected continue path to inject image path tag into the provider request")
} }
defaultAgent := al.registry.GetDefaultAgent() defaultAgent := al.registry.GetDefaultAgent()

View file

@ -64,6 +64,62 @@ func extractJSONStringField(content, field string) string {
// Format: {"image_key": "img_xxx"} // Format: {"image_key": "img_xxx"}
func extractImageKey(content string) string { return extractJSONStringField(content, "image_key") } func extractImageKey(content string) string { return extractJSONStringField(content, "image_key") }
// extractPostImageKeys extracts all image_key values from a Feishu post (rich text)
// message. Post messages have nested arrays of elements where images appear as
// {"tag":"img","image_key":"img_xxx"}.
func extractPostImageKeys(rawContent string) []string {
if rawContent == "" {
return nil
}
var post map[string]json.RawMessage
if err := json.Unmarshal([]byte(rawContent), &post); err != nil {
return nil
}
var keys []string
seen := make(map[string]struct{})
collectFromRows := func(contentRaw json.RawMessage) {
var rows [][]map[string]any
if err := json.Unmarshal(contentRaw, &rows); err != nil {
return
}
for _, row := range rows {
for _, elem := range row {
if tag, _ := elem["tag"].(string); tag == "img" {
if ik, _ := elem["image_key"].(string); ik != "" {
if _, dup := seen[ik]; !dup {
seen[ik] = struct{}{}
keys = append(keys, ik)
}
}
}
}
}
}
// Flat format: {"title":"...", "content":[[...]]}
if contentRaw, ok := post["content"]; ok {
collectFromRows(contentRaw)
}
// Localized format: {"zh_cn": {"title":"...", "content":[[...]]}, ...}
for _, raw := range post {
var locale map[string]json.RawMessage
if err := json.Unmarshal(raw, &locale); err != nil {
continue
}
contentRaw, ok := locale["content"]
if !ok {
continue
}
collectFromRows(contentRaw)
}
return keys
}
// extractFileKey extracts the file_key from a Feishu file/audio message content JSON. // extractFileKey extracts the file_key from a Feishu file/audio message content JSON.
// Format: {"file_key": "file_xxx", "file_name": "...", ...} // Format: {"file_key": "file_xxx", "file_name": "...", ...}
func extractFileKey(content string) string { return extractJSONStringField(content, "file_key") } func extractFileKey(content string) string { return extractJSONStringField(content, "file_key") }

View file

@ -291,6 +291,100 @@ func TestStripMentionPlaceholders(t *testing.T) {
} }
} }
func TestExtractPostImageKeys(t *testing.T) {
tests := []struct {
name string
content string
want []string
}{
{
name: "empty content",
content: "",
want: nil,
},
{
name: "invalid JSON",
content: "not json",
want: nil,
},
{
name: "post with no images",
content: `{"zh_cn":{"title":"Title","content":[[{"tag":"text","text":"hello"}]]}}`,
want: nil,
},
{
name: "post with one image",
content: `{"zh_cn":{"title":"","content":[[{"tag":"img","image_key":"img_v3_001"}]]}}`,
want: []string{"img_v3_001"},
},
{
name: "post with multiple images",
content: `{"zh_cn":{"title":"","content":[[{"tag":"text","text":"see"},{"tag":"img","image_key":"img_001"}],[{"tag":"img","image_key":"img_002"}]]}}`,
want: []string{"img_001", "img_002"},
},
{
name: "post with text and image mixed in row",
content: `{"zh_cn":{"title":"","content":[[{"tag":"text","text":"hi"},{"tag":"img","image_key":"img_mix"}]]}}`,
want: []string{"img_mix"},
},
{
name: "en_us locale",
content: `{"en_us":{"title":"","content":[[{"tag":"img","image_key":"img_en"}]]}}`,
want: []string{"img_en"},
},
{
name: "multiple locales with distinct images",
content: `{"zh_cn":{"title":"","content":[[{"tag":"img","image_key":"img_zh"}]]},"en_us":{"title":"","content":[[{"tag":"img","image_key":"img_en"}]]}}`,
want: []string{"img_zh", "img_en"},
},
{
name: "duplicate image_key across locales is deduplicated",
content: `{"zh_cn":{"title":"","content":[[{"tag":"img","image_key":"img_same"}]]},"en_us":{"title":"","content":[[{"tag":"img","image_key":"img_same"}]]}}`,
want: []string{"img_same"},
},
{
name: "image with empty image_key",
content: `{"zh_cn":{"title":"","content":[[{"tag":"img","image_key":""}]]}}`,
want: nil,
},
{
name: "flat format without locale wrapper",
content: `{"title":"","content":[[{"tag":"img","image_key":"img_v3_flat","width":1826,"height":338}],[{"tag":"text","text":" check this image","style":[]}]]}`,
want: []string{"img_v3_flat"},
},
{
name: "flat format multiple images",
content: `{"title":"","content":[[{"tag":"img","image_key":"img_flat_1"}],[{"tag":"img","image_key":"img_flat_2"},{"tag":"text","text":"desc"}]]}`,
want: []string{"img_flat_1", "img_flat_2"},
},
{
name: "flat format no images",
content: `{"title":"Test","content":[[{"tag":"text","text":"just text"}]]}`,
want: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := extractPostImageKeys(tt.content)
if len(got) != len(tt.want) {
t.Errorf("extractPostImageKeys() = %v, want %v", got, tt.want)
return
}
// Use set comparison to avoid map iteration order dependency
gotSet := make(map[string]bool, len(got))
for _, v := range got {
gotSet[v] = true
}
for _, v := range tt.want {
if !gotSet[v] {
t.Errorf("extractPostImageKeys() missing expected key %q; got %v", v, got)
}
}
})
}
}
func TestExtractCardImageKeys(t *testing.T) { func TestExtractCardImageKeys(t *testing.T) {
tests := []struct { tests := []struct {
name string name string

View file

@ -803,6 +803,14 @@ func (c *FeishuChannel) downloadInboundMedia(
refs = append(refs, ref) refs = append(refs, ref)
} }
case larkim.MsgTypePost:
for _, imageKey := range extractPostImageKeys(rawContent) {
ref := c.downloadResource(ctx, messageID, imageKey, "image", ".jpg", store, scope)
if ref != "" {
refs = append(refs, ref)
}
}
case larkim.MsgTypeInteractive: case larkim.MsgTypeInteractive:
// Extract and download images embedded in interactive cards // Extract and download images embedded in interactive cards
feishuKeys, _ := extractCardImageKeys(rawContent) feishuKeys, _ := extractCardImageKeys(rawContent)
@ -842,12 +850,41 @@ func (c *FeishuChannel) downloadInboundMedia(
// downloadResource downloads a message resource (image/file) from Feishu, // downloadResource downloads a message resource (image/file) from Feishu,
// writes it to the project media directory, and stores the reference in MediaStore. // writes it to the project media directory, and stores the reference in MediaStore.
// fallbackExt (e.g. ".jpg") is appended when the resolved filename has no extension. // fallbackExt (e.g. ".jpg") is appended when the resolved filename has no extension.
//
// For image resources, if the primary MessageResource.Get API fails (which
// requires im:message or im:message:readonly scope), a fallback to the
// Image.Get API (which requires im:resource scope) is attempted. This ensures
// image downloads succeed regardless of which permission the user has granted.
func (c *FeishuChannel) downloadResource( func (c *FeishuChannel) downloadResource(
ctx context.Context, ctx context.Context,
messageID, fileKey, resourceType, fallbackExt string, messageID, fileKey, resourceType, fallbackExt string,
store media.MediaStore, store media.MediaStore,
scope string, scope string,
) string { ) string {
file, filename := c.fetchResourceData(ctx, messageID, fileKey, resourceType)
if file == nil {
return ""
}
if closer, ok := file.(io.Closer); ok {
defer closer.Close()
}
if filename == "" {
filename = fileKey
}
if filepath.Ext(filename) == "" && fallbackExt != "" {
filename += fallbackExt
}
return c.storeResourceFile(ctx, messageID, fileKey, filename, file, store, scope)
}
// fetchResourceData tries to download a resource from Feishu, first via
// MessageResource.Get, then falling back to Image.Get for image resources.
func (c *FeishuChannel) fetchResourceData(
ctx context.Context,
messageID, fileKey, resourceType string,
) (io.Reader, string) {
req := larkim.NewGetMessageResourceReqBuilder(). req := larkim.NewGetMessageResourceReqBuilder().
MessageId(messageID). MessageId(messageID).
FileKey(fileKey). FileKey(fileKey).
@ -855,41 +892,80 @@ func (c *FeishuChannel) downloadResource(
Build() Build()
resp, err := c.client.Im.V1.MessageResource.Get(ctx, req) resp, err := c.client.Im.V1.MessageResource.Get(ctx, req)
if err == nil && resp.Success() && resp.File != nil {
return resp.File, resp.FileName
}
if err != nil { if err != nil {
logger.ErrorCF("feishu", "Failed to download resource", map[string]any{ logger.WarnCF("feishu", "MessageResource.Get failed", map[string]any{
"message_id": messageID, "message_id": messageID,
"file_key": fileKey, "file_key": fileKey,
"error": err.Error(), "error": err.Error(),
}) })
return "" } else if !resp.Success() {
c.invalidateTokenOnAuthError(resp.Code)
logger.WarnCF("feishu", "MessageResource.Get api error", map[string]any{
"message_id": messageID,
"file_key": fileKey,
"code": resp.Code,
"msg": resp.Msg,
})
} else {
logger.WarnCF("feishu", "MessageResource.Get returned empty file body", map[string]any{
"message_id": messageID,
"file_key": fileKey,
})
}
if resourceType != "image" {
return nil, ""
}
return c.fetchImageDirect(ctx, fileKey)
}
// fetchImageDirect downloads an image using the Image.Get API
// (/open-apis/im/v1/images/:image_key), which requires the im:resource scope.
func (c *FeishuChannel) fetchImageDirect(ctx context.Context, imageKey string) (io.Reader, string) {
req := larkim.NewGetImageReqBuilder().
ImageKey(imageKey).
Build()
resp, err := c.client.Im.V1.Image.Get(ctx, req)
if err != nil {
logger.ErrorCF("feishu", "Image.Get fallback failed", map[string]any{
"image_key": imageKey,
"error": err.Error(),
})
return nil, ""
} }
if !resp.Success() { if !resp.Success() {
c.invalidateTokenOnAuthError(resp.Code) c.invalidateTokenOnAuthError(resp.Code)
logger.ErrorCF("feishu", "Resource download api error", map[string]any{ logger.ErrorCF("feishu", "Image.Get fallback api error", map[string]any{
"code": resp.Code, "image_key": imageKey,
"msg": resp.Msg, "code": resp.Code,
"msg": resp.Msg,
}) })
return "" return nil, ""
} }
if resp.File == nil { if resp.File == nil {
return "" return nil, ""
}
// Safely close the underlying reader if it implements io.Closer (e.g. HTTP response body).
if closer, ok := resp.File.(io.Closer); ok {
defer closer.Close()
} }
filename := resp.FileName logger.DebugCF("feishu", "Image downloaded via Image.Get fallback", map[string]any{
if filename == "" { "image_key": imageKey,
filename = fileKey })
} return resp.File, resp.FileName
// If filename still has no extension, append the fallback (like Telegram's ext parameter). }
if filepath.Ext(filename) == "" && fallbackExt != "" {
filename += fallbackExt
}
// Write to the shared picoclaw_media directory using a unique name to avoid collisions. // storeResourceFile writes downloaded resource data to disk and registers it in the MediaStore.
func (c *FeishuChannel) storeResourceFile(
ctx context.Context,
messageID, fileKey, filename string,
file io.Reader,
store media.MediaStore,
scope string,
) string {
mediaDir := media.TempDir() mediaDir := media.TempDir()
if mkdirErr := os.MkdirAll(mediaDir, 0o700); mkdirErr != nil { if mkdirErr := os.MkdirAll(mediaDir, 0o700); mkdirErr != nil {
logger.ErrorCF("feishu", "Failed to create media directory", map[string]any{ logger.ErrorCF("feishu", "Failed to create media directory", map[string]any{
@ -908,7 +984,7 @@ func (c *FeishuChannel) downloadResource(
return "" return ""
} }
if _, copyErr := io.Copy(out, resp.File); copyErr != nil { if _, copyErr := io.Copy(out, file); copyErr != nil {
out.Close() out.Close()
os.Remove(localPath) os.Remove(localPath)
logger.ErrorCF("feishu", "Failed to write resource to file", map[string]any{ logger.ErrorCF("feishu", "Failed to write resource to file", map[string]any{
@ -943,8 +1019,8 @@ func appendMediaTags(content, messageType string, mediaRefs []string) string {
return content return content
} }
// Don't append tags to JSON content (interactive cards) - would produce invalid JSON // Don't append tags to JSON content - would produce invalid JSON
if messageType == larkim.MsgTypeInteractive { if messageType == larkim.MsgTypeInteractive || messageType == larkim.MsgTypePost {
return content return content
} }

View file

@ -180,6 +180,13 @@ func TestAppendMediaTags(t *testing.T) {
mediaRefs: []string{"ref1"}, mediaRefs: []string{"ref1"},
want: `{"schema":"2.0","body":{"elements":[{"tag":"img","img_key":"img_123"}]}}`, want: `{"schema":"2.0","body":{"elements":[{"tag":"img","img_key":"img_123"}]}}`,
}, },
{
name: "post message with images returns content unchanged",
content: `{"zh_cn":{"title":"","content":[[{"tag":"img","image_key":"img_001"}]]}}`,
messageType: "post",
mediaRefs: []string{"ref1"},
want: `{"zh_cn":{"title":"","content":[[{"tag":"img","image_key":"img_001"}]]}}`,
},
} }
for _, tt := range tests { for _, tt := range tests {

View file

@ -147,10 +147,10 @@ func (t *LoadImageTool) Execute(ctx context.Context, args map[string]any) *ToolR
return ErrorResult(fmt.Sprintf("failed to register image in media store: %v", err)) return ErrorResult(fmt.Sprintf("failed to register image in media store: %v", err))
} }
// Build the tool result text. The media:// ref will be picked up by // Build the tool result text. The media:// ref in Media will be picked
// resolveMediaRefs in loop_media.go and converted to a base64 data URL // up by resolveMediaRefs in agent_media.go and base64-encoded for tool
// before the next LLM call, exactly like channel-received images. // result messages (role="tool"), so the LLM can see the image content.
msg := fmt.Sprintf("Image loaded: %s\n[image: %s]", filename, ref) msg := fmt.Sprintf("Image loaded: %s\n[image: photo]", filename)
return &ToolResult{ return &ToolResult{
ForLLM: msg, ForLLM: msg,

View file

@ -135,9 +135,10 @@ func TestLoadImage_SuccessPath(t *testing.T) {
t.Errorf("expected ForLLM to contain '[image:' marker, got: %s", result.ForLLM) t.Errorf("expected ForLLM to contain '[image:' marker, got: %s", result.ForLLM)
} }
// 4. ForLLM should also contain the media:// ref // 4. ForLLM should contain the generic [image: photo] placeholder
if !strings.Contains(result.ForLLM, result.Media[0]) { // (resolveMediaRefs will replace it with the actual path later)
t.Errorf("expected ForLLM to contain media ref %q, got: %s", result.Media[0], result.ForLLM) if !strings.Contains(result.ForLLM, "[image: photo]") {
t.Errorf("expected ForLLM to contain '[image: photo]' placeholder, got: %s", result.ForLLM)
} }
// 5. Verify the ref is resolvable in the store // 5. Verify the ref is resolvable in the store