//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) } }