feishu: support local image upload and send via im/v1/image/create; parse Markdown image syntax
This commit is contained in:
parent
b704ccdb1d
commit
ecd5fb5c00
3 changed files with 321 additions and 15 deletions
|
|
@ -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 图片语法(如 ``),会提取并发送图片。
|
||||
- 同一条消息里如果同时包含文本和 Markdown 图片,文本会先发送,再发送图片。
|
||||
|
||||
支持格式:`jpg/jpeg/png/webp/gif/tiff/bmp/ico`。
|
||||
|
|
|
|||
|
|
@ -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) handleMessageReceive(_ context.Context, event *larkim.P2MessageReceiveV1) error {
|
||||
if event == nil || event.Event == nil || event.Event.Message == 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
|
||||
}
|
||||
|
||||
message := event.Event.Message
|
||||
sender := event.Event.Sender
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
|
|
|||
68
pkg/channels/feishu_64_test.go
Normal file
68
pkg/channels/feishu_64_test.go
Normal 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("")
|
||||
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")
|
||||
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
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue