Merge branch 'main' into version

This commit is contained in:
Cytown
2026-03-22 02:45:09 +08:00
213 changed files with 19651 additions and 2878 deletions
+61
View File
@@ -84,3 +84,64 @@ func stripMentionPlaceholders(content string, mentions []*larkim.MentionEvent) s
content = mentionPlaceholderRegex.ReplaceAllString(content, "")
return strings.TrimSpace(content)
}
// extractCardImageKeys recursively extracts all image keys from a Feishu interactive card.
// Image keys are used to download images from Feishu API.
// Returns two slices: Feishu-hosted keys and external URLs.
func extractCardImageKeys(rawContent string) (feishuKeys []string, externalURLs []string) {
if rawContent == "" {
return nil, nil
}
var card map[string]any
if err := json.Unmarshal([]byte(rawContent), &card); err != nil {
return nil, nil
}
extractImageKeysRecursive(card, &feishuKeys, &externalURLs)
return feishuKeys, externalURLs
}
// isExternalURL returns true if the string is an external HTTP/HTTPS URL.
func isExternalURL(s string) bool {
return strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://")
}
// extractImageKeysRecursive traverses card structure to find all image keys.
// Collects both Feishu-hosted keys and external URLs separately.
func extractImageKeysRecursive(v any, feishuKeys, externalURLs *[]string) {
switch val := v.(type) {
case map[string]any:
// Check if this is an img element
if tag, ok := val["tag"].(string); ok {
switch tag {
case "img":
// Try img_key first (always Feishu-hosted)
if imgKey, ok := val["img_key"].(string); ok && imgKey != "" {
*feishuKeys = append(*feishuKeys, imgKey)
}
// Check src - could be Feishu key or external URL
if src, ok := val["src"].(string); ok && src != "" {
if isExternalURL(src) {
*externalURLs = append(*externalURLs, src)
} else {
*feishuKeys = append(*feishuKeys, src)
}
}
case "icon":
// Icon elements use icon_key
if iconKey, ok := val["icon_key"].(string); ok && iconKey != "" {
*feishuKeys = append(*feishuKeys, iconKey)
}
}
}
// Recurse into all nested structures
for _, child := range val {
extractImageKeysRecursive(child, feishuKeys, externalURLs)
}
case []any:
for _, item := range val {
extractImageKeysRecursive(item, feishuKeys, externalURLs)
}
}
}
+116
View File
@@ -290,3 +290,119 @@ func TestStripMentionPlaceholders(t *testing.T) {
})
}
}
func TestExtractCardImageKeys(t *testing.T) {
tests := []struct {
name string
content string
wantFeishuKeys []string
wantExternalURLs []string
}{
{
name: "empty content",
content: "",
wantFeishuKeys: nil,
wantExternalURLs: nil,
},
{
name: "invalid JSON",
content: "not json",
wantFeishuKeys: nil,
wantExternalURLs: nil,
},
{
name: "card with no images",
content: `{"schema":"2.0","body":{"elements":[{"tag":"markdown","content":"text"}]}}`,
wantFeishuKeys: nil,
wantExternalURLs: nil,
},
{
name: "single image with img_key",
content: `{"elements":[{"tag":"img","img_key":"img_abc123"}]}`,
wantFeishuKeys: []string{"img_abc123"},
wantExternalURLs: nil,
},
{
name: "single image with src as Feishu key",
content: `{"elements":[{"tag":"img","src":"img_xyz789"}]}`,
wantFeishuKeys: []string{"img_xyz789"},
wantExternalURLs: nil,
},
{
name: "multiple images",
content: `{"elements":[{"tag":"img","img_key":"img_1"},{"tag":"div","text":{"content":"text"}},{"tag":"img","img_key":"img_2"}]}`,
wantFeishuKeys: []string{"img_1", "img_2"},
wantExternalURLs: nil,
},
{
name: "nested image in columns",
content: `{"elements":[{"tag":"div","columns":[{"tag":"img","img_key":"img_col1"},{"tag":"img","img_key":"img_col2"}]}]}`,
wantFeishuKeys: []string{"img_col1", "img_col2"},
wantExternalURLs: nil,
},
{
name: "image in action",
content: `{"elements":[{"tag":"action","actions":[{"tag":"img","img_key":"img_action"}]}]}`,
wantFeishuKeys: []string{"img_action"},
wantExternalURLs: nil,
},
{
name: "icon element",
content: `{"elements":[{"tag":"icon","icon_key":"icon_123"}]}`,
wantFeishuKeys: []string{"icon_123"},
wantExternalURLs: nil,
},
{
name: "complex card with text and images",
content: `{"header":{"title":{"content":"Title"}},"elements":[{"tag":"div","text":{"content":"Description"}},{"tag":"img","img_key":"img_main"}]}`,
wantFeishuKeys: []string{"img_main"},
wantExternalURLs: nil,
},
{
name: "external URL in src",
content: `{"elements":[{"tag":"img","src":"https://example.com/image.png"}]}`,
wantFeishuKeys: nil,
wantExternalURLs: []string{"https://example.com/image.png"},
},
{
name: "mixed Feishu keys and external URLs",
content: `{"elements":[{"tag":"img","img_key":"img_feishu"},{"tag":"img","src":"https://cdn.example.com/external.jpg"},{"tag":"img","src":"img_another"}]}`,
wantFeishuKeys: []string{"img_feishu", "img_another"},
wantExternalURLs: []string{"https://cdn.example.com/external.jpg"},
},
{
name: "multiple external URLs",
content: `{"elements":[{"tag":"img","src":"https://a.com/1.png"},{"tag":"img","src":"http://b.com/2.jpg"}]}`,
wantFeishuKeys: nil,
wantExternalURLs: []string{"https://a.com/1.png", "http://b.com/2.jpg"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotFeishuKeys, gotExternalURLs := extractCardImageKeys(tt.content)
// Compare Feishu keys
if len(gotFeishuKeys) != len(tt.wantFeishuKeys) {
t.Errorf("extractCardImageKeys() feishuKeys = %v, want %v", gotFeishuKeys, tt.wantFeishuKeys)
return
}
for i, v := range gotFeishuKeys {
if v != tt.wantFeishuKeys[i] {
t.Errorf("extractCardImageKeys() feishuKeys[%d] = %q, want %q", i, v, tt.wantFeishuKeys[i])
}
}
// Compare external URLs
if len(gotExternalURLs) != len(tt.wantExternalURLs) {
t.Errorf("extractCardImageKeys() externalURLs = %v, want %v", gotExternalURLs, tt.wantExternalURLs)
return
}
for i, v := range gotExternalURLs {
if v != tt.wantExternalURLs[i] {
t.Errorf("extractCardImageKeys() externalURLs[%d] = %q, want %q", i, v, tt.wantExternalURLs[i])
}
}
})
}
}
+94 -2
View File
@@ -11,6 +11,7 @@ import (
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
@@ -129,6 +130,7 @@ func (c *FeishuChannel) Stop(ctx context.Context) error {
}
// Send sends a message using Interactive Card format for markdown rendering.
// Falls back to plain text message if card sending fails (e.g., table limit exceeded).
func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
if !c.IsRunning() {
return channels.ErrNotRunning
@@ -141,9 +143,38 @@ func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
// Build interactive card with markdown content
cardContent, err := buildMarkdownCard(msg.Content)
if err != nil {
return fmt.Errorf("feishu send: card build failed: %w", err)
// If card build fails, fall back to plain text
return c.sendText(ctx, msg.ChatID, msg.Content)
}
return c.sendCard(ctx, msg.ChatID, cardContent)
// First attempt: try sending as interactive card
err = c.sendCard(ctx, msg.ChatID, cardContent)
if err == nil {
return nil
}
// Check if error is due to card table limit (error code 11310)
// See: https://open.feishu.cn/document/server-docs/im-api/message-content-description/create_json
errMsg := err.Error()
isCardLimitError := strings.Contains(errMsg, "11310")
if isCardLimitError {
logger.WarnCF("feishu", "Card send failed (table limit), falling back to text message", map[string]any{
"chat_id": msg.ChatID,
"error": errMsg,
})
// Second attempt: fall back to plain text message
textErr := c.sendText(ctx, msg.ChatID, msg.Content)
if textErr == nil {
return nil
}
// If text also fails, return the text error
return textErr
}
// For other errors, return the original card error
return err
}
// EditMessage implements channels.MessageEditor.
@@ -393,6 +424,15 @@ func (c *FeishuChannel) handleMessageReceive(ctx context.Context, event *larkim.
mediaRefs = c.downloadInboundMedia(ctx, chatID, messageID, messageType, rawContent, store)
}
// For interactive cards, pass external image URLs via media refs.
// Keep content as valid raw JSON for downstream parsing.
if messageType == larkim.MsgTypeInteractive {
_, externalURLs := extractCardImageKeys(rawContent)
if len(externalURLs) > 0 {
mediaRefs = append(mediaRefs, externalURLs...)
}
}
// Append media tags to content (like Telegram does)
content = appendMediaTags(content, messageType, mediaRefs)
@@ -528,6 +568,10 @@ func extractContent(messageType, rawContent string) string {
// Pass raw JSON to LLM — structured rich text is more informative than flattened plain text
return rawContent
case larkim.MsgTypeInteractive:
// Pass raw JSON to LLM — structured card is more informative than flattened text
return rawContent
case larkim.MsgTypeImage:
// Image messages don't have text content
return ""
@@ -565,6 +609,18 @@ func (c *FeishuChannel) downloadInboundMedia(
refs = append(refs, ref)
}
case larkim.MsgTypeInteractive:
// Extract and download images embedded in interactive cards
feishuKeys, _ := extractCardImageKeys(rawContent)
// Download Feishu-hosted images via API
for _, imageKey := range feishuKeys {
ref := c.downloadResource(ctx, messageID, imageKey, "image", ".jpg", store, scope)
if ref != "" {
refs = append(refs, ref)
}
}
// External URLs are passed directly to LLM, not downloaded
case larkim.MsgTypeFile, larkim.MsgTypeAudio, larkim.MsgTypeMedia:
fileKey := extractFileKey(rawContent)
if fileKey == "" {
@@ -685,11 +741,18 @@ func (c *FeishuChannel) downloadResource(
}
// appendMediaTags appends media type tags to content (like Telegram's "[image: photo]").
// For interactive cards, media tags are not appended because content is raw JSON
// and appending would produce invalid JSON format.
func appendMediaTags(content, messageType string, mediaRefs []string) string {
if len(mediaRefs) == 0 {
return content
}
// Don't append tags to JSON content (interactive cards) - would produce invalid JSON
if messageType == larkim.MsgTypeInteractive {
return content
}
var tag string
switch messageType {
case larkim.MsgTypeImage:
@@ -738,6 +801,35 @@ func (c *FeishuChannel) sendCard(ctx context.Context, chatID, cardContent string
return nil
}
// sendText sends a plain text message to a chat (fallback when card fails).
func (c *FeishuChannel) sendText(ctx context.Context, chatID, text string) error {
content, _ := json.Marshal(map[string]string{"text": text})
req := larkim.NewCreateMessageReqBuilder().
ReceiveIdType(larkim.ReceiveIdTypeChatId).
Body(larkim.NewCreateMessageReqBodyBuilder().
ReceiveId(chatID).
MsgType(larkim.MsgTypeText).
Content(string(content)).
Build()).
Build()
resp, err := c.client.Im.V1.Message.Create(ctx, req)
if err != nil {
return fmt.Errorf("feishu send text: %w", channels.ErrTemporary)
}
if !resp.Success() {
return fmt.Errorf("feishu text api error (code=%d msg=%s): %w", resp.Code, resp.Msg, channels.ErrTemporary)
}
logger.DebugCF("feishu", "Feishu text message sent (fallback)", map[string]any{
"chat_id": chatID,
})
return nil
}
// sendImage uploads an image and sends it as a message.
func (c *FeishuChannel) sendImage(ctx context.Context, chatID string, file *os.File) error {
// Upload image to get image_key
+25
View File
@@ -75,6 +75,24 @@ func TestExtractContent(t *testing.T) {
rawContent: "",
want: "",
},
{
name: "interactive card returns raw JSON",
messageType: "interactive",
rawContent: `{"schema":"2.0","body":{"elements":[{"tag":"markdown","content":"Hello from card"}]}}`,
want: `{"schema":"2.0","body":{"elements":[{"tag":"markdown","content":"Hello from card"}]}}`,
},
{
name: "interactive card with complex structure returns raw JSON",
messageType: "interactive",
rawContent: `{"header":{"title":{"tag":"plain_text","content":"Title"}},"elements":[{"tag":"div","text":{"tag":"lark_md","content":"Card content"}}]}`,
want: `{"header":{"title":{"tag":"plain_text","content":"Title"}},"elements":[{"tag":"div","text":{"tag":"lark_md","content":"Card content"}}]}`,
},
{
name: "interactive card invalid JSON returns as-is",
messageType: "interactive",
rawContent: `not valid json`,
want: `not valid json`,
},
}
for _, tt := range tests {
@@ -151,6 +169,13 @@ func TestAppendMediaTags(t *testing.T) {
mediaRefs: []string{"ref1"},
want: "something [attachment]",
},
{
name: "interactive card with images returns content unchanged",
content: `{"schema":"2.0","body":{"elements":[{"tag":"img","img_key":"img_123"}]}}`,
messageType: "interactive",
mediaRefs: []string{"ref1"},
want: `{"schema":"2.0","body":{"elements":[{"tag":"img","img_key":"img_123"}]}}`,
},
}
for _, tt := range tests {