feishu: support local image upload and send via im/v1/image/create; parse Markdown image syntax

This commit is contained in:
westwind027 2026-02-28 17:27:30 +08:00
parent b704ccdb1d
commit ecd5fb5c00
3 changed files with 321 additions and 15 deletions

View file

@ -35,3 +35,13 @@
3. 配置事件订阅和Webhook URL
4. 设置加密(可选,生产环境建议启用)
5. 将 App ID、App Secret、Encrypt Key 和 Verification Token(如果启用加密) 填入配置文件中
## 发送本地图片
飞书频道现已支持通过 `im/v1/image/create` 上传本地图片并发送图片消息。
- 如果发送内容是本地图片路径(如 `/tmp/a.png``file:///tmp/a.png`),会自动上传并发送图片。
- 如果发送内容包含 Markdown 图片语法(如 `![img](/tmp/a.png)`),会提取并发送图片。
- 同一条消息里如果同时包含文本和 Markdown 图片,文本会先发送,再发送图片。
支持格式:`jpg/jpeg/png/webp/gif/tiff/bmp/ico`

View file

@ -6,9 +6,15 @@ import (
"context"
"encoding/json"
"fmt"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"time"
larkevent "github.com/larksuite/oapi-sdk-go/v3/event"
lark "github.com/larksuite/oapi-sdk-go/v3"
larkdispatcher "github.com/larksuite/oapi-sdk-go/v3/event/dispatcher"
larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
@ -20,6 +26,8 @@ import (
"github.com/sipeed/picoclaw/pkg/utils"
)
var feishuMarkdownImageRe = regexp.MustCompile(`!\[[^\]]*\]\(([^)]+)\)`)
type FeishuChannel struct {
*BaseChannel
config config.FeishuConfig
@ -30,6 +38,16 @@ type FeishuChannel struct {
cancel context.CancelFunc
}
type feishuEventEnvelope struct {
Header *struct {
EventType string `json:"event_type"`
} `json:"header"`
Event *struct {
Sender *larkim.EventSender `json:"sender"`
Message *larkim.EventMessage `json:"message"`
} `json:"event"`
}
func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChannel, error) {
base := NewBaseChannel("feishu", cfg, bus, cfg.AllowFrom)
@ -46,7 +64,8 @@ func (c *FeishuChannel) Start(ctx context.Context) error {
}
dispatcher := larkdispatcher.NewEventDispatcher(c.config.VerificationToken, c.config.EncryptKey).
OnP2MessageReceiveV1(c.handleMessageReceive)
OnCustomizedEvent("im.message.receive_v1", c.handleMessageReceiveRaw).
OnCustomizedEvent("im.message.receive_v2", c.handleMessageReceiveRaw)
runCtx, cancel := context.WithCancel(ctx)
@ -97,17 +116,71 @@ func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
return fmt.Errorf("chat ID is empty")
}
payload, err := json.Marshal(map[string]string{"text": msg.Content})
if err != nil {
return fmt.Errorf("failed to marshal feishu content: %w", err)
textContent, imagePaths := splitFeishuOutboundContent(msg.Content)
if textContent == "" && len(imagePaths) == 0 {
return nil
}
if textContent != "" {
if err := c.sendFeishuTextMessage(ctx, msg.ChatID, textContent); err != nil {
return err
}
}
for _, imagePath := range imagePaths {
imageKey, err := c.uploadFeishuImage(ctx, imagePath)
if err != nil {
return fmt.Errorf("failed to upload feishu image %q: %w", imagePath, err)
}
if err := c.sendFeishuImageMessage(ctx, msg.ChatID, imageKey); err != nil {
return fmt.Errorf("failed to send feishu image %q: %w", imagePath, err)
}
logger.DebugCF("feishu", "Feishu image sent", map[string]any{
"chat_id": msg.ChatID,
"image_path": imagePath,
})
}
return nil
}
func (c *FeishuChannel) sendFeishuTextMessage(ctx context.Context, chatID, content string) error {
payload, err := json.Marshal(map[string]string{"text": content})
if err != nil {
return fmt.Errorf("failed to marshal feishu text content: %w", err)
}
if err := c.sendFeishuMessage(ctx, chatID, larkim.MsgTypeText, string(payload)); err != nil {
return err
}
logger.DebugCF("feishu", "Feishu text message sent", map[string]any{
"chat_id": chatID,
})
return nil
}
func (c *FeishuChannel) sendFeishuImageMessage(ctx context.Context, chatID, imageKey string) error {
payload, err := json.Marshal(map[string]string{"image_key": imageKey})
if err != nil {
return fmt.Errorf("failed to marshal feishu image content: %w", err)
}
return c.sendFeishuMessage(ctx, chatID, larkim.MsgTypeImage, string(payload))
}
func (c *FeishuChannel) sendFeishuMessage(ctx context.Context, chatID, msgType, content string) error {
req := larkim.NewCreateMessageReqBuilder().
ReceiveIdType(larkim.ReceiveIdTypeChatId).
Body(larkim.NewCreateMessageReqBodyBuilder().
ReceiveId(msg.ChatID).
MsgType(larkim.MsgTypeText).
Content(string(payload)).
ReceiveId(chatID).
MsgType(msgType).
Content(content).
Uuid(fmt.Sprintf("picoclaw-%d", time.Now().UnixNano())).
Build()).
Build()
@ -121,20 +194,66 @@ func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
return fmt.Errorf("feishu api error: code=%d msg=%s", resp.Code, resp.Msg)
}
logger.DebugCF("feishu", "Feishu message sent", map[string]any{
"chat_id": msg.ChatID,
return nil
}
func (c *FeishuChannel) uploadFeishuImage(ctx context.Context, imagePath string) (string, error) {
body, err := larkim.NewCreateImagePathReqBodyBuilder().
ImageType("message").
ImagePath(imagePath).
Build()
if err != nil {
return "", fmt.Errorf("failed to read image file: %w", err)
}
req := larkim.NewCreateImageReqBuilder().
Body(body).
Build()
resp, err := c.client.Im.V1.Image.Create(ctx, req)
if err != nil {
return "", fmt.Errorf("failed to upload image: %w", err)
}
if !resp.Success() {
return "", fmt.Errorf("feishu image api error: code=%d msg=%s", resp.Code, resp.Msg)
}
if resp.Data == nil || resp.Data.ImageKey == nil || *resp.Data.ImageKey == "" {
return "", fmt.Errorf("feishu image upload succeeded but image_key is empty")
}
return *resp.Data.ImageKey, nil
}
func (c *FeishuChannel) handleMessageReceiveRaw(_ context.Context, event *larkevent.EventReq) error {
if event == nil || len(event.Body) == 0 {
logger.WarnC("feishu", "Received empty custom Feishu event payload")
return nil
}
var envelope feishuEventEnvelope
if err := json.Unmarshal(event.Body, &envelope); err != nil {
logger.ErrorCF("feishu", "Failed to parse Feishu message event payload", map[string]any{
"error": err.Error(),
"payload": utils.Truncate(string(event.Body), 300),
})
return err
}
if envelope.Event == nil || envelope.Event.Message == nil {
eventType := ""
if envelope.Header != nil {
eventType = envelope.Header.EventType
}
logger.DebugCF("feishu", "Ignored Feishu event without message body", map[string]any{
"event_type": eventType,
})
return nil
}
func (c *FeishuChannel) handleMessageReceive(_ context.Context, event *larkim.P2MessageReceiveV1) error {
if event == nil || event.Event == nil || event.Event.Message == nil {
return nil
}
message := event.Event.Message
sender := event.Event.Sender
message := envelope.Event.Message
sender := envelope.Event.Sender
chatID := stringValue(message.ChatId)
if chatID == "" {
@ -225,3 +344,112 @@ func stringValue(v *string) string {
}
return *v
}
func splitFeishuOutboundContent(content string) (string, []string) {
trimmed := strings.TrimSpace(content)
if trimmed == "" {
return "", nil
}
imagePaths := make([]string, 0, 2)
cleaned := feishuMarkdownImageRe.ReplaceAllStringFunc(trimmed, func(segment string) string {
matches := feishuMarkdownImageRe.FindStringSubmatch(segment)
if len(matches) < 2 {
return segment
}
if imagePath, ok := normalizeFeishuLocalImagePath(matches[1]); ok {
imagePaths = append(imagePaths, imagePath)
return ""
}
return segment
})
cleaned = strings.TrimSpace(cleaned)
if len(imagePaths) == 0 {
if imagePath, ok := normalizeFeishuLocalImagePath(cleaned); ok {
return "", []string{imagePath}
}
}
if len(imagePaths) == 0 {
return cleaned, nil
}
return cleaned, dedupeStrings(imagePaths)
}
func normalizeFeishuLocalImagePath(candidate string) (string, bool) {
candidate = strings.TrimSpace(candidate)
candidate = strings.Trim(candidate, "\"'")
candidate = strings.Trim(candidate, "<>")
if candidate == "" {
return "", false
}
if strings.HasPrefix(strings.ToLower(candidate), "file://") {
parsed, err := url.Parse(candidate)
if err != nil || parsed.Scheme != "file" {
return "", false
}
if parsed.Host != "" && parsed.Host != "localhost" {
return "", false
}
path, err := url.PathUnescape(parsed.Path)
if err != nil {
return "", false
}
candidate = path
}
if candidate == "" {
return "", false
}
absPath, err := filepath.Abs(candidate)
if err != nil {
return "", false
}
info, err := os.Stat(absPath)
if err != nil || info.IsDir() {
return "", false
}
if !isFeishuSupportedImageFile(absPath) {
return "", false
}
return absPath, true
}
func isFeishuSupportedImageFile(path string) bool {
switch strings.ToLower(filepath.Ext(path)) {
case ".jpg", ".jpeg", ".png", ".webp", ".gif", ".tiff", ".tif", ".bmp", ".ico":
return true
default:
return false
}
}
func dedupeStrings(values []string) []string {
if len(values) <= 1 {
return values
}
seen := make(map[string]struct{}, len(values))
result := make([]string, 0, len(values))
for _, value := range values {
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
result = append(result, value)
}
return result
}

View file

@ -0,0 +1,68 @@
//go:build amd64 || arm64 || riscv64 || mips64 || ppc64
package channels
import (
"os"
"path/filepath"
"testing"
)
func TestSplitFeishuOutboundContent_MarkdownImageOnly(t *testing.T) {
imagePath := createFeishuTempImageFile(t, "test_image.png")
text, images := splitFeishuOutboundContent("![](" + imagePath + ")")
if text != "" {
t.Fatalf("expected empty text, got %q", text)
}
if len(images) != 1 || images[0] != imagePath {
t.Fatalf("expected one image path %q, got %#v", imagePath, images)
}
}
func TestSplitFeishuOutboundContent_TextAndMarkdownImage(t *testing.T) {
imagePath := createFeishuTempImageFile(t, "test_image.png")
text, images := splitFeishuOutboundContent("请查看图片\n![img](" + imagePath + ")")
if text != "请查看图片" {
t.Fatalf("expected text to be cleaned, got %q", text)
}
if len(images) != 1 || images[0] != imagePath {
t.Fatalf("expected one image path %q, got %#v", imagePath, images)
}
}
func TestSplitFeishuOutboundContent_FileURI(t *testing.T) {
imagePath := createFeishuTempImageFile(t, "test_image.jpeg")
text, images := splitFeishuOutboundContent("file://" + imagePath)
if text != "" {
t.Fatalf("expected empty text, got %q", text)
}
if len(images) != 1 || images[0] != imagePath {
t.Fatalf("expected one image path %q, got %#v", imagePath, images)
}
}
func TestSplitFeishuOutboundContent_UnsupportedFileAsText(t *testing.T) {
nonImagePath := createFeishuTempImageFile(t, "notes.txt")
text, images := splitFeishuOutboundContent(nonImagePath)
if text != nonImagePath {
t.Fatalf("expected text to keep original non-image path, got %q", text)
}
if len(images) != 0 {
t.Fatalf("expected no images, got %#v", images)
}
}
func createFeishuTempImageFile(t *testing.T, name string) string {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, name)
if err := os.WriteFile(path, []byte("test"), 0o600); err != nil {
t.Fatalf("failed to write temp file: %v", err)
}
return path
}