mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
fa1cb9cc74
- Consolidate extractImageKey/extractFileKey/extractFileName into shared extractJSONStringField helper to reduce code duplication - Move mentionPlaceholderRegex to package-level position after imports - Rename feishuCfg field to config for clarity within FeishuChannel - Replace @_user_1 heuristic with GET /open-apis/bot/v3/info API call at startup for reliable bot @mention detection - Fix double close on file handle in downloadResource by removing defer and using explicit close in both success and error paths - Add unit tests for common.go and feishu_64.go helpers (53 test cases)
819 lines
22 KiB
Go
819 lines
22 KiB
Go
//go:build amd64 || arm64 || riscv64 || mips64 || ppc64
|
|
|
|
package feishu
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"sync/atomic"
|
|
|
|
lark "github.com/larksuite/oapi-sdk-go/v3"
|
|
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
|
larkdispatcher "github.com/larksuite/oapi-sdk-go/v3/event/dispatcher"
|
|
larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
|
|
larkws "github.com/larksuite/oapi-sdk-go/v3/ws"
|
|
|
|
"github.com/sipeed/picoclaw/pkg/bus"
|
|
"github.com/sipeed/picoclaw/pkg/channels"
|
|
"github.com/sipeed/picoclaw/pkg/config"
|
|
"github.com/sipeed/picoclaw/pkg/identity"
|
|
"github.com/sipeed/picoclaw/pkg/logger"
|
|
"github.com/sipeed/picoclaw/pkg/media"
|
|
"github.com/sipeed/picoclaw/pkg/utils"
|
|
)
|
|
|
|
type FeishuChannel struct {
|
|
*channels.BaseChannel
|
|
config config.FeishuConfig
|
|
client *lark.Client
|
|
wsClient *larkws.Client
|
|
|
|
botOpenID atomic.Value // stores string; populated lazily for @mention detection
|
|
|
|
mu sync.Mutex
|
|
cancel context.CancelFunc
|
|
}
|
|
|
|
func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChannel, error) {
|
|
base := channels.NewBaseChannel("feishu", cfg, bus, cfg.AllowFrom,
|
|
channels.WithGroupTrigger(cfg.GroupTrigger),
|
|
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
|
|
)
|
|
|
|
ch := &FeishuChannel{
|
|
BaseChannel: base,
|
|
config: cfg,
|
|
client: lark.NewClient(cfg.AppID, cfg.AppSecret),
|
|
}
|
|
ch.SetOwner(ch)
|
|
return ch, nil
|
|
}
|
|
|
|
func (c *FeishuChannel) Start(ctx context.Context) error {
|
|
if c.config.AppID == "" || c.config.AppSecret == "" {
|
|
return fmt.Errorf("feishu app_id or app_secret is empty")
|
|
}
|
|
|
|
// Fetch bot open_id via API for reliable @mention detection.
|
|
if err := c.fetchBotOpenID(ctx); err != nil {
|
|
logger.ErrorCF("feishu", "Failed to fetch bot open_id, @mention detection may not work", map[string]any{
|
|
"error": err.Error(),
|
|
})
|
|
}
|
|
|
|
dispatcher := larkdispatcher.NewEventDispatcher(c.config.VerificationToken, c.config.EncryptKey).
|
|
OnP2MessageReceiveV1(c.handleMessageReceive)
|
|
|
|
runCtx, cancel := context.WithCancel(ctx)
|
|
|
|
c.mu.Lock()
|
|
c.cancel = cancel
|
|
c.wsClient = larkws.NewClient(
|
|
c.config.AppID,
|
|
c.config.AppSecret,
|
|
larkws.WithEventHandler(dispatcher),
|
|
)
|
|
wsClient := c.wsClient
|
|
c.mu.Unlock()
|
|
|
|
c.SetRunning(true)
|
|
logger.InfoC("feishu", "Feishu channel started (websocket mode)")
|
|
|
|
go func() {
|
|
if err := wsClient.Start(runCtx); err != nil {
|
|
logger.ErrorCF("feishu", "Feishu websocket stopped with error", map[string]any{
|
|
"error": err.Error(),
|
|
})
|
|
}
|
|
}()
|
|
|
|
return nil
|
|
}
|
|
|
|
func (c *FeishuChannel) Stop(ctx context.Context) error {
|
|
c.mu.Lock()
|
|
if c.cancel != nil {
|
|
c.cancel()
|
|
c.cancel = nil
|
|
}
|
|
c.wsClient = nil
|
|
c.mu.Unlock()
|
|
|
|
c.SetRunning(false)
|
|
logger.InfoC("feishu", "Feishu channel stopped")
|
|
return nil
|
|
}
|
|
|
|
// Send sends a message using Interactive Card format for markdown rendering.
|
|
func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
|
if !c.IsRunning() {
|
|
return channels.ErrNotRunning
|
|
}
|
|
|
|
if msg.ChatID == "" {
|
|
return fmt.Errorf("chat ID is empty: %w", channels.ErrSendFailed)
|
|
}
|
|
|
|
// Build interactive card with markdown content
|
|
cardContent, err := buildMarkdownCard(msg.Content)
|
|
if err != nil {
|
|
return fmt.Errorf("feishu send: card build failed: %w", err)
|
|
}
|
|
return c.sendCard(ctx, msg.ChatID, cardContent)
|
|
}
|
|
|
|
// EditMessage implements channels.MessageEditor.
|
|
// Uses Message.Patch to update an interactive card message.
|
|
func (c *FeishuChannel) EditMessage(ctx context.Context, chatID, messageID, content string) error {
|
|
cardContent, err := buildMarkdownCard(content)
|
|
if err != nil {
|
|
return fmt.Errorf("feishu edit: card build failed: %w", err)
|
|
}
|
|
|
|
req := larkim.NewPatchMessageReqBuilder().
|
|
MessageId(messageID).
|
|
Body(larkim.NewPatchMessageReqBodyBuilder().Content(cardContent).Build()).
|
|
Build()
|
|
|
|
resp, err := c.client.Im.V1.Message.Patch(ctx, req)
|
|
if err != nil {
|
|
return fmt.Errorf("feishu edit: %w", err)
|
|
}
|
|
if !resp.Success() {
|
|
return fmt.Errorf("feishu edit api error (code=%d msg=%s)", resp.Code, resp.Msg)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SendPlaceholder implements channels.PlaceholderCapable.
|
|
// Sends an interactive card with placeholder text and returns its message ID.
|
|
func (c *FeishuChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) {
|
|
if !c.config.Placeholder.Enabled {
|
|
logger.DebugCF("feishu", "Placeholder disabled, skipping", map[string]any{
|
|
"chat_id": chatID,
|
|
})
|
|
return "", nil
|
|
}
|
|
|
|
text := c.config.Placeholder.Text
|
|
if text == "" {
|
|
text = "Thinking..."
|
|
}
|
|
|
|
cardContent, err := buildMarkdownCard(text)
|
|
if err != nil {
|
|
return "", fmt.Errorf("feishu placeholder: card build failed: %w", err)
|
|
}
|
|
|
|
req := larkim.NewCreateMessageReqBuilder().
|
|
ReceiveIdType(larkim.ReceiveIdTypeChatId).
|
|
Body(larkim.NewCreateMessageReqBodyBuilder().
|
|
ReceiveId(chatID).
|
|
MsgType(larkim.MsgTypeInteractive).
|
|
Content(cardContent).
|
|
Build()).
|
|
Build()
|
|
|
|
resp, err := c.client.Im.V1.Message.Create(ctx, req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("feishu placeholder send: %w", err)
|
|
}
|
|
if !resp.Success() {
|
|
return "", fmt.Errorf("feishu placeholder api error (code=%d msg=%s)", resp.Code, resp.Msg)
|
|
}
|
|
|
|
if resp.Data != nil && resp.Data.MessageId != nil {
|
|
return *resp.Data.MessageId, nil
|
|
}
|
|
return "", nil
|
|
}
|
|
|
|
// ReactToMessage implements channels.ReactionCapable.
|
|
// Adds an "Pin" reaction and returns an undo function to remove it.
|
|
func (c *FeishuChannel) ReactToMessage(ctx context.Context, chatID, messageID string) (func(), error) {
|
|
req := larkim.NewCreateMessageReactionReqBuilder().
|
|
MessageId(messageID).
|
|
Body(larkim.NewCreateMessageReactionReqBodyBuilder().
|
|
ReactionType(larkim.NewEmojiBuilder().EmojiType("Pin").Build()).
|
|
Build()).
|
|
Build()
|
|
|
|
resp, err := c.client.Im.V1.MessageReaction.Create(ctx, req)
|
|
if err != nil {
|
|
logger.ErrorCF("feishu", "Failed to add reaction", map[string]any{
|
|
"message_id": messageID,
|
|
"error": err.Error(),
|
|
})
|
|
return func() {}, fmt.Errorf("feishu react: %w", err)
|
|
}
|
|
if !resp.Success() {
|
|
logger.ErrorCF("feishu", "Reaction API error", map[string]any{
|
|
"message_id": messageID,
|
|
"code": resp.Code,
|
|
"msg": resp.Msg,
|
|
})
|
|
return func() {}, fmt.Errorf("feishu react api error (code=%d msg=%s)", resp.Code, resp.Msg)
|
|
}
|
|
|
|
var reactionID string
|
|
if resp.Data != nil && resp.Data.ReactionId != nil {
|
|
reactionID = *resp.Data.ReactionId
|
|
}
|
|
if reactionID == "" {
|
|
return func() {}, nil
|
|
}
|
|
|
|
var undone atomic.Bool
|
|
undo := func() {
|
|
if !undone.CompareAndSwap(false, true) {
|
|
return
|
|
}
|
|
delReq := larkim.NewDeleteMessageReactionReqBuilder().
|
|
MessageId(messageID).
|
|
ReactionId(reactionID).
|
|
Build()
|
|
_, _ = c.client.Im.V1.MessageReaction.Delete(context.Background(), delReq)
|
|
}
|
|
return undo, nil
|
|
}
|
|
|
|
// SendMedia implements channels.MediaSender.
|
|
// Uploads images/files via Feishu API then sends as messages.
|
|
func (c *FeishuChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {
|
|
if !c.IsRunning() {
|
|
return channels.ErrNotRunning
|
|
}
|
|
|
|
if msg.ChatID == "" {
|
|
return fmt.Errorf("chat ID is empty: %w", channels.ErrSendFailed)
|
|
}
|
|
|
|
store := c.GetMediaStore()
|
|
if store == nil {
|
|
return fmt.Errorf("no media store available: %w", channels.ErrSendFailed)
|
|
}
|
|
|
|
for _, part := range msg.Parts {
|
|
if err := c.sendMediaPart(ctx, msg.ChatID, part, store); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// sendMediaPart resolves and sends a single media part.
|
|
func (c *FeishuChannel) sendMediaPart(
|
|
ctx context.Context,
|
|
chatID string,
|
|
part bus.MediaPart,
|
|
store media.MediaStore,
|
|
) error {
|
|
localPath, err := store.Resolve(part.Ref)
|
|
if err != nil {
|
|
logger.ErrorCF("feishu", "Failed to resolve media ref", map[string]any{
|
|
"ref": part.Ref,
|
|
"error": err.Error(),
|
|
})
|
|
return nil // skip this part
|
|
}
|
|
|
|
file, err := os.Open(localPath)
|
|
if err != nil {
|
|
logger.ErrorCF("feishu", "Failed to open media file", map[string]any{
|
|
"path": localPath,
|
|
"error": err.Error(),
|
|
})
|
|
return nil // skip this part
|
|
}
|
|
defer file.Close()
|
|
|
|
switch part.Type {
|
|
case "image":
|
|
err = c.sendImage(ctx, chatID, file)
|
|
default:
|
|
filename := part.Filename
|
|
if filename == "" {
|
|
filename = "file"
|
|
}
|
|
err = c.sendFile(ctx, chatID, file, filename, part.Type)
|
|
}
|
|
|
|
if err != nil {
|
|
logger.ErrorCF("feishu", "Failed to send media", map[string]any{
|
|
"type": part.Type,
|
|
"error": err.Error(),
|
|
})
|
|
return fmt.Errorf("feishu send media: %w", channels.ErrTemporary)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// --- Inbound message handling ---
|
|
|
|
func (c *FeishuChannel) handleMessageReceive(ctx 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
|
|
|
|
chatID := stringValue(message.ChatId)
|
|
if chatID == "" {
|
|
return nil
|
|
}
|
|
|
|
senderID := extractFeishuSenderID(sender)
|
|
if senderID == "" {
|
|
senderID = "unknown"
|
|
}
|
|
|
|
messageType := stringValue(message.MessageType)
|
|
messageID := stringValue(message.MessageId)
|
|
rawContent := stringValue(message.Content)
|
|
|
|
// Check allowlist early to avoid downloading media for rejected senders.
|
|
// BaseChannel.HandleMessage will check again, but this avoids wasted network I/O.
|
|
senderInfo := bus.SenderInfo{
|
|
Platform: "feishu",
|
|
PlatformID: senderID,
|
|
CanonicalID: identity.BuildCanonicalID("feishu", senderID),
|
|
}
|
|
if !c.IsAllowedSender(senderInfo) {
|
|
return nil
|
|
}
|
|
|
|
// Extract content based on message type
|
|
content := extractContent(messageType, rawContent)
|
|
|
|
// Handle media messages (download and store)
|
|
var mediaRefs []string
|
|
if store := c.GetMediaStore(); store != nil && messageID != "" {
|
|
mediaRefs = c.downloadInboundMedia(ctx, chatID, messageID, messageType, rawContent, store)
|
|
}
|
|
|
|
// Append media tags to content (like Telegram does)
|
|
content = appendMediaTags(content, messageType, mediaRefs)
|
|
|
|
if content == "" {
|
|
content = "[empty message]"
|
|
}
|
|
|
|
metadata := map[string]string{}
|
|
if messageID != "" {
|
|
metadata["message_id"] = messageID
|
|
}
|
|
if messageType != "" {
|
|
metadata["message_type"] = messageType
|
|
}
|
|
chatType := stringValue(message.ChatType)
|
|
if chatType != "" {
|
|
metadata["chat_type"] = chatType
|
|
}
|
|
if sender != nil && sender.TenantKey != nil {
|
|
metadata["tenant_key"] = *sender.TenantKey
|
|
}
|
|
|
|
var peer bus.Peer
|
|
if chatType == "p2p" {
|
|
peer = bus.Peer{Kind: "direct", ID: senderID}
|
|
} else {
|
|
peer = bus.Peer{Kind: "group", ID: chatID}
|
|
|
|
// Check if bot was mentioned
|
|
isMentioned := c.isBotMentioned(message)
|
|
|
|
// Strip mention placeholders from content before group trigger check
|
|
if len(message.Mentions) > 0 {
|
|
content = stripMentionPlaceholders(content, message.Mentions)
|
|
}
|
|
|
|
// In group chats, apply unified group trigger filtering
|
|
respond, cleaned := c.ShouldRespondInGroup(isMentioned, content)
|
|
if !respond {
|
|
return nil
|
|
}
|
|
content = cleaned
|
|
}
|
|
|
|
logger.InfoCF("feishu", "Feishu message received", map[string]any{
|
|
"sender_id": senderID,
|
|
"chat_id": chatID,
|
|
"message_id": messageID,
|
|
"preview": utils.Truncate(content, 80),
|
|
})
|
|
|
|
c.HandleMessage(ctx, peer, messageID, senderID, chatID, content, mediaRefs, metadata, senderInfo)
|
|
return nil
|
|
}
|
|
|
|
// --- Internal helpers ---
|
|
|
|
// fetchBotOpenID calls the Feishu bot info API to retrieve and store the bot's open_id.
|
|
func (c *FeishuChannel) fetchBotOpenID(ctx context.Context) error {
|
|
resp, err := c.client.Do(ctx, &larkcore.ApiReq{
|
|
HttpMethod: http.MethodGet,
|
|
ApiPath: "/open-apis/bot/v3/info",
|
|
SupportedAccessTokenTypes: []larkcore.AccessTokenType{larkcore.AccessTokenTypeTenant},
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("bot info request: %w", err)
|
|
}
|
|
|
|
var result struct {
|
|
Code int `json:"code"`
|
|
Bot struct {
|
|
OpenID string `json:"open_id"`
|
|
} `json:"bot"`
|
|
}
|
|
if err := json.Unmarshal(resp.RawBody, &result); err != nil {
|
|
return fmt.Errorf("bot info parse: %w", err)
|
|
}
|
|
if result.Code != 0 {
|
|
return fmt.Errorf("bot info api error (code=%d)", result.Code)
|
|
}
|
|
if result.Bot.OpenID == "" {
|
|
return fmt.Errorf("bot info: empty open_id")
|
|
}
|
|
|
|
c.botOpenID.Store(result.Bot.OpenID)
|
|
logger.InfoCF("feishu", "Fetched bot open_id from API", map[string]any{
|
|
"open_id": result.Bot.OpenID,
|
|
})
|
|
return nil
|
|
}
|
|
|
|
// isBotMentioned checks if the bot was @mentioned in the message.
|
|
func (c *FeishuChannel) isBotMentioned(message *larkim.EventMessage) bool {
|
|
if message.Mentions == nil {
|
|
return false
|
|
}
|
|
|
|
knownID, _ := c.botOpenID.Load().(string)
|
|
if knownID == "" {
|
|
logger.DebugCF("feishu", "Bot open_id unknown, cannot detect @mention", nil)
|
|
return false
|
|
}
|
|
|
|
for _, m := range message.Mentions {
|
|
if m.Id == nil {
|
|
continue
|
|
}
|
|
if m.Id.OpenId != nil && *m.Id.OpenId == knownID {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// extractContent extracts text content from different message types.
|
|
func extractContent(messageType, rawContent string) string {
|
|
if rawContent == "" {
|
|
return ""
|
|
}
|
|
|
|
switch messageType {
|
|
case larkim.MsgTypeText:
|
|
var textPayload struct {
|
|
Text string `json:"text"`
|
|
}
|
|
if err := json.Unmarshal([]byte(rawContent), &textPayload); err == nil {
|
|
return textPayload.Text
|
|
}
|
|
return rawContent
|
|
|
|
case larkim.MsgTypePost:
|
|
// Pass raw JSON to LLM — structured rich text is more informative than flattened plain text
|
|
return rawContent
|
|
|
|
case larkim.MsgTypeImage:
|
|
// Image messages don't have text content
|
|
return ""
|
|
|
|
case larkim.MsgTypeFile, larkim.MsgTypeAudio, larkim.MsgTypeMedia:
|
|
// File/audio/video messages may have a filename
|
|
name := extractFileName(rawContent)
|
|
if name != "" {
|
|
return name
|
|
}
|
|
return ""
|
|
|
|
default:
|
|
return rawContent
|
|
}
|
|
}
|
|
|
|
// downloadInboundMedia downloads media from inbound messages and stores in MediaStore.
|
|
func (c *FeishuChannel) downloadInboundMedia(
|
|
ctx context.Context,
|
|
chatID, messageID, messageType, rawContent string,
|
|
store media.MediaStore,
|
|
) []string {
|
|
var refs []string
|
|
scope := channels.BuildMediaScope("feishu", chatID, messageID)
|
|
|
|
switch messageType {
|
|
case larkim.MsgTypeImage:
|
|
imageKey := extractImageKey(rawContent)
|
|
if imageKey == "" {
|
|
return nil
|
|
}
|
|
ref := c.downloadResource(ctx, messageID, imageKey, "image", ".jpg", store, scope)
|
|
if ref != "" {
|
|
refs = append(refs, ref)
|
|
}
|
|
|
|
case larkim.MsgTypeFile, larkim.MsgTypeAudio, larkim.MsgTypeMedia:
|
|
fileKey := extractFileKey(rawContent)
|
|
if fileKey == "" {
|
|
return nil
|
|
}
|
|
// Derive a fallback extension from the message type.
|
|
var ext string
|
|
switch messageType {
|
|
case larkim.MsgTypeAudio:
|
|
ext = ".ogg"
|
|
case larkim.MsgTypeMedia:
|
|
ext = ".mp4"
|
|
default:
|
|
ext = "" // generic file — rely on resp.FileName
|
|
}
|
|
ref := c.downloadResource(ctx, messageID, fileKey, "file", ext, store, scope)
|
|
if ref != "" {
|
|
refs = append(refs, ref)
|
|
}
|
|
}
|
|
|
|
return refs
|
|
}
|
|
|
|
// downloadResource downloads a message resource (image/file) from Feishu,
|
|
// 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.
|
|
func (c *FeishuChannel) downloadResource(
|
|
ctx context.Context,
|
|
messageID, fileKey, resourceType, fallbackExt string,
|
|
store media.MediaStore,
|
|
scope string,
|
|
) string {
|
|
req := larkim.NewGetMessageResourceReqBuilder().
|
|
MessageId(messageID).
|
|
FileKey(fileKey).
|
|
Type(resourceType).
|
|
Build()
|
|
|
|
resp, err := c.client.Im.V1.MessageResource.Get(ctx, req)
|
|
if err != nil {
|
|
logger.ErrorCF("feishu", "Failed to download resource", map[string]any{
|
|
"message_id": messageID,
|
|
"file_key": fileKey,
|
|
"error": err.Error(),
|
|
})
|
|
return ""
|
|
}
|
|
if !resp.Success() {
|
|
logger.ErrorCF("feishu", "Resource download api error", map[string]any{
|
|
"code": resp.Code,
|
|
"msg": resp.Msg,
|
|
})
|
|
return ""
|
|
}
|
|
|
|
if resp.File == nil {
|
|
return ""
|
|
}
|
|
// 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
|
|
if filename == "" {
|
|
filename = fileKey
|
|
}
|
|
// 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.
|
|
mediaDir := filepath.Join(os.TempDir(), "picoclaw_media")
|
|
if mkdirErr := os.MkdirAll(mediaDir, 0o700); mkdirErr != nil {
|
|
logger.ErrorCF("feishu", "Failed to create media directory", map[string]any{
|
|
"error": mkdirErr.Error(),
|
|
})
|
|
return ""
|
|
}
|
|
ext := filepath.Ext(filename)
|
|
localPath := filepath.Join(mediaDir, utils.SanitizeFilename(messageID+"-"+fileKey+ext))
|
|
|
|
out, err := os.Create(localPath)
|
|
if err != nil {
|
|
logger.ErrorCF("feishu", "Failed to create local file for resource", map[string]any{
|
|
"error": err.Error(),
|
|
})
|
|
return ""
|
|
}
|
|
|
|
if _, copyErr := io.Copy(out, resp.File); copyErr != nil {
|
|
out.Close()
|
|
os.Remove(localPath)
|
|
logger.ErrorCF("feishu", "Failed to write resource to file", map[string]any{
|
|
"error": copyErr.Error(),
|
|
})
|
|
return ""
|
|
}
|
|
out.Close()
|
|
|
|
ref, err := store.Store(localPath, media.MediaMeta{
|
|
Filename: filename,
|
|
Source: "feishu",
|
|
}, scope)
|
|
if err != nil {
|
|
logger.ErrorCF("feishu", "Failed to store downloaded resource", map[string]any{
|
|
"file_key": fileKey,
|
|
"error": err.Error(),
|
|
})
|
|
os.Remove(localPath)
|
|
return ""
|
|
}
|
|
|
|
return ref
|
|
}
|
|
|
|
// appendMediaTags appends media type tags to content (like Telegram's "[image: photo]").
|
|
func appendMediaTags(content, messageType string, mediaRefs []string) string {
|
|
if len(mediaRefs) == 0 {
|
|
return content
|
|
}
|
|
|
|
var tag string
|
|
switch messageType {
|
|
case larkim.MsgTypeImage:
|
|
tag = "[image: photo]"
|
|
case larkim.MsgTypeAudio:
|
|
tag = "[audio]"
|
|
case larkim.MsgTypeMedia:
|
|
tag = "[video]"
|
|
case larkim.MsgTypeFile:
|
|
tag = "[file]"
|
|
default:
|
|
tag = "[attachment]"
|
|
}
|
|
|
|
if content == "" {
|
|
return tag
|
|
}
|
|
return content + " " + tag
|
|
}
|
|
|
|
// sendCard sends an interactive card message to a chat.
|
|
func (c *FeishuChannel) sendCard(ctx context.Context, chatID, cardContent string) error {
|
|
req := larkim.NewCreateMessageReqBuilder().
|
|
ReceiveIdType(larkim.ReceiveIdTypeChatId).
|
|
Body(larkim.NewCreateMessageReqBodyBuilder().
|
|
ReceiveId(chatID).
|
|
MsgType(larkim.MsgTypeInteractive).
|
|
Content(cardContent).
|
|
Build()).
|
|
Build()
|
|
|
|
resp, err := c.client.Im.V1.Message.Create(ctx, req)
|
|
if err != nil {
|
|
return fmt.Errorf("feishu send card: %w", channels.ErrTemporary)
|
|
}
|
|
|
|
if !resp.Success() {
|
|
return fmt.Errorf("feishu api error (code=%d msg=%s): %w", resp.Code, resp.Msg, channels.ErrTemporary)
|
|
}
|
|
|
|
logger.DebugCF("feishu", "Feishu card message sent", 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
|
|
uploadReq := larkim.NewCreateImageReqBuilder().
|
|
Body(larkim.NewCreateImageReqBodyBuilder().
|
|
ImageType("message").
|
|
Image(file).
|
|
Build()).
|
|
Build()
|
|
|
|
uploadResp, err := c.client.Im.V1.Image.Create(ctx, uploadReq)
|
|
if err != nil {
|
|
return fmt.Errorf("feishu image upload: %w", err)
|
|
}
|
|
if !uploadResp.Success() {
|
|
return fmt.Errorf("feishu image upload api error (code=%d msg=%s)", uploadResp.Code, uploadResp.Msg)
|
|
}
|
|
if uploadResp.Data == nil || uploadResp.Data.ImageKey == nil {
|
|
return fmt.Errorf("feishu image upload: no image_key returned")
|
|
}
|
|
|
|
imageKey := *uploadResp.Data.ImageKey
|
|
|
|
// Send image message
|
|
content, _ := json.Marshal(map[string]string{"image_key": imageKey})
|
|
req := larkim.NewCreateMessageReqBuilder().
|
|
ReceiveIdType(larkim.ReceiveIdTypeChatId).
|
|
Body(larkim.NewCreateMessageReqBodyBuilder().
|
|
ReceiveId(chatID).
|
|
MsgType(larkim.MsgTypeImage).
|
|
Content(string(content)).
|
|
Build()).
|
|
Build()
|
|
|
|
resp, err := c.client.Im.V1.Message.Create(ctx, req)
|
|
if err != nil {
|
|
return fmt.Errorf("feishu image send: %w", err)
|
|
}
|
|
if !resp.Success() {
|
|
return fmt.Errorf("feishu image send api error (code=%d msg=%s)", resp.Code, resp.Msg)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// sendFile uploads a file and sends it as a message.
|
|
func (c *FeishuChannel) sendFile(ctx context.Context, chatID string, file *os.File, filename, fileType string) error {
|
|
// Map part type to Feishu file type
|
|
feishuFileType := "stream"
|
|
switch fileType {
|
|
case "audio":
|
|
feishuFileType = "opus"
|
|
case "video":
|
|
feishuFileType = "mp4"
|
|
}
|
|
|
|
// Upload file to get file_key
|
|
uploadReq := larkim.NewCreateFileReqBuilder().
|
|
Body(larkim.NewCreateFileReqBodyBuilder().
|
|
FileType(feishuFileType).
|
|
FileName(filename).
|
|
File(file).
|
|
Build()).
|
|
Build()
|
|
|
|
uploadResp, err := c.client.Im.V1.File.Create(ctx, uploadReq)
|
|
if err != nil {
|
|
return fmt.Errorf("feishu file upload: %w", err)
|
|
}
|
|
if !uploadResp.Success() {
|
|
return fmt.Errorf("feishu file upload api error (code=%d msg=%s)", uploadResp.Code, uploadResp.Msg)
|
|
}
|
|
if uploadResp.Data == nil || uploadResp.Data.FileKey == nil {
|
|
return fmt.Errorf("feishu file upload: no file_key returned")
|
|
}
|
|
|
|
fileKey := *uploadResp.Data.FileKey
|
|
|
|
// Send file message
|
|
content, _ := json.Marshal(map[string]string{"file_key": fileKey})
|
|
req := larkim.NewCreateMessageReqBuilder().
|
|
ReceiveIdType(larkim.ReceiveIdTypeChatId).
|
|
Body(larkim.NewCreateMessageReqBodyBuilder().
|
|
ReceiveId(chatID).
|
|
MsgType(larkim.MsgTypeFile).
|
|
Content(string(content)).
|
|
Build()).
|
|
Build()
|
|
|
|
resp, err := c.client.Im.V1.Message.Create(ctx, req)
|
|
if err != nil {
|
|
return fmt.Errorf("feishu file send: %w", err)
|
|
}
|
|
if !resp.Success() {
|
|
return fmt.Errorf("feishu file send api error (code=%d msg=%s)", resp.Code, resp.Msg)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func extractFeishuSenderID(sender *larkim.EventSender) string {
|
|
if sender == nil || sender.SenderId == nil {
|
|
return ""
|
|
}
|
|
|
|
if sender.SenderId.UserId != nil && *sender.SenderId.UserId != "" {
|
|
return *sender.SenderId.UserId
|
|
}
|
|
if sender.SenderId.OpenId != nil && *sender.SenderId.OpenId != "" {
|
|
return *sender.SenderId.OpenId
|
|
}
|
|
if sender.SenderId.UnionId != nil && *sender.SenderId.UnionId != "" {
|
|
return *sender.SenderId.UnionId
|
|
}
|
|
|
|
return ""
|
|
}
|