mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
cb1e1a3595
* 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>
1240 lines
35 KiB
Go
1240 lines
35 KiB
Go
//go:build amd64 || arm64 || riscv64 || mips64 || ppc64
|
|
|
|
package feishu
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"math/rand"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
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"
|
|
)
|
|
|
|
// errCodeTenantTokenInvalid is the Feishu API error code for an expired/revoked
|
|
// tenant_access_token. The Lark SDK's built-in retry does not clear its cache
|
|
// on this error, so we do it ourselves.
|
|
const errCodeTenantTokenInvalid = 99991663
|
|
|
|
type FeishuChannel struct {
|
|
*channels.BaseChannel
|
|
bc *config.Channel
|
|
config *config.FeishuSettings
|
|
client *lark.Client
|
|
wsClient *larkws.Client
|
|
tokenCache *tokenCache // custom cache that supports invalidation
|
|
|
|
botOpenID atomic.Value // stores string; populated lazily for @mention detection
|
|
messageCache sync.Map // caches fetched messages (messageID -> *larkim.Message)
|
|
|
|
mu sync.Mutex
|
|
cancel context.CancelFunc
|
|
|
|
progress *channels.ToolFeedbackAnimator
|
|
deleteMessageFn func(context.Context, string, string) error
|
|
}
|
|
|
|
type cachedMessage struct {
|
|
msg *larkim.Message
|
|
expiry time.Time
|
|
}
|
|
|
|
func NewFeishuChannel(bc *config.Channel, cfg *config.FeishuSettings, bus *bus.MessageBus) (*FeishuChannel, error) {
|
|
base := channels.NewBaseChannel("feishu", cfg, bus, bc.AllowFrom,
|
|
channels.WithGroupTrigger(bc.GroupTrigger),
|
|
channels.WithReasoningChannelID(bc.ReasoningChannelID),
|
|
)
|
|
|
|
tc := newTokenCache()
|
|
opts := []lark.ClientOptionFunc{lark.WithTokenCache(tc)}
|
|
if cfg.IsLark {
|
|
opts = append(opts, lark.WithOpenBaseUrl(lark.LarkBaseUrl))
|
|
}
|
|
ch := &FeishuChannel{
|
|
BaseChannel: base,
|
|
bc: bc,
|
|
config: cfg,
|
|
tokenCache: tc,
|
|
client: lark.NewClient(cfg.AppID, cfg.AppSecret.String(), opts...),
|
|
}
|
|
ch.deleteMessageFn = ch.deleteMessageAPI
|
|
ch.progress = channels.NewToolFeedbackAnimator(ch.EditMessage)
|
|
ch.SetOwner(ch)
|
|
return ch, nil
|
|
}
|
|
|
|
func (c *FeishuChannel) Start(ctx context.Context) error {
|
|
if c.config.AppID == "" || c.config.AppSecret.String() == "" {
|
|
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.String(), c.config.EncryptKey.String()).
|
|
OnP2MessageReceiveV1(c.handleMessageReceive)
|
|
|
|
runCtx, cancel := context.WithCancel(ctx)
|
|
|
|
c.mu.Lock()
|
|
c.cancel = cancel
|
|
domain := lark.FeishuBaseUrl
|
|
if c.config.IsLark {
|
|
domain = lark.LarkBaseUrl
|
|
}
|
|
c.wsClient = larkws.NewClient(
|
|
c.config.AppID,
|
|
c.config.AppSecret.String(),
|
|
larkws.WithEventHandler(dispatcher),
|
|
larkws.WithDomain(domain),
|
|
)
|
|
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()
|
|
if c.progress != nil {
|
|
c.progress.StopAll()
|
|
}
|
|
|
|
c.SetRunning(false)
|
|
logger.InfoC("feishu", "Feishu channel stopped")
|
|
return nil
|
|
}
|
|
|
|
// 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) ([]string, error) {
|
|
if !c.IsRunning() {
|
|
return nil, channels.ErrNotRunning
|
|
}
|
|
|
|
if msg.ChatID == "" {
|
|
return nil, fmt.Errorf("chat ID is empty: %w", channels.ErrSendFailed)
|
|
}
|
|
|
|
isToolFeedback := outboundMessageIsToolFeedback(msg)
|
|
if isToolFeedback {
|
|
if msgID, handled, err := c.progress.Update(ctx, msg.ChatID, msg.Content); handled {
|
|
if err != nil {
|
|
// Feishu can fall back to plain text for a previous progress
|
|
// message, and those messages cannot be patched through the card
|
|
// edit API. Drop the stale tracker and recreate the progress
|
|
// message so later tool feedback is not blocked.
|
|
c.resetTrackedToolFeedbackAfterEditFailure(ctx, msg.ChatID)
|
|
} else {
|
|
return []string{msgID}, nil
|
|
}
|
|
}
|
|
} else {
|
|
if msgIDs, handled := c.FinalizeToolFeedbackMessage(ctx, msg); handled {
|
|
return msgIDs, nil
|
|
}
|
|
}
|
|
trackedMsgID, hasTrackedMsg := c.currentToolFeedbackMessage(msg.ChatID)
|
|
|
|
// Build interactive card with markdown content
|
|
sendContent := msg.Content
|
|
if isToolFeedback {
|
|
sendContent = channels.InitialAnimatedToolFeedbackContent(msg.Content)
|
|
}
|
|
cardContent, err := buildMarkdownCard(sendContent)
|
|
if err != nil {
|
|
// If card build fails, fall back to plain text
|
|
msgID, sendErr := c.sendText(ctx, msg.ChatID, sendContent)
|
|
if sendErr != nil {
|
|
return nil, sendErr
|
|
}
|
|
if isToolFeedback {
|
|
c.RecordToolFeedbackMessage(msg.ChatID, msgID, msg.Content)
|
|
} else if hasTrackedMsg {
|
|
c.dismissTrackedToolFeedbackMessage(ctx, msg.ChatID, trackedMsgID)
|
|
}
|
|
return []string{msgID}, nil
|
|
}
|
|
|
|
// First attempt: try sending as interactive card
|
|
msgID, err := c.sendCard(ctx, msg.ChatID, cardContent)
|
|
if err == nil {
|
|
if isToolFeedback {
|
|
c.RecordToolFeedbackMessage(msg.ChatID, msgID, msg.Content)
|
|
} else if hasTrackedMsg {
|
|
c.dismissTrackedToolFeedbackMessage(ctx, msg.ChatID, trackedMsgID)
|
|
}
|
|
return []string{msgID}, 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
|
|
msgID, textErr := c.sendText(ctx, msg.ChatID, sendContent)
|
|
if textErr == nil {
|
|
if isToolFeedback {
|
|
c.RecordToolFeedbackMessage(msg.ChatID, msgID, msg.Content)
|
|
} else if hasTrackedMsg {
|
|
c.dismissTrackedToolFeedbackMessage(ctx, msg.ChatID, trackedMsgID)
|
|
}
|
|
return []string{msgID}, nil
|
|
}
|
|
// If text also fails, return the text error
|
|
return nil, textErr
|
|
}
|
|
|
|
// For other errors, return the original card error
|
|
return nil, err
|
|
}
|
|
|
|
// 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() {
|
|
c.invalidateTokenOnAuthError(resp.Code)
|
|
return fmt.Errorf("feishu edit api error (code=%d msg=%s)", resp.Code, resp.Msg)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DeleteMessage implements channels.MessageDeleter.
|
|
func (c *FeishuChannel) DeleteMessage(ctx context.Context, chatID, messageID string) error {
|
|
deleteFn := c.deleteMessageFn
|
|
if deleteFn == nil {
|
|
deleteFn = c.deleteMessageAPI
|
|
}
|
|
return deleteFn(ctx, chatID, messageID)
|
|
}
|
|
|
|
func (c *FeishuChannel) deleteMessageAPI(ctx context.Context, chatID, messageID string) error {
|
|
req := larkim.NewDeleteMessageReqBuilder().
|
|
MessageId(messageID).
|
|
Build()
|
|
|
|
resp, err := c.client.Im.V1.Message.Delete(ctx, req)
|
|
if err != nil {
|
|
return fmt.Errorf("feishu delete: %w", err)
|
|
}
|
|
if !resp.Success() {
|
|
c.invalidateTokenOnAuthError(resp.Code)
|
|
return fmt.Errorf("feishu delete 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.bc.Placeholder.Enabled {
|
|
logger.DebugCF("feishu", "Placeholder disabled, skipping", map[string]any{
|
|
"chat_id": chatID,
|
|
})
|
|
return "", nil
|
|
}
|
|
|
|
text := c.bc.Placeholder.GetRandomText()
|
|
|
|
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() {
|
|
c.invalidateTokenOnAuthError(resp.Code)
|
|
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
|
|
}
|
|
|
|
func outboundMessageIsToolFeedback(msg bus.OutboundMessage) bool {
|
|
if len(msg.Context.Raw) == 0 {
|
|
return false
|
|
}
|
|
return strings.EqualFold(strings.TrimSpace(msg.Context.Raw["message_kind"]), "tool_feedback")
|
|
}
|
|
|
|
func (c *FeishuChannel) currentToolFeedbackMessage(chatID string) (string, bool) {
|
|
if c.progress == nil {
|
|
return "", false
|
|
}
|
|
return c.progress.Current(chatID)
|
|
}
|
|
|
|
func (c *FeishuChannel) takeToolFeedbackMessage(chatID string) (string, string, bool) {
|
|
if c.progress == nil {
|
|
return "", "", false
|
|
}
|
|
return c.progress.Take(chatID)
|
|
}
|
|
|
|
func (c *FeishuChannel) RecordToolFeedbackMessage(chatID, messageID, content string) {
|
|
if c.progress == nil {
|
|
return
|
|
}
|
|
c.progress.Record(chatID, messageID, content)
|
|
}
|
|
|
|
func (c *FeishuChannel) ClearToolFeedbackMessage(chatID string) {
|
|
if c.progress == nil {
|
|
return
|
|
}
|
|
c.progress.Clear(chatID)
|
|
}
|
|
|
|
func (c *FeishuChannel) DismissToolFeedbackMessage(ctx context.Context, chatID string) {
|
|
msgID, ok := c.currentToolFeedbackMessage(chatID)
|
|
if !ok {
|
|
return
|
|
}
|
|
c.dismissTrackedToolFeedbackMessage(ctx, chatID, msgID)
|
|
}
|
|
|
|
func (c *FeishuChannel) resetTrackedToolFeedbackAfterEditFailure(ctx context.Context, chatID string) {
|
|
msgID, ok := c.currentToolFeedbackMessage(chatID)
|
|
if !ok {
|
|
return
|
|
}
|
|
c.dismissTrackedToolFeedbackMessage(ctx, chatID, msgID)
|
|
}
|
|
|
|
func (c *FeishuChannel) dismissTrackedToolFeedbackMessage(ctx context.Context, chatID, messageID string) {
|
|
if strings.TrimSpace(chatID) == "" || strings.TrimSpace(messageID) == "" {
|
|
return
|
|
}
|
|
c.ClearToolFeedbackMessage(chatID)
|
|
deleteFn := c.deleteMessageFn
|
|
if deleteFn == nil {
|
|
deleteFn = c.deleteMessageAPI
|
|
}
|
|
_ = deleteFn(ctx, chatID, messageID)
|
|
}
|
|
|
|
func (c *FeishuChannel) finalizeTrackedToolFeedbackMessage(
|
|
ctx context.Context,
|
|
chatID string,
|
|
content string,
|
|
editFn func(context.Context, string, string, string) error,
|
|
) ([]string, bool) {
|
|
msgID, baseContent, ok := c.takeToolFeedbackMessage(chatID)
|
|
if !ok || editFn == nil {
|
|
return nil, false
|
|
}
|
|
if err := editFn(ctx, chatID, msgID, content); err != nil {
|
|
c.RecordToolFeedbackMessage(chatID, msgID, baseContent)
|
|
return nil, false
|
|
}
|
|
return []string{msgID}, true
|
|
}
|
|
|
|
func (c *FeishuChannel) FinalizeToolFeedbackMessage(ctx context.Context, msg bus.OutboundMessage) ([]string, bool) {
|
|
if outboundMessageIsToolFeedback(msg) {
|
|
return nil, false
|
|
}
|
|
return c.finalizeTrackedToolFeedbackMessage(ctx, msg.ChatID, msg.Content, c.EditMessage)
|
|
}
|
|
|
|
// ReactToMessage implements channels.ReactionCapable.
|
|
// Adds a reaction (randomly chosen from config) and returns an undo function to remove it.
|
|
func (c *FeishuChannel) ReactToMessage(ctx context.Context, chatID, messageID string) (func(), error) {
|
|
// Get emoji list from config (Feishu emoji_type keys, e.g. Pin, THUMBSUP).
|
|
// Ignore empty entries so a list like ["", "Pin"] does not randomly pick "" (API 231001).
|
|
var candidates []string
|
|
for _, e := range c.config.RandomReactionEmoji {
|
|
e = strings.TrimSpace(e)
|
|
if e != "" {
|
|
candidates = append(candidates, e)
|
|
}
|
|
}
|
|
chosenEmoji := "Pin"
|
|
if len(candidates) > 0 {
|
|
chosenEmoji = candidates[rand.Intn(len(candidates))]
|
|
}
|
|
|
|
req := larkim.NewCreateMessageReactionReqBuilder().
|
|
MessageId(messageID).
|
|
Body(larkim.NewCreateMessageReactionReqBodyBuilder().
|
|
ReactionType(larkim.NewEmojiBuilder().EmojiType(chosenEmoji).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{
|
|
"emoji": chosenEmoji,
|
|
"message_id": messageID,
|
|
"error": err.Error(),
|
|
})
|
|
return func() {}, fmt.Errorf("feishu react: %w", err)
|
|
}
|
|
if !resp.Success() {
|
|
c.invalidateTokenOnAuthError(resp.Code)
|
|
logger.ErrorCF("feishu", "Reaction API error", map[string]any{
|
|
"emoji": chosenEmoji,
|
|
"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) ([]string, error) {
|
|
if !c.IsRunning() {
|
|
return nil, channels.ErrNotRunning
|
|
}
|
|
trackedMsgID, hasTrackedMsg := c.currentToolFeedbackMessage(msg.ChatID)
|
|
|
|
if msg.ChatID == "" {
|
|
return nil, fmt.Errorf("chat ID is empty: %w", channels.ErrSendFailed)
|
|
}
|
|
|
|
store := c.GetMediaStore()
|
|
if store == nil {
|
|
return nil, 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 nil, err
|
|
}
|
|
}
|
|
|
|
if hasTrackedMsg {
|
|
c.dismissTrackedToolFeedbackMessage(ctx, msg.ChatID, trackedMsgID)
|
|
}
|
|
|
|
return nil, 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)
|
|
}
|
|
|
|
// 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)
|
|
|
|
if content == "" {
|
|
content = "[empty message]"
|
|
}
|
|
chatType := stringValue(message.ChatType)
|
|
metadata := buildInboundMetadata(message, sender)
|
|
|
|
var (
|
|
inboundChatType string
|
|
isMentioned bool
|
|
)
|
|
if chatType == "p2p" {
|
|
inboundChatType = "direct"
|
|
} else {
|
|
inboundChatType = "group"
|
|
|
|
// 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
|
|
}
|
|
|
|
if replyTargetID(message) != "" || stringValue(message.ThreadId) != "" {
|
|
content, mediaRefs = c.prependReplyContext(ctx, message, chatID, content, mediaRefs)
|
|
}
|
|
if content == "" {
|
|
content = "[empty message]"
|
|
}
|
|
|
|
logger.InfoCF("feishu", "Feishu message received", map[string]any{
|
|
"sender_id": senderID,
|
|
"chat_id": chatID,
|
|
"message_id": messageID,
|
|
"preview": utils.Truncate(content, 80),
|
|
})
|
|
logger.InfoCF("feishu", "Feishu reply linkage", map[string]any{
|
|
"message_id": messageID,
|
|
"parent_id": stringValue(message.ParentId),
|
|
"root_id": stringValue(message.RootId),
|
|
"thread_id": stringValue(message.ThreadId),
|
|
})
|
|
|
|
inboundCtx := bus.InboundContext{
|
|
Channel: "feishu",
|
|
ChatID: chatID,
|
|
ChatType: inboundChatType,
|
|
SenderID: senderID,
|
|
MessageID: messageID,
|
|
Mentioned: isMentioned,
|
|
Raw: metadata,
|
|
}
|
|
if sender != nil && sender.TenantKey != nil && *sender.TenantKey != "" {
|
|
inboundCtx.SpaceType = "tenant"
|
|
inboundCtx.SpaceID = *sender.TenantKey
|
|
}
|
|
|
|
c.HandleInboundContext(ctx, chatID, content, mediaRefs, inboundCtx, 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 {
|
|
c.invalidateTokenOnAuthError(result.Code)
|
|
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.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 ""
|
|
|
|
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.MsgTypePost:
|
|
for _, imageKey := range extractPostImageKeys(rawContent) {
|
|
ref := c.downloadResource(ctx, messageID, imageKey, "image", ".jpg", store, scope)
|
|
if ref != "" {
|
|
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 == "" {
|
|
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.
|
|
//
|
|
// For image resources, if the primary MessageResource.Get API fails (which
|
|
// requires im:message or im:message:readonly scope), a fallback to the
|
|
// Image.Get API (which requires im:resource scope) is attempted. This ensures
|
|
// image downloads succeed regardless of which permission the user has granted.
|
|
func (c *FeishuChannel) downloadResource(
|
|
ctx context.Context,
|
|
messageID, fileKey, resourceType, fallbackExt string,
|
|
store media.MediaStore,
|
|
scope string,
|
|
) string {
|
|
file, filename := c.fetchResourceData(ctx, messageID, fileKey, resourceType)
|
|
if file == nil {
|
|
return ""
|
|
}
|
|
if closer, ok := file.(io.Closer); ok {
|
|
defer closer.Close()
|
|
}
|
|
|
|
if filename == "" {
|
|
filename = fileKey
|
|
}
|
|
if filepath.Ext(filename) == "" && fallbackExt != "" {
|
|
filename += fallbackExt
|
|
}
|
|
|
|
return c.storeResourceFile(ctx, messageID, fileKey, filename, file, store, scope)
|
|
}
|
|
|
|
// fetchResourceData tries to download a resource from Feishu, first via
|
|
// MessageResource.Get, then falling back to Image.Get for image resources.
|
|
func (c *FeishuChannel) fetchResourceData(
|
|
ctx context.Context,
|
|
messageID, fileKey, resourceType string,
|
|
) (io.Reader, string) {
|
|
req := larkim.NewGetMessageResourceReqBuilder().
|
|
MessageId(messageID).
|
|
FileKey(fileKey).
|
|
Type(resourceType).
|
|
Build()
|
|
|
|
resp, err := c.client.Im.V1.MessageResource.Get(ctx, req)
|
|
if err == nil && resp.Success() && resp.File != nil {
|
|
return resp.File, resp.FileName
|
|
}
|
|
|
|
if err != nil {
|
|
logger.WarnCF("feishu", "MessageResource.Get failed", map[string]any{
|
|
"message_id": messageID,
|
|
"file_key": fileKey,
|
|
"error": err.Error(),
|
|
})
|
|
} else if !resp.Success() {
|
|
c.invalidateTokenOnAuthError(resp.Code)
|
|
logger.WarnCF("feishu", "MessageResource.Get api error", map[string]any{
|
|
"message_id": messageID,
|
|
"file_key": fileKey,
|
|
"code": resp.Code,
|
|
"msg": resp.Msg,
|
|
})
|
|
} else {
|
|
logger.WarnCF("feishu", "MessageResource.Get returned empty file body", map[string]any{
|
|
"message_id": messageID,
|
|
"file_key": fileKey,
|
|
})
|
|
}
|
|
|
|
if resourceType != "image" {
|
|
return nil, ""
|
|
}
|
|
|
|
return c.fetchImageDirect(ctx, fileKey)
|
|
}
|
|
|
|
// fetchImageDirect downloads an image using the Image.Get API
|
|
// (/open-apis/im/v1/images/:image_key), which requires the im:resource scope.
|
|
func (c *FeishuChannel) fetchImageDirect(ctx context.Context, imageKey string) (io.Reader, string) {
|
|
req := larkim.NewGetImageReqBuilder().
|
|
ImageKey(imageKey).
|
|
Build()
|
|
|
|
resp, err := c.client.Im.V1.Image.Get(ctx, req)
|
|
if err != nil {
|
|
logger.ErrorCF("feishu", "Image.Get fallback failed", map[string]any{
|
|
"image_key": imageKey,
|
|
"error": err.Error(),
|
|
})
|
|
return nil, ""
|
|
}
|
|
if !resp.Success() {
|
|
c.invalidateTokenOnAuthError(resp.Code)
|
|
logger.ErrorCF("feishu", "Image.Get fallback api error", map[string]any{
|
|
"image_key": imageKey,
|
|
"code": resp.Code,
|
|
"msg": resp.Msg,
|
|
})
|
|
return nil, ""
|
|
}
|
|
if resp.File == nil {
|
|
return nil, ""
|
|
}
|
|
|
|
logger.DebugCF("feishu", "Image downloaded via Image.Get fallback", map[string]any{
|
|
"image_key": imageKey,
|
|
})
|
|
return resp.File, resp.FileName
|
|
}
|
|
|
|
// storeResourceFile writes downloaded resource data to disk and registers it in the MediaStore.
|
|
func (c *FeishuChannel) storeResourceFile(
|
|
ctx context.Context,
|
|
messageID, fileKey, filename string,
|
|
file io.Reader,
|
|
store media.MediaStore,
|
|
scope string,
|
|
) string {
|
|
mediaDir := media.TempDir()
|
|
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, 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",
|
|
CleanupPolicy: media.CleanupPolicyDeleteOnCleanup,
|
|
}, 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]").
|
|
// 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 - would produce invalid JSON
|
|
if messageType == larkim.MsgTypeInteractive || messageType == larkim.MsgTypePost {
|
|
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) (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() {
|
|
c.invalidateTokenOnAuthError(resp.Code)
|
|
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,
|
|
})
|
|
|
|
if resp.Data != nil && resp.Data.MessageId != nil {
|
|
return *resp.Data.MessageId, nil
|
|
}
|
|
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) (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,
|
|
})
|
|
|
|
if resp.Data != nil && resp.Data.MessageId != nil {
|
|
return *resp.Data.MessageId, nil
|
|
}
|
|
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() {
|
|
c.invalidateTokenOnAuthError(uploadResp.Code)
|
|
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() {
|
|
c.invalidateTokenOnAuthError(resp.Code)
|
|
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() {
|
|
c.invalidateTokenOnAuthError(uploadResp.Code)
|
|
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() {
|
|
c.invalidateTokenOnAuthError(resp.Code)
|
|
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 ""
|
|
}
|
|
|
|
// invalidateTokenOnAuthError clears the cached tenant_access_token when the
|
|
// Feishu API reports it as invalid (99991663), so the next request fetches a
|
|
// fresh one. The Lark SDK's built-in retry does not clear the cache, causing
|
|
// all API calls to fail until the token naturally expires (~2 hours).
|
|
func (c *FeishuChannel) invalidateTokenOnAuthError(code int) {
|
|
if code == errCodeTenantTokenInvalid {
|
|
c.tokenCache.InvalidateAll()
|
|
logger.WarnCF("feishu", "Invalidated cached token due to auth error", nil)
|
|
}
|
|
}
|