2026-02-20 15:25:44 +00:00
|
|
|
package feishu
|
|
|
|
|
|
feat(feishu): enhance channel with markdown cards, media, mentions, and editing
Upgrade the Feishu channel from basic text-only to full feature parity with
Telegram/Discord: interactive card messages with markdown rendering, message
editing (MessageEditor), placeholder messages (PlaceholderCapable), emoji
reactions (ReactionCapable), and inbound/outbound media support (MediaSender).
Also add @mention detection with lazy bot open_id discovery, group trigger
filtering with mention awareness, and multi-type inbound message parsing
(text, post, image, file, audio, video).
2026-03-02 16:49:11 +00:00
|
|
|
import (
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"regexp"
|
|
|
|
|
"strings"
|
|
|
|
|
|
|
|
|
|
larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
|
2026-04-01 04:21:21 +00:00
|
|
|
|
|
|
|
|
"github.com/sipeed/picoclaw/pkg/channels"
|
feat(feishu): enhance channel with markdown cards, media, mentions, and editing
Upgrade the Feishu channel from basic text-only to full feature parity with
Telegram/Discord: interactive card messages with markdown rendering, message
editing (MessageEditor), placeholder messages (PlaceholderCapable), emoji
reactions (ReactionCapable), and inbound/outbound media support (MediaSender).
Also add @mention detection with lazy bot open_id discovery, group trigger
filtering with mention awareness, and multi-type inbound message parsing
(text, post, image, file, audio, video).
2026-03-02 16:49:11 +00:00
|
|
|
)
|
|
|
|
|
|
2026-03-03 08:43:04 +00:00
|
|
|
// mentionPlaceholderRegex matches @_user_N placeholders inserted by Feishu for mentions.
|
|
|
|
|
var mentionPlaceholderRegex = regexp.MustCompile(`@_user_\d+`)
|
|
|
|
|
|
2026-02-20 15:25:44 +00:00
|
|
|
// stringValue safely dereferences a *string pointer.
|
|
|
|
|
func stringValue(v *string) string {
|
|
|
|
|
if v == nil {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
return *v
|
|
|
|
|
}
|
feat(feishu): enhance channel with markdown cards, media, mentions, and editing
Upgrade the Feishu channel from basic text-only to full feature parity with
Telegram/Discord: interactive card messages with markdown rendering, message
editing (MessageEditor), placeholder messages (PlaceholderCapable), emoji
reactions (ReactionCapable), and inbound/outbound media support (MediaSender).
Also add @mention detection with lazy bot open_id discovery, group trigger
filtering with mention awareness, and multi-type inbound message parsing
(text, post, image, file, audio, video).
2026-03-02 16:49:11 +00:00
|
|
|
|
|
|
|
|
// buildMarkdownCard builds a Feishu Interactive Card JSON 2.0 string with markdown content.
|
|
|
|
|
// JSON 2.0 cards support full CommonMark standard markdown syntax.
|
|
|
|
|
func buildMarkdownCard(content string) (string, error) {
|
|
|
|
|
card := map[string]any{
|
|
|
|
|
"schema": "2.0",
|
|
|
|
|
"body": map[string]any{
|
|
|
|
|
"elements": []map[string]any{
|
|
|
|
|
{
|
|
|
|
|
"tag": "markdown",
|
|
|
|
|
"content": content,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
data, err := json.Marshal(card)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return "", err
|
|
|
|
|
}
|
|
|
|
|
return string(data), nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-03 08:43:04 +00:00
|
|
|
// extractJSONStringField unmarshals content as JSON and returns the value of the given string field.
|
|
|
|
|
// Returns "" if the content is invalid JSON or the field is missing/empty.
|
|
|
|
|
func extractJSONStringField(content, field string) string {
|
|
|
|
|
var m map[string]json.RawMessage
|
|
|
|
|
if err := json.Unmarshal([]byte(content), &m); err != nil {
|
|
|
|
|
return ""
|
feat(feishu): enhance channel with markdown cards, media, mentions, and editing
Upgrade the Feishu channel from basic text-only to full feature parity with
Telegram/Discord: interactive card messages with markdown rendering, message
editing (MessageEditor), placeholder messages (PlaceholderCapable), emoji
reactions (ReactionCapable), and inbound/outbound media support (MediaSender).
Also add @mention detection with lazy bot open_id discovery, group trigger
filtering with mention awareness, and multi-type inbound message parsing
(text, post, image, file, audio, video).
2026-03-02 16:49:11 +00:00
|
|
|
}
|
2026-03-03 08:43:04 +00:00
|
|
|
raw, ok := m[field]
|
|
|
|
|
if !ok {
|
feat(feishu): enhance channel with markdown cards, media, mentions, and editing
Upgrade the Feishu channel from basic text-only to full feature parity with
Telegram/Discord: interactive card messages with markdown rendering, message
editing (MessageEditor), placeholder messages (PlaceholderCapable), emoji
reactions (ReactionCapable), and inbound/outbound media support (MediaSender).
Also add @mention detection with lazy bot open_id discovery, group trigger
filtering with mention awareness, and multi-type inbound message parsing
(text, post, image, file, audio, video).
2026-03-02 16:49:11 +00:00
|
|
|
return ""
|
|
|
|
|
}
|
2026-03-03 08:43:04 +00:00
|
|
|
var s string
|
|
|
|
|
if err := json.Unmarshal(raw, &s); err != nil {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
return s
|
feat(feishu): enhance channel with markdown cards, media, mentions, and editing
Upgrade the Feishu channel from basic text-only to full feature parity with
Telegram/Discord: interactive card messages with markdown rendering, message
editing (MessageEditor), placeholder messages (PlaceholderCapable), emoji
reactions (ReactionCapable), and inbound/outbound media support (MediaSender).
Also add @mention detection with lazy bot open_id discovery, group trigger
filtering with mention awareness, and multi-type inbound message parsing
(text, post, image, file, audio, video).
2026-03-02 16:49:11 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-03 08:43:04 +00:00
|
|
|
// extractImageKey extracts the image_key from a Feishu image message content JSON.
|
|
|
|
|
// Format: {"image_key": "img_xxx"}
|
|
|
|
|
func extractImageKey(content string) string { return extractJSONStringField(content, "image_key") }
|
|
|
|
|
|
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>
2026-04-30 03:08:00 +00:00
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
|
feat(feishu): enhance channel with markdown cards, media, mentions, and editing
Upgrade the Feishu channel from basic text-only to full feature parity with
Telegram/Discord: interactive card messages with markdown rendering, message
editing (MessageEditor), placeholder messages (PlaceholderCapable), emoji
reactions (ReactionCapable), and inbound/outbound media support (MediaSender).
Also add @mention detection with lazy bot open_id discovery, group trigger
filtering with mention awareness, and multi-type inbound message parsing
(text, post, image, file, audio, video).
2026-03-02 16:49:11 +00:00
|
|
|
// extractFileKey extracts the file_key from a Feishu file/audio message content JSON.
|
|
|
|
|
// Format: {"file_key": "file_xxx", "file_name": "...", ...}
|
2026-03-03 08:43:04 +00:00
|
|
|
func extractFileKey(content string) string { return extractJSONStringField(content, "file_key") }
|
feat(feishu): enhance channel with markdown cards, media, mentions, and editing
Upgrade the Feishu channel from basic text-only to full feature parity with
Telegram/Discord: interactive card messages with markdown rendering, message
editing (MessageEditor), placeholder messages (PlaceholderCapable), emoji
reactions (ReactionCapable), and inbound/outbound media support (MediaSender).
Also add @mention detection with lazy bot open_id discovery, group trigger
filtering with mention awareness, and multi-type inbound message parsing
(text, post, image, file, audio, video).
2026-03-02 16:49:11 +00:00
|
|
|
|
|
|
|
|
// extractFileName extracts the file_name from a Feishu file message content JSON.
|
2026-03-03 08:43:04 +00:00
|
|
|
func extractFileName(content string) string { return extractJSONStringField(content, "file_name") }
|
feat(feishu): enhance channel with markdown cards, media, mentions, and editing
Upgrade the Feishu channel from basic text-only to full feature parity with
Telegram/Discord: interactive card messages with markdown rendering, message
editing (MessageEditor), placeholder messages (PlaceholderCapable), emoji
reactions (ReactionCapable), and inbound/outbound media support (MediaSender).
Also add @mention detection with lazy bot open_id discovery, group trigger
filtering with mention awareness, and multi-type inbound message parsing
(text, post, image, file, audio, video).
2026-03-02 16:49:11 +00:00
|
|
|
|
|
|
|
|
// stripMentionPlaceholders removes @_user_N placeholders from the text content.
|
|
|
|
|
// These are inserted by Feishu when users @mention someone in a message.
|
|
|
|
|
func stripMentionPlaceholders(content string, mentions []*larkim.MentionEvent) string {
|
|
|
|
|
if len(mentions) == 0 {
|
|
|
|
|
return content
|
|
|
|
|
}
|
|
|
|
|
for _, m := range mentions {
|
|
|
|
|
if m.Key != nil && *m.Key != "" {
|
|
|
|
|
content = strings.ReplaceAll(content, *m.Key, "")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// Also clean up any remaining @_user_N patterns
|
|
|
|
|
content = mentionPlaceholderRegex.ReplaceAllString(content, "")
|
|
|
|
|
return strings.TrimSpace(content)
|
|
|
|
|
}
|
Feat/feishu card parsing (#1534)
* feat(feishu): add interactive card message parsing
Add support for parsing inbound Feishu interactive card messages.
When a user sends a card message, the text content is now extracted
and passed to the LLM for processing.
- Add extractCardText() to recursively extract text from card JSON
- Support both JSON 1.0 (legacy) and JSON 2.0 schema formats
- Handle nested elements: header, body, actions, columns
- Extract text from markdown, lark_md, and plain_text elements
- Add comprehensive unit tests for card parsing
Fixes #<issue_number>
💘 Generated with Crush
Assisted-by: GLM-5 via Crush <crush@charm.land>
* feat(feishu): extract and download images from interactive cards
When receiving interactive card messages, extract embedded images
(img_key, src, icon_key) and download them for LLM processing.
- Add extractCardImageKeys() to recursively extract image keys from card JSON
- Support img elements (img_key, src) and icon elements (icon_key)
- Update downloadInboundMedia() to handle MsgTypeInteractive
- Add comprehensive unit tests for image extraction
Images are downloaded and stored via MediaStore, then appended to
the message content as [image: photo] tags for LLM visibility.
💘 Generated with Crush
Assisted-by: GLM-5 via Crush <crush@charm.land>
* fix(feishu): simplify card parsing - pass raw JSON, only extract images
Address review feedback: text extraction cannot exhaustively handle all
card formats (i18n_elements, div.fields, etc.). Pass raw JSON to LLM
instead - same approach as MsgTypePost. Only image extraction remains
as images must be downloaded for LLM to process.
- Remove extractCardText() and helper functions
- extractContent() now returns raw JSON for MsgTypeInteractive
- Keep extractCardImageKeys() for downloading embedded images
- Update tests to expect raw JSON for interactive cards
* fix(feishu): don't append media tags to interactive card JSON
Appending media tags like "[attachment]" to raw JSON content produces
invalid JSON format. For interactive cards, the JSON already contains
image information and media refs are downloaded separately.
- Skip appendMediaTags for MsgTypeInteractive to preserve valid JSON
- Add test case for interactive card with images
* fix(feishu): filter out external URLs from card image extraction
Only Feishu-hosted image keys (img_xxx, icon_xxx) can be downloaded via
the Feishu API. External URLs in src field (https://...) should be
filtered out to avoid download failures.
- Add isFeishuImageKey() to detect Feishu-hosted keys vs external URLs
- Update extractImageKeysRecursive to skip external URLs in src field
- Add tests for external URL filtering and mixed scenarios
* feat(feishu): support downloading external images from interactive cards
Previously only Feishu-hosted images (img_key, icon_key) could be
downloaded. Now external URLs in src field are also downloaded via
HTTP and made available to the LLM.
- extractCardImageKeys now returns two slices: Feishu keys and external URLs
- Add downloadExternalImage to download images from HTTP URLs
- Update downloadInboundMedia to handle both Feishu API and HTTP downloads
- Update tests for new function signature
* fix(feishu): use HTTP client with timeout for external image downloads
Replaced http.DefaultClient with a client that has a 30-second timeout
to prevent hanging on unresponsive external URLs.
Generated with Crush
Assisted-by: GLM-5 via Crush <crush@charm.land>
* fix(feishu): resolve lint errors for shadow and formatting
- Rename err variables to avoid shadowing in downloadExternalImage
- Fix struct field alignment in TestExtractCardImageKeys
Generated with Crush
Assisted-by: GLM-5 via Crush <crush@charm.land>
* refactor(feishu): pass external image URLs to LLM instead of downloading
Instead of downloading external images from interactive cards, pass
the URLs directly to LLM. This reduces network overhead and lets
vision-capable models fetch images as needed.
- Remove downloadExternalImage function
- Append external URLs to card content for LLM processing
- Only download Feishu-hosted images via API
💘 Generated with Crush
Assisted-by: GLM-5 via Crush <crush@charm.land>
* fix(feishu): add blank line between functions for gci formatting
* fix(feishu): keep interactive card content as valid JSON
2026-03-20 04:59:43 +00:00
|
|
|
|
|
|
|
|
// 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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-01 04:21:21 +00:00
|
|
|
|
|
|
|
|
// VoiceCapabilities returns the voice capabilities of the channel.
|
|
|
|
|
func (c *FeishuChannel) VoiceCapabilities() channels.VoiceCapabilities {
|
|
|
|
|
return channels.VoiceCapabilities{ASR: true, TTS: true}
|
|
|
|
|
}
|