package feishu
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"html"
"io"
"log/slog"
"math/rand/v2"
"net"
"net/http"
"net/url"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"syscall"
"time"
"github.com/chenhg5/cc-connect/core"
lark "github.com/larksuite/oapi-sdk-go/v3"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
larkevent "github.com/larksuite/oapi-sdk-go/v3/event"
"github.com/larksuite/oapi-sdk-go/v3/event/dispatcher"
"github.com/larksuite/oapi-sdk-go/v3/event/dispatcher/callback"
larkapplication "github.com/larksuite/oapi-sdk-go/v3/service/application/v6"
larkcontact "github.com/larksuite/oapi-sdk-go/v3/service/contact/v3"
larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
larkws "github.com/larksuite/oapi-sdk-go/v3/ws"
)
// sanitizingLogger wraps a logger and masks sensitive URL parameters.
type sanitizingLogger struct {
inner larkcore.Logger
}
func (l *sanitizingLogger) maskURL(args ...interface{}) []interface{} {
masked := make([]interface{}, len(args))
for i, arg := range args {
if s, ok := arg.(string); ok {
masked[i] = l.sanitize(s)
} else {
masked[i] = arg
}
}
return masked
}
func (l *sanitizingLogger) sanitize(s string) string {
// Mask sensitive query parameters in URLs
sensitiveParams := []string{
"device_id=", "access_key=", "ticket=", "conn_id=",
"secret=", "token=", "password=", "key=",
}
for _, param := range sensitiveParams {
if idx := strings.Index(s, param); idx != -1 {
// Find the end of the value (either & or end of string)
end := idx + len(param)
for end < len(s) && s[end] != '&' && s[end] != ' ' {
end++
}
s = s[:idx+len(param)] + "***" + s[end:]
}
}
return s
}
func (l *sanitizingLogger) Debug(ctx context.Context, args ...interface{}) {
for _, arg := range args {
s, ok := arg.(string)
if !ok {
continue
}
msg := strings.ToLower(s)
if strings.Contains(msg, "ping success") || strings.Contains(msg, "receive pong") {
return
}
}
l.inner.Debug(ctx, l.maskURL(args...)...)
}
func (l *sanitizingLogger) Info(ctx context.Context, args ...interface{}) {
l.inner.Info(ctx, l.maskURL(args...)...)
}
func (l *sanitizingLogger) Warn(ctx context.Context, args ...interface{}) {
l.inner.Warn(ctx, l.maskURL(args...)...)
}
func (l *sanitizingLogger) Error(ctx context.Context, args ...interface{}) {
l.inner.Error(ctx, l.maskURL(args...)...)
}
func init() {
core.RegisterPlatform("feishu", func(opts map[string]any) (core.Platform, error) {
return newPlatform("feishu", lark.FeishuBaseUrl, opts)
})
core.RegisterPlatform("lark", func(opts map[string]any) (core.Platform, error) {
return newPlatform("lark", lark.LarkBaseUrl, opts)
})
}
type replyContext struct {
messageID string
chatID string
sessionKey string
}
type Platform struct {
mu sync.RWMutex
platformName string
domain string
appID string
appSecret string
progressStyle string
useInteractiveCard bool
self core.Platform
reactionEmoji string
doneEmoji string
allowFrom string
allowChat string
groupOnly bool
groupReplyAll bool
respondToAtEveryoneAndHere bool
shareSessionInChannel bool
threadIsolation bool
// noReplyToTrigger: when true, send via Create instead of Im.Message.Reply (no quote to the user's message).
noReplyToTrigger bool
resolveMentions bool
client *lark.Client
replayClient *lark.Client
replayClientMu sync.Mutex
wsClient *larkws.Client
handler core.MessageHandler
cardNavHandler core.CardNavigationHandler
cancel context.CancelFunc
dedup *core.MessageDedup
botOpenID string
peerBots map[string]string // app_id -> friendly alias, for quoted-reply attribution
userNameCache sync.Map // open_id -> display name
chatNameCache sync.Map // chat_id -> chat name
chatMemberCache sync.Map // chatID -> *chatMemberEntry
recalledMu sync.Mutex
recalledMsgIDs map[string]time.Time // message_id -> recall time, short TTL race guard
// Webhook mode fields (for Lark international version)
server *http.Server
port string
callbackPath string
encryptKey string
eventHandler *dispatcher.EventDispatcher
sharedGroup *sharedWSGroup // non-nil when sharing WebSocket with other platforms
isWSPrimary bool // true if this platform owns the shared WebSocket connection
// cardActionMessageIDs tracks the most recent card-action messageID per
// session key, enabling async card refreshes via the Patch API.
cardActionMsgMu sync.Mutex
cardActionMsgIDs map[string]string // sessionKey → messageID
// activeThreadSessions tracks thread sessionKeys that have already been
// accepted by the bot. In group chats with thread_isolation, once a thread
// has been engaged (the first @bot message), subsequent attachment-only
// messages (image/file/audio) inside the same thread are passed through
// without requiring another @bot mention. Value is the last-seen time so
// stale entries can be expired by a future TTL sweep if needed.
activeThreadSessions sync.Map // sessionKey -> time.Time
}
type interactivePlatform struct {
*Platform
}
type feishuRequestFunc func(client *lark.Client, options ...larkcore.RequestOptionFunc) error
func (p *Platform) SetCardNavigationHandler(h core.CardNavigationHandler) {
p.cardNavHandler = h
}
func New(opts map[string]any) (core.Platform, error) {
return newPlatform("feishu", lark.FeishuBaseUrl, opts)
}
func newPlatform(name, domain string, opts map[string]any) (core.Platform, error) {
appID, _ := opts["app_id"].(string)
appSecret, _ := opts["app_secret"].(string)
if appID == "" || appSecret == "" {
return nil, fmt.Errorf("%s: app_id and app_secret are required", name)
}
if v, ok := opts["domain"].(string); ok {
v = strings.TrimSpace(v)
if v != "" {
if _, err := url.ParseRequestURI(v); err != nil {
return nil, fmt.Errorf("%s: invalid domain %q: %w", name, v, err)
}
domain = v
}
}
reactionEmoji, _ := opts["reaction_emoji"].(string)
if reactionEmoji == "" {
reactionEmoji = "OnIt"
}
if v, ok := opts["reaction_emoji"].(string); ok && v == "none" {
reactionEmoji = ""
}
doneEmoji, _ := opts["done_emoji"].(string)
if doneEmoji == "none" {
doneEmoji = ""
}
allowFrom, _ := opts["allow_from"].(string)
core.CheckAllowFrom(name, allowFrom)
allowChat, _ := opts["allow_chat"].(string)
groupOnly, _ := opts["group_only"].(bool)
groupReplyAll, _ := opts["group_reply_all"].(bool)
// require_mention = false is equivalent to group_reply_all = true:
// both mean "respond to all group messages without needing an @mention".
if v, ok := opts["require_mention"].(bool); ok && !v {
groupReplyAll = true
}
respondToAtEveryoneAndHere, _ := opts["respond_to_at_everyone_and_here"].(bool)
shareSessionInChannel, _ := opts["share_session_in_channel"].(bool)
threadIsolation, _ := opts["thread_isolation"].(bool)
resolveMentionsOpt, _ := opts["resolve_mentions"].(bool)
noReplyToTrigger := false
if v, ok := opts["reply_to_trigger"].(bool); ok && !v {
noReplyToTrigger = true
}
peerBots := map[string]string{}
if raw, ok := opts["peer_bots"].(map[string]any); ok {
for k, v := range raw {
if s, ok := v.(string); ok && s != "" {
peerBots[k] = s
}
}
}
progressStyle := "legacy"
if v, ok := opts["progress_style"].(string); ok {
switch strings.ToLower(strings.TrimSpace(v)) {
case "", "legacy":
progressStyle = "legacy"
case "compact", "card":
progressStyle = strings.ToLower(strings.TrimSpace(v))
default:
return nil, fmt.Errorf("%s: invalid progress_style %q (want legacy, compact, or card)", name, v)
}
}
useInteractiveCard := true
if v, ok := opts["enable_feishu_card"].(bool); ok {
useInteractiveCard = v
}
// Webhook mode configuration (for Lark international version)
port, _ := opts["port"].(string)
if port == "" {
port = "8080"
}
callbackPath, _ := opts["callback_path"].(string)
if callbackPath == "" {
callbackPath = "/feishu/webhook"
}
encryptKey, _ := opts["encrypt_key"].(string)
var clientOpts []lark.ClientOptionFunc
if domain != lark.FeishuBaseUrl {
clientOpts = append(clientOpts, lark.WithOpenBaseUrl(domain))
}
base := &Platform{
platformName: name,
domain: domain,
appID: appID,
appSecret: appSecret,
progressStyle: progressStyle,
useInteractiveCard: useInteractiveCard,
reactionEmoji: reactionEmoji,
doneEmoji: doneEmoji,
allowFrom: allowFrom,
allowChat: allowChat,
groupOnly: groupOnly,
groupReplyAll: groupReplyAll,
respondToAtEveryoneAndHere: respondToAtEveryoneAndHere,
shareSessionInChannel: shareSessionInChannel,
threadIsolation: threadIsolation,
resolveMentions: resolveMentionsOpt,
noReplyToTrigger: noReplyToTrigger,
client: lark.NewClient(appID, appSecret, clientOpts...),
replayClient: newFeishuReplayClient(appID, appSecret, domain),
dedup: &core.MessageDedup{},
port: port,
callbackPath: callbackPath,
encryptKey: encryptKey,
peerBots: peerBots,
}
if !useInteractiveCard {
base.self = base
return base, nil
}
wrapped := &interactivePlatform{Platform: base}
base.self = wrapped
return wrapped, nil
}
func (p *Platform) Name() string { return p.platformName }
func (p *Platform) ProgressStyle() string { return p.progressStyle }
func (p *Platform) SupportsProgressCardPayload() bool { return true }
func (p *Platform) tag() string { return p.platformName }
func (p *Platform) dispatchPlatform() core.Platform {
if p.self != nil {
return p.self
}
return p
}
func (p *Platform) getHandler() core.MessageHandler {
p.mu.RLock()
defer p.mu.RUnlock()
return p.handler
}
func (p *Platform) getCancel() context.CancelFunc {
p.mu.RLock()
defer p.mu.RUnlock()
return p.cancel
}
func (p *Platform) getServer() *http.Server {
p.mu.RLock()
defer p.mu.RUnlock()
return p.server
}
func (p *Platform) getBotOpenID() string {
p.mu.RLock()
defer p.mu.RUnlock()
return p.botOpenID
}
func (p *Platform) KeepPreviewOnFinish() bool {
return p.useInteractiveCard
}
func (p *Platform) Start(handler core.MessageHandler) error {
p.mu.Lock()
p.handler = handler
p.mu.Unlock()
// In webhook mode (private/self-hosted Feishu/Lark), startup must not depend
// on a successful bot-info API call. Older private deployments may not support
// the same auth/bootstrap flow as the public SDK path, but the webhook server
// can still receive events and operate correctly. We therefore only attempt
// bot open_id discovery eagerly for WebSocket mode.
if !p.shouldUseWebhookMode() {
if openID, err := p.fetchBotOpenID(); err != nil {
slog.Warn(p.platformName+": failed to get bot open_id, group chat filtering disabled", "error", err)
} else {
p.mu.Lock()
p.botOpenID = openID
p.mu.Unlock()
slog.Info(p.platformName+": bot identified", "open_id", openID)
}
}
// Register for shared WebSocket: multiple projects using the same app_id
// share a single WebSocket connection to avoid Feishu's server-side
// load-balancing which randomly routes messages across connections.
group, isPrimary := registerSharedWS(p)
p.sharedGroup = group
p.isWSPrimary = isPrimary
// Secondary platforms skip connection creation — the primary's connection
// fans out events to all platforms in the shared group.
if !isPrimary {
return nil
}
p.eventHandler = dispatcher.NewEventDispatcher("", p.encryptKey).
OnP2MessageReceiveV1(func(ctx context.Context, event *larkim.P2MessageReceiveV1) error {
// Fan out to all platforms sharing this WebSocket connection.
// Each platform's onMessage applies its own allow_chat filter.
for _, sibling := range p.sharedGroup.allPlatforms() {
if err := sibling.onMessage(ctx, event); err != nil {
slog.Error("shared ws: onMessage error", "err", err)
}
}
return nil
}).
OnP2MessageRecalledV1(func(ctx context.Context, event *larkim.P2MessageRecalledV1) error {
for _, sibling := range p.sharedGroup.allPlatforms() {
if err := sibling.onMessageRecalled(ctx, event); err != nil {
slog.Error("shared ws: onMessageRecalled error", "err", err)
}
}
return nil
}).
OnP2MessageReadV1(func(ctx context.Context, event *larkim.P2MessageReadV1) error {
return nil // ignore read receipts
}).
OnP2ChatAccessEventBotP2pChatEnteredV1(func(ctx context.Context, event *larkim.P2ChatAccessEventBotP2pChatEnteredV1) error {
slog.Debug(p.platformName+": user opened bot chat", "app_id", p.appID)
return nil
}).
OnP1P2PChatCreatedV1(func(ctx context.Context, event *larkim.P1P2PChatCreatedV1) error {
slog.Debug(p.platformName+": p2p chat created", "app_id", p.appID)
return nil
}).
OnP2MessageReactionCreatedV1(func(ctx context.Context, event *larkim.P2MessageReactionCreatedV1) error {
return nil // ignore reaction events (triggered by our own addReaction)
}).
OnP2MessageReactionDeletedV1(func(ctx context.Context, event *larkim.P2MessageReactionDeletedV1) error {
return nil // ignore reaction removal events (triggered by our own removeReaction)
}).
OnP2CardActionTrigger(func(ctx context.Context, event *callback.CardActionTriggerEvent) (*callback.CardActionTriggerResponse, error) {
// Fan out card actions: try each platform, return first non-nil response.
// Each platform's onCardAction checks allow_chat before processing.
for _, sibling := range p.sharedGroup.allPlatforms() {
resp, err := sibling.onCardAction(event)
if err != nil {
return nil, err
}
if resp != nil {
return resp, nil
}
}
return nil, nil
}).
OnP2BotMenuV6(func(ctx context.Context, event *larkapplication.P2BotMenuV6) error {
for _, sibling := range p.sharedGroup.allPlatforms() {
if err := sibling.onBotMenu(event); err != nil {
slog.Error("shared ws: onBotMenu error", "err", err)
}
}
return nil
})
if p.useInteractiveCard {
slog.Info(p.platformName + ": interactive card mode enabled, ensure card.action.trigger event is subscribed in Feishu console")
}
if p.shouldUseWebhookMode() {
return p.startWebhookMode()
}
return p.startWebSocketMode()
}
func (p *Platform) shouldUseWebhookMode() bool {
return strings.TrimSpace(p.encryptKey) != ""
}
// startWebSocketMode starts the WebSocket long connection mode.
func (p *Platform) startWebSocketMode() error {
wsOpts := []larkws.ClientOption{
larkws.WithEventHandler(p.eventHandler),
larkws.WithLogLevel(larkcore.LogLevelInfo),
larkws.WithLogger(&sanitizingLogger{inner: larkcore.NewEventLogger()}),
}
if p.domain != lark.FeishuBaseUrl {
wsOpts = append(wsOpts, larkws.WithDomain(p.domain))
}
p.wsClient = larkws.NewClient(p.appID, p.appSecret, wsOpts...)
ctx, cancel := context.WithCancel(context.Background())
p.mu.Lock()
p.cancel = cancel
p.mu.Unlock()
go func() {
if err := p.wsClient.Start(ctx); err != nil {
slog.Error(p.tag()+": websocket error", "error", err)
}
}()
return nil
}
// startWebhookMode starts the HTTP webhook server mode (for Lark international version)
func (p *Platform) startWebhookMode() error {
mux := http.NewServeMux()
mux.HandleFunc(p.callbackPath, p.webhookHandler)
p.mu.Lock()
p.server = &http.Server{
Addr: ":" + p.port,
Handler: mux,
}
_, cancel := context.WithCancel(context.Background())
p.cancel = cancel
p.mu.Unlock()
go func() {
slog.Info(p.tag()+": webhook server listening", "port", p.port, "path", p.callbackPath)
if err := p.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
slog.Error(p.tag()+": webhook server error", "error", err)
}
}()
return nil
}
// webhookHandler handles HTTP webhook requests from Lark international version
func (p *Platform) webhookHandler(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
slog.Error(p.tag()+": read webhook body failed", "error", err)
http.Error(w, "read body failed", http.StatusBadRequest)
return
}
// Build EventReq from HTTP request
req := &larkevent.EventReq{
Header: r.Header,
Body: body,
RequestURI: r.RequestURI,
}
// Use the SDK's event dispatcher to handle the request
resp := p.eventHandler.Handle(r.Context(), req)
// Write response
for k, v := range resp.Header {
w.Header()[k] = v
}
w.WriteHeader(resp.StatusCode)
_, _ = w.Write(resp.Body)
}
// onCardAction handles card.action.trigger callbacks via the official SDK event dispatcher.
// Three prefixes are supported:
// - nav:/xxx — render a card page and update the original card in-place
// - act:/xxx — execute an action, then render and update the card in-place
// - cmd:/xxx — legacy: dispatch as a user command (sends a new message)
func (p *Platform) onCardAction(event *callback.CardActionTriggerEvent) (*callback.CardActionTriggerResponse, error) {
if event.Event == nil || event.Event.Action == nil {
return nil, nil
}
// Check allow_chat filter: skip card actions from chats this platform doesn't own.
if event.Event.Context != nil && event.Event.Context.OpenChatID != "" {
if !core.AllowList(p.allowChat, event.Event.Context.OpenChatID) {
return nil, nil
}
}
actionVal, _ := event.Event.Action.Value["action"].(string)
// select_static callbacks put the chosen value in event.Event.Action.Option
if actionVal == "" && event.Event.Action.Option != "" {
actionVal = event.Event.Action.Option
}
if actionVal == "" {
switch event.Event.Action.Name {
case "delete_mode_submit":
actionVal = "act:/delete-mode form-submit"
case "delete_mode_cancel":
actionVal = "act:/delete-mode cancel"
}
}
if actionVal == "act:/delete-mode form-submit" {
ids := collectDeleteModeSelectedFromFormValue(event.Event.Action.FormValue)
if len(ids) > 0 {
actionVal += " " + strings.Join(ids, ",")
}
}
userID := ""
if event.Event.Operator != nil {
userID = event.Event.Operator.OpenID
}
chatID := ""
messageID := ""
if event.Event.Context != nil {
chatID = event.Event.Context.OpenChatID
messageID = event.Event.Context.OpenMessageID
}
if chatID == "" {
chatID = userID
}
sessionKey := p.sessionKeyFromCardAction(chatID, userID, event.Event.Action.Value)
// nav: / act: — synchronous card update
if strings.HasPrefix(actionVal, "nav:") || strings.HasPrefix(actionVal, "act:") {
if messageID != "" {
p.cardActionMsgMu.Lock()
if p.cardActionMsgIDs == nil {
p.cardActionMsgIDs = make(map[string]string)
}
p.cardActionMsgIDs[sessionKey] = messageID
p.cardActionMsgMu.Unlock()
}
// Feishu uses native form checker for delete-mode toggle,
// so return a toast without calling cardNavHandler to avoid a full card refresh.
if strings.HasPrefix(actionVal, "act:/delete-mode toggle ") {
return &callback.CardActionTriggerResponse{
Toast: &callback.Toast{
Type: "info",
Content: "已记录选择(Selection recorded)",
},
}, nil
}
if p.cardNavHandler != nil {
done := make(chan *core.Card, 1)
go func() {
done <- p.cardNavHandler(actionVal, sessionKey)
}()
select {
case card := <-done:
if card != nil {
return &callback.CardActionTriggerResponse{
Card: &callback.Card{
Type: "raw",
Data: renderCardMap(card, sessionKey),
},
}, nil
}
case <-time.After(cardNavTimeout):
go func() {
card := <-done
if card == nil {
return
}
if refresher, ok := p.self.(core.CardRefresher); ok {
if err := refresher.RefreshCard(context.Background(), sessionKey, card); err != nil {
slog.Warn(p.tag()+": async card refresh failed", "action", actionVal, "err", err)
}
}
}()
return &callback.CardActionTriggerResponse{
Toast: &callback.Toast{
Type: "info",
Content: "⏳ Loading... / 加载中...",
},
}, nil
}
}
if strings.HasPrefix(actionVal, "act:") {
slog.Debug(p.tag()+": card action produced no card update", "action", actionVal)
return nil, nil
}
slog.Warn(p.tag()+": card nav returned nil, ignoring", "action", actionVal)
return nil, nil
}
// perm: — permission response with in-place card update
if strings.HasPrefix(actionVal, "perm:") {
var responseText string
switch actionVal {
case "perm:allow":
responseText = "allow"
case "perm:deny":
responseText = "deny"
case "perm:allow_all":
responseText = "allow all"
default:
return nil, nil
}
rctx := replyContext{messageID: messageID, chatID: chatID, sessionKey: sessionKey}
h := p.getHandler()
go h(p.dispatchPlatform(), &core.Message{
SessionKey: sessionKey,
Platform: p.platformName,
UserID: userID,
UserName: p.resolveUserName(userID),
ChatName: p.resolveChatName(chatID),
Content: responseText,
ReplyCtx: rctx,
})
permLabel, _ := event.Event.Action.Value["perm_label"].(string)
permColor, _ := event.Event.Action.Value["perm_color"].(string)
permBody, _ := event.Event.Action.Value["perm_body"].(string)
if permColor == "" {
permColor = "green"
}
cb := core.NewCard().Title(permLabel, permColor)
if permBody != "" {
cb.Markdown(permBody)
}
return &callback.CardActionTriggerResponse{
Card: &callback.Card{
Type: "raw",
Data: renderCardMap(cb.Build(), sessionKey),
},
}, nil
}
// askq: — AskUserQuestion option selected, forward as user message
if strings.HasPrefix(actionVal, "askq:") {
rctx := replyContext{messageID: messageID, chatID: chatID, sessionKey: sessionKey}
h := p.getHandler()
go h(p.dispatchPlatform(), &core.Message{
SessionKey: sessionKey,
Platform: p.platformName,
UserID: userID,
UserName: p.resolveUserName(userID),
ChatName: p.resolveChatName(chatID),
Content: actionVal,
ReplyCtx: rctx,
})
answerLabel, _ := event.Event.Action.Value["askq_label"].(string)
askqQuestion, _ := event.Event.Action.Value["askq_question"].(string)
if answerLabel == "" {
answerLabel = actionVal
}
cb := core.NewCard().Title("✅ "+answerLabel, "green")
if askqQuestion != "" {
cb.Markdown(askqQuestion)
}
cb.Markdown("**→ " + answerLabel + "**")
return &callback.CardActionTriggerResponse{
Card: &callback.Card{
Type: "raw",
Data: renderCardMap(cb.Build(), sessionKey),
},
}, nil
}
// cmd: — async command dispatch
if strings.HasPrefix(actionVal, "cmd:") {
cmdText := strings.TrimPrefix(actionVal, "cmd:")
rctx := replyContext{messageID: messageID, chatID: chatID, sessionKey: sessionKey}
slog.Info(p.tag()+": card action dispatched as command", "cmd", cmdText, "user", userID)
h := p.getHandler()
go h(p.dispatchPlatform(), &core.Message{
SessionKey: sessionKey,
Platform: p.platformName,
UserID: userID,
UserName: p.resolveUserName(userID),
ChatName: p.resolveChatName(chatID),
Content: cmdText,
ReplyCtx: rctx,
})
}
return nil, nil
}
func (p *Platform) addReaction(messageID string) string {
return p.addReactionWithEmoji(messageID, p.reactionEmoji)
}
func (p *Platform) addReactionWithEmoji(messageID, emojiType string) string {
if emojiType == "" {
return ""
}
resp, err := p.client.Im.MessageReaction.Create(context.Background(),
larkim.NewCreateMessageReactionReqBuilder().
MessageId(messageID).
Body(larkim.NewCreateMessageReactionReqBodyBuilder().
ReactionType(&larkim.Emoji{EmojiType: &emojiType}).
Build()).
Build())
if err != nil {
slog.Debug(p.tag()+": add reaction failed", "error", err)
return ""
}
if !resp.Success() {
slog.Debug(p.tag()+": add reaction failed", "code", resp.Code, "msg", resp.Msg)
return ""
}
if resp.Data != nil && resp.Data.ReactionId != nil {
return *resp.Data.ReactionId
}
return ""
}
func (p *Platform) removeReaction(messageID, reactionID string) {
if reactionID == "" || messageID == "" {
return
}
resp, err := p.client.Im.MessageReaction.Delete(context.Background(),
larkim.NewDeleteMessageReactionReqBuilder().
MessageId(messageID).
ReactionId(reactionID).
Build())
if err != nil {
slog.Debug(p.tag()+": remove reaction failed", "error", err)
return
}
if !resp.Success() {
slog.Debug(p.tag()+": remove reaction failed", "code", resp.Code, "msg", resp.Msg)
}
}
// StartTyping adds an emoji reaction to the user's message and returns a stop
// function that removes the reaction when processing is complete.
func (p *Platform) StartTyping(ctx context.Context, rctx any) (stop func()) {
rc, ok := rctx.(replyContext)
if !ok || rc.messageID == "" {
return func() {}
}
reactionID := p.addReaction(rc.messageID)
return func() {
go p.removeReaction(rc.messageID, reactionID)
}
}
// AddDoneReaction adds a "done" emoji reaction so the user gets a push
// notification when the agent finishes a multi-round turn in quiet mode.
func (p *Platform) AddDoneReaction(rctx any) {
if p.doneEmoji == "" {
return
}
rc, ok := rctx.(replyContext)
if !ok || rc.messageID == "" {
return
}
go p.addReactionWithEmoji(rc.messageID, p.doneEmoji)
}
const recalledMessageTTL = 10 * time.Minute
const cardNavTimeout = 2500 * time.Millisecond
func (p *Platform) markMessageRecalled(messageID string) {
messageID = strings.TrimSpace(messageID)
if messageID == "" {
return
}
now := time.Now()
p.recalledMu.Lock()
defer p.recalledMu.Unlock()
if p.recalledMsgIDs == nil {
p.recalledMsgIDs = make(map[string]time.Time)
}
for id, markedAt := range p.recalledMsgIDs {
if now.Sub(markedAt) > recalledMessageTTL {
delete(p.recalledMsgIDs, id)
}
}
p.recalledMsgIDs[messageID] = now
}
func (p *Platform) isMessageRecalled(messageID string) bool {
messageID = strings.TrimSpace(messageID)
if messageID == "" {
return false
}
now := time.Now()
p.recalledMu.Lock()
defer p.recalledMu.Unlock()
markedAt, ok := p.recalledMsgIDs[messageID]
if !ok {
return false
}
if now.Sub(markedAt) > recalledMessageTTL {
delete(p.recalledMsgIDs, messageID)
return false
}
return true
}
func isMessageWithdrawnCode(code int, msg string) bool {
msg = strings.ToLower(strings.TrimSpace(msg))
if code == 230011 {
return true
}
for _, needle := range []string{"withdrawn", "recalled", "recall", "deleted", "not found", "not exist", "撤回"} {
if strings.Contains(msg, needle) {
return true
}
}
return false
}
func (p *Platform) IsMessageRecalled(ctx context.Context, rctx any) (bool, error) {
rc, ok := rctx.(replyContext)
if !ok || strings.TrimSpace(rc.messageID) == "" {
return false, nil
}
messageID := strings.TrimSpace(rc.messageID)
if p.isMessageRecalled(messageID) {
return true, nil
}
if p.client == nil {
return false, fmt.Errorf("%s: client not initialized", p.tag())
}
req := larkim.NewGetMessageReqBuilder().
MessageId(messageID).
UserIdType(larkim.UserIdTypeGetMessageOpenId).
Build()
var resp *larkim.GetMessageResp
if err := p.withTransientRetry(ctx, "get message", func() error {
return p.withFreshTenantAccessTokenRetry(ctx, "get message", func(client *lark.Client, options ...larkcore.RequestOptionFunc) error {
var err error
resp, err = client.Im.Message.Get(ctx, req, options...)
if err != nil {
return fmt.Errorf("%s: get message api call: %w", p.tag(), err)
}
if !resp.Success() {
return fmt.Errorf("%s: get message failed code=%d msg=%s", p.tag(), resp.Code, resp.Msg)
}
return nil
})
}); err != nil {
if resp != nil && isMessageWithdrawnCode(resp.Code, resp.Msg) {
p.markMessageRecalled(messageID)
return true, nil
}
if isMessageWithdrawnError(err) {
p.markMessageRecalled(messageID)
return true, nil
}
return false, err
}
if resp == nil || resp.Data == nil || len(resp.Data.Items) == 0 {
p.markMessageRecalled(messageID)
return true, nil
}
for _, item := range resp.Data.Items {
if item != nil && item.Deleted != nil && *item.Deleted {
p.markMessageRecalled(messageID)
return true, nil
}
}
return false, nil
}
func isMessageWithdrawnError(err error) bool {
if err == nil {
return false
}
return isMessageWithdrawnCode(0, err.Error())
}
func (p *Platform) dispatchCoreMessage(msg *core.Message) {
h := p.getHandler()
if msg == nil || h == nil {
return
}
if p.isMessageRecalled(msg.MessageID) {
slog.Debug(p.tag()+": recalled message dispatch dropped", "message_id", msg.MessageID)
return
}
h(p.dispatchPlatform(), msg)
}
func (p *Platform) onMessageRecalled(_ context.Context, event *larkim.P2MessageRecalledV1) error {
if event == nil || event.Event == nil {
return nil
}
messageID := stringValue(event.Event.MessageId)
chatID := stringValue(event.Event.ChatId)
if messageID == "" {
slog.Debug(p.tag()+": recall event without message id", "chat_id", chatID)
return nil
}
if chatID != "" && !core.AllowList(p.allowChat, chatID) {
slog.Debug(p.tag()+": recall event from unauthorized chat", "chat_id", chatID, "message_id", messageID)
return nil
}
p.markMessageRecalled(messageID)
slog.Info(p.tag()+": message recalled",
"message_id", messageID,
"chat_id", chatID,
"recall_type", stringValue(event.Event.RecallType),
)
h := p.getHandler()
if h == nil {
return nil
}
h(p.dispatchPlatform(), &core.Message{
Platform: p.platformName,
MessageID: messageID,
Recalled: true,
ReplyCtx: replyContext{messageID: messageID, chatID: chatID},
})
return nil
}
func (p *Platform) onMessage(ctx context.Context, event *larkim.P2MessageReceiveV1) error {
msg := event.Event.Message
sender := event.Event.Sender
msgType := ""
if msg.MessageType != nil {
msgType = *msg.MessageType
}
chatID := ""
if msg.ChatId != nil {
chatID = *msg.ChatId
}
userID := userIDFromEvent(sender.SenderId)
// userName and chatName are resolved in dispatchMessage to avoid blocking
// the SDK dispatcher goroutine with synchronous HTTP calls.
messageID := ""
if msg.MessageId != nil {
messageID = *msg.MessageId
}
if p.isMessageRecalled(messageID) {
slog.Debug(p.tag()+": recalled message ignored before dispatch", "message_id", messageID)
return nil
}
if p.dedup.IsDuplicate(messageID) {
slog.Debug(p.tag()+": duplicate message ignored", "message_id", messageID)
return nil
}
if msg.CreateTime != nil {
if ms, err := strconv.ParseInt(*msg.CreateTime, 10, 64); err == nil {
msgTime := time.Unix(ms/1000, (ms%1000)*int64(time.Millisecond))
if core.IsOldMessage(msgTime) {
slog.Debug(p.tag()+": ignoring old message after restart", "create_time", *msg.CreateTime)
return nil
}
}
}
chatType := ""
if msg.ChatType != nil {
chatType = *msg.ChatType
}
mentionCount := len(msg.Mentions)
slog.Debug(p.tag()+": inbound message",
"message_id", messageID,
"chat_id", chatID,
"chat_type", chatType,
"root_id", stringValue(msg.RootId),
"thread_id", stringValue(msg.ThreadId),
"parent_id", stringValue(msg.ParentId),
"mentions", mentionCount,
"group_reply_all", p.groupReplyAll,
"thread_isolation", p.threadIsolation,
)
// Pre-compute sessionKey so the @bot filter below can consult the active
// thread set; sessionKey is also used downstream for dispatch.
sessionKey := p.makeSessionKey(msg, chatID, userID)
if chatType == "group" && !p.groupReplyAll && p.getBotOpenID() != "" {
if !isBotMentioned(msg.Mentions, p.getBotOpenID()) {
switch {
// Feishu @all sends {"text":"@_all"} with 0 mentions.
case p.respondToAtEveryoneAndHere && msg.Content != nil && strings.Contains(*msg.Content, "@_all"):
slog.Debug(p.tag()+": responding to @all message", "chat_id", chatID)
// Once a thread has been engaged via @bot, allow follow-up
// attachment-only messages (image/file/audio) in the same thread
// through without re-mentioning the bot. Plain text and rich-text
// posts still require an explicit @bot to avoid pulling in
// unrelated chatter.
case p.threadIsolation && isAttachmentMsgType(msgType) && p.isActiveThreadSession(sessionKey):
slog.Debug(p.tag()+": passing attachment through active thread without mention",
"chat_id", chatID, "session_key", sessionKey, "msg_type", msgType, "message_id", messageID)
default:
slog.Debug(p.tag()+": ignoring group message without bot mention", "chat_id", chatID)
return nil
}
}
}
if !core.AllowList(p.allowFrom, userID) {
slog.Debug(p.tag()+": message from unauthorized user", "user", userID)
return nil
}
if chatType == "group" && !core.AllowList(p.allowChat, chatID) {
slog.Debug(p.tag()+": message from unauthorized chat", "chat_id", chatID)
return nil
}
if chatType != "group" && p.groupOnly {
slog.Debug(p.tag()+": p2p message skipped (group_only=true)", "chat_type", chatType)
return nil
}
if msg.Content == nil && msgType != "merge_forward" {
slog.Debug(p.tag()+": message content is nil", "message_id", messageID, "type", msgType)
return nil
}
// Capture content before going async — the SDK may reuse the event object.
content := ""
if msg.Content != nil {
content = *msg.Content
}
mentions := msg.Mentions
parentID := stringValue(msg.ParentId)
rctx := replyContext{messageID: messageID, chatID: chatID, sessionKey: sessionKey}
slog.Debug(p.tag()+": routed inbound message",
"message_id", messageID,
"session_key", sessionKey,
"reply_in_thread", p.shouldReplyInThread(rctx),
)
// Mark this thread as bot-engaged so subsequent attachment-only messages
// in the same thread can pass through without re-mentioning the bot.
p.markThreadSessionActive(sessionKey)
// Dispatch message handling asynchronously so the SDK event loop is not
// blocked by IO-heavy operations (image/audio download, handler HTTP calls).
// The dedup and old-message checks above remain synchronous to guarantee
// correctness before spawning the goroutine.
go p.dispatchMessage(ctx, msgType, content, mentions, messageID, sessionKey, userID, chatID, rctx, parentID)
return nil
}
// dispatchMessage handles the message content parsing, media download, and
// handler invocation. It runs in its own goroutine so that onMessage returns
// quickly and does not block the SDK event loop.
func (p *Platform) dispatchMessage(ctx context.Context, msgType, content string, mentions []*larkim.MentionEvent, messageID, sessionKey, userID, chatID string, rctx replyContext, parentID string) {
if p.isMessageRecalled(messageID) {
slog.Debug(p.tag()+": recalled message ignored in async dispatch", "message_id", messageID)
return
}
// Resolve user and chat names asynchronously so SDK dispatcher is not blocked.
userName := ""
if userID != "" {
userName = p.resolveUserName(userID)
}
chatName := p.resolveChatName(chatID)
// If this message is a reply to another message, fetch the quoted content
// and prepend it so the agent has full context.
// Skip quote injection when thread_isolation is enabled and the message is
// inside a thread — the thread already provides conversational context, and
// long quoted prefixes can drown out the user's actual text (issue #764).
var quoted quotedMessage
if parentID != "" && !(p.threadIsolation && isThreadSessionKey(sessionKey)) {
quoted = p.fetchQuotedMessage(ctx, parentID)
}
switch msgType {
case "text":
var textBody struct {
Text string `json:"text"`
}
if err := json.Unmarshal([]byte(content), &textBody); err != nil {
slog.Error(p.tag()+": failed to parse text content", "error", err)
return
}
text := stripMentions(textBody.Text, mentions, p.getBotOpenID())
if text == "" && quoted.text == "" && len(quoted.images) == 0 {
slog.Debug(p.tag()+": dropping empty text after mention stripping",
"message_id", messageID,
"raw_text_len", len(textBody.Text),
"mentions", len(mentions),
)
return
}
p.dispatchCoreMessage(&core.Message{
SessionKey: sessionKey, Platform: p.platformName,
MessageID: messageID,
UserID: userID, UserName: userName, ChatName: chatName,
Content: text, ExtraContent: quoted.text, Images: quoted.images, ReplyCtx: rctx,
})
case "image":
var imgBody struct {
ImageKey string `json:"image_key"`
}
if err := json.Unmarshal([]byte(content), &imgBody); err != nil {
slog.Error(p.tag()+": failed to parse image content", "error", err)
return
}
imgData, mimeType, err := p.downloadImage(messageID, imgBody.ImageKey)
if err != nil {
slog.Error(p.tag()+": download image failed", "error", err)
if sendErr := p.Send(ctx, rctx, "⚠️ Image download failed (network error). Please resend."); sendErr != nil {
slog.Error(p.tag()+": failed to notify user about image download failure", "error", sendErr)
}
return
}
p.dispatchCoreMessage(&core.Message{
SessionKey: sessionKey, Platform: p.platformName,
MessageID: messageID,
UserID: userID, UserName: userName, ChatName: chatName,
Images: []core.ImageAttachment{{MimeType: mimeType, Data: imgData}},
ReplyCtx: rctx,
})
case "audio":
var audioBody struct {
FileKey string `json:"file_key"`
Duration int `json:"duration"` // milliseconds
}
if err := json.Unmarshal([]byte(content), &audioBody); err != nil {
slog.Error(p.tag()+": failed to parse audio content", "error", err)
return
}
slog.Debug(p.tag()+": audio received", "user", userID, "file_key", audioBody.FileKey)
audioData, err := p.downloadResource(messageID, audioBody.FileKey, "file")
if err != nil {
slog.Error(p.tag()+": download audio failed", "error", err)
if sendErr := p.Send(ctx, rctx, "⚠️ Voice message download failed (network error). Please resend."); sendErr != nil {
slog.Error(p.tag()+": failed to notify user about audio download failure", "error", sendErr)
}
return
}
p.dispatchCoreMessage(&core.Message{
SessionKey: sessionKey, Platform: p.platformName,
MessageID: messageID,
UserID: userID, UserName: userName, ChatName: chatName,
Audio: &core.AudioAttachment{
MimeType: "audio/opus",
Data: audioData,
Format: "ogg",
Duration: audioBody.Duration / 1000,
},
ReplyCtx: rctx,
})
case "post":
textParts, images := p.parsePostContent(messageID, content)
text := stripMentions(strings.Join(textParts, "\n"), mentions, p.getBotOpenID())
if text == "" && len(images) == 0 && quoted.text == "" && len(quoted.images) == 0 {
return
}
p.dispatchCoreMessage(&core.Message{
SessionKey: sessionKey, Platform: p.platformName,
MessageID: messageID,
UserID: userID, UserName: userName, ChatName: chatName,
Content: text, ExtraContent: quoted.text, Images: append(quoted.images, images...),
ReplyCtx: rctx,
})
case "file":
var fileBody struct {
FileKey string `json:"file_key"`
FileName string `json:"file_name"`
}
if err := json.Unmarshal([]byte(content), &fileBody); err != nil {
slog.Error(p.tag()+": failed to parse file content", "error", err)
return
}
slog.Info(p.tag()+": file received", "user", userID, "file_key", fileBody.FileKey, "file_name", fileBody.FileName)
fileData, err := p.downloadResource(messageID, fileBody.FileKey, "file")
if err != nil {
slog.Error(p.tag()+": download file failed", "error", err)
if sendErr := p.Send(ctx, rctx, "⚠️ File download failed (network error). Please resend."); sendErr != nil {
slog.Error(p.tag()+": failed to notify user about file download failure", "error", sendErr)
}
return
}
slog.Debug(p.tag()+": file downloaded", "file_name", fileBody.FileName, "size", len(fileData))
mimeType := detectMimeType(fileData)
p.dispatchCoreMessage(&core.Message{
SessionKey: sessionKey, Platform: p.platformName,
MessageID: messageID,
UserID: userID, UserName: userName, ChatName: chatName,
Files: []core.FileAttachment{{
MimeType: mimeType,
Data: fileData,
FileName: fileBody.FileName,
}},
ReplyCtx: rctx,
})
case "merge_forward":
text, images, files := p.parseMergeForward(messageID)
if text == "" && len(images) == 0 && len(files) == 0 {
slog.Warn(p.tag()+": merge_forward produced no content", "message_id", messageID)
return
}
coreMsg := &core.Message{
SessionKey: sessionKey, Platform: p.platformName,
MessageID: messageID,
UserID: userID, UserName: userName, ChatName: chatName,
Content: text,
Images: images,
Files: files,
ReplyCtx: rctx,
}
p.dispatchCoreMessage(coreMsg)
case "sticker":
var stickerBody struct {
FileKey string `json:"file_key"`
}
if err := json.Unmarshal([]byte(content), &stickerBody); err != nil {
slog.Error(p.tag()+": failed to parse sticker content", "error", err)
return
}
slog.Info(p.tag()+": sticker received", "user", userID, "file_key", stickerBody.FileKey)
imgData, mimeType, err := p.downloadImage(messageID, stickerBody.FileKey)
if err != nil {
slog.Warn(p.tag()+": download sticker failed, falling back to placeholder", "error", err)
p.dispatchCoreMessage(&core.Message{
SessionKey: sessionKey, Platform: p.platformName,
MessageID: messageID,
UserID: userID, UserName: userName, ChatName: chatName,
Content: "[sticker]", ExtraContent: quoted.text, ReplyCtx: rctx,
})
return
}
p.dispatchCoreMessage(&core.Message{
SessionKey: sessionKey, Platform: p.platformName,
MessageID: messageID,
UserID: userID, UserName: userName, ChatName: chatName,
Images: []core.ImageAttachment{{MimeType: mimeType, Data: imgData}},
ReplyCtx: rctx,
})
case "media":
var mediaBody struct {
FileKey string `json:"file_key"`
ImageKey string `json:"image_key"`
FileName string `json:"file_name"`
Duration int `json:"duration"`
}
if err := json.Unmarshal([]byte(content), &mediaBody); err != nil {
slog.Error(p.tag()+": failed to parse media content", "error", err)
return
}
slog.Info(p.tag()+": media received", "user", userID, "file_key", mediaBody.FileKey, "file_name", mediaBody.FileName)
text := "[video"
if mediaBody.FileName != "" {
text += ": " + mediaBody.FileName
}
if mediaBody.Duration > 0 {
text += fmt.Sprintf(", %ds", mediaBody.Duration/1000)
}
text += "]"
var images []core.ImageAttachment
if mediaBody.ImageKey != "" {
if thumbData, thumbMime, err := p.downloadImage(messageID, mediaBody.ImageKey); err == nil {
images = append(images, core.ImageAttachment{MimeType: thumbMime, Data: thumbData})
} else {
slog.Warn(p.tag()+": download media thumbnail failed", "error", err)
}
}
p.dispatchCoreMessage(&core.Message{
SessionKey: sessionKey, Platform: p.platformName,
MessageID: messageID,
UserID: userID, UserName: userName, ChatName: chatName,
Content: text, ExtraContent: quoted.text, Images: images, ReplyCtx: rctx,
})
default:
slog.Debug(p.tag()+": ignoring unsupported message type", "type", msgType)
}
}
// resolveUserName fetches a user's display name via the Contact API, with caching.
func (p *Platform) resolveUserName(openID string) string {
if !isValidFeishuLookupID(openID) {
return openID
}
if cached, ok := p.userNameCache.Load(openID); ok {
return cached.(string)
}
resp, err := p.client.Contact.User.Get(context.Background(),
larkcontact.NewGetUserReqBuilder().
UserId(openID).
UserIdType("open_id").
Build())
if err != nil {
slog.Debug(p.tag()+": resolve user name failed", "open_id", openID, "error", err)
return openID
}
if !resp.Success() || resp.Data == nil || resp.Data.User == nil || resp.Data.User.Name == nil {
slog.Debug(p.tag()+": resolve user name: no data", "open_id", openID, "code", resp.Code)
return openID
}
name := *resp.Data.User.Name
p.userNameCache.Store(openID, name)
return name
}
func userIDFromEvent(id *larkim.UserId) string {
if id == nil {
return ""
}
if id.OpenId != nil && *id.OpenId != "" {
return *id.OpenId
}
if id.UserId != nil && *id.UserId != "" {
return *id.UserId
}
if id.UnionId != nil && *id.UnionId != "" {
return *id.UnionId
}
return ""
}
func isValidFeishuLookupID(id string) bool {
if id == "" {
return false
}
for _, r := range id {
switch {
case r >= 'a' && r <= 'z':
case r >= 'A' && r <= 'Z':
case r >= '0' && r <= '9':
case r == '_' || r == '-':
default:
return false
}
}
return true
}
// resolveUserNames batch-resolves open_ids to display names.
func (p *Platform) resolveUserNames(openIDs []string) map[string]string {
names := make(map[string]string, len(openIDs))
for _, id := range openIDs {
if _, ok := names[id]; !ok {
names[id] = p.resolveUserName(id)
}
}
return names
}
// resolveChatName fetches a chat/group name via the IM API, with caching.
func (p *Platform) resolveChatName(chatID string) string {
if chatID == "" {
return ""
}
if cached, ok := p.chatNameCache.Load(chatID); ok {
return cached.(string)
}
resp, err := p.client.Im.Chat.Get(context.Background(),
larkim.NewGetChatReqBuilder().ChatId(chatID).Build())
if err != nil {
slog.Debug(p.tag()+": resolve chat name failed", "chat_id", chatID, "error", err)
return chatID
}
if !resp.Success() || resp.Data == nil || resp.Data.Name == nil {
slog.Debug(p.tag()+": resolve chat name: no data", "chat_id", chatID, "code", resp.Code)
return chatID
}
name := *resp.Data.Name
if name == "" {
return chatID
}
p.chatNameCache.Store(chatID, name)
return name
}
// --- Mention resolution ---
const chatMemberCacheTTL = 1 * time.Hour
type chatMemberEntry struct {
members map[string]string // displayName -> openID
fetchedAt time.Time
}
// fetchChatMembers retrieves all members of a chat and returns a name->openID map.
func (p *Platform) fetchChatMembers(ctx context.Context, chatID string) (map[string]string, error) {
if p.client == nil {
return nil, fmt.Errorf("%s: client not initialized", p.tag())
}
members := make(map[string]string)
req := larkim.NewGetChatMembersReqBuilder().
ChatId(chatID).
MemberIdType("open_id").
PageSize(100).
Build()
timeoutCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
token, err := p.fetchFreshTenantAccessToken(timeoutCtx)
if err != nil {
return nil, fmt.Errorf("%s: fetch tenant token for chat members: %w", p.tag(), err)
}
iter, err := p.client.Im.ChatMembers.GetByIterator(timeoutCtx, req, larkcore.WithTenantAccessToken(token))
if err != nil {
return nil, fmt.Errorf("%s: list chat members: %w", p.tag(), err)
}
for {
hasMore, member, err := iter.Next()
if err != nil {
slog.Debug(p.tag()+": fetch chat members page error", "chat_id", chatID, "error", err)
break
}
if member != nil && member.Name != nil && member.MemberId != nil {
name := *member.Name
if _, exists := members[name]; !exists {
members[name] = *member.MemberId
} else {
members[name] = ""
}
}
if !hasMore {
break
}
}
return members, nil
}
// getChatMembers returns the cached name->openID map for a chat, fetching if needed.
func (p *Platform) getChatMembers(ctx context.Context, chatID string) map[string]string {
if v, ok := p.chatMemberCache.Load(chatID); ok {
entry := v.(*chatMemberEntry)
if time.Since(entry.fetchedAt) < chatMemberCacheTTL {
return entry.members
}
}
members, err := p.fetchChatMembers(ctx, chatID)
if err != nil {
slog.Debug(p.tag()+": fetch chat members failed", "chat_id", chatID, "error", err)
return nil
}
p.chatMemberCache.Store(chatID, &chatMemberEntry{members: members, fetchedAt: time.Now()})
return members
}
// resolveMentionsInContent replaces @name with Feishu at tags in raw content
// (before JSON serialization). Reverse-matches against the chat member list,
// longest name first. Uses the correct at syntax based on predicted message type.
func (p *Platform) resolveMentionsInContent(ctx context.Context, chatID, content string) string {
if !p.resolveMentions || chatID == "" || !strings.Contains(content, "@") {
return content
}
members := p.getChatMembers(ctx, chatID)
if len(members) == 0 {
return content
}
// Sort names longest-first to avoid partial matches.
names := make([]string, 0, len(members))
for name := range members {
names = append(names, name)
}
sort.Slice(names, func(i, j int) bool { return len(names[i]) > len(names[j]) })
useCardFormat := predictMsgType(content) == larkim.MsgTypeInteractive
result := content
for _, name := range names {
pattern := "@" + name
if !strings.Contains(result, pattern) {
continue
}
openID := members[name]
if openID == "" {
slog.Debug(p.tag()+": skipping ambiguous mention", "name", name)
continue
}
var atTag string
if useCardFormat {
atTag = fmt.Sprintf(``, openID)
} else {
escapedName := html.EscapeString(name)
atTag = fmt.Sprintf(`%s`, openID, escapedName)
}
slog.Debug(p.tag()+": mention resolved", "name", name, "card_format", useCardFormat)
result = strings.ReplaceAll(result, pattern, atTag)
}
return result
}
// chainMessage holds extracted data from one message in a reply chain.
type chainMessage struct {
senderName string
senderType string // "user" or "app"
text string
images []core.ImageAttachment
parentID string
}
type quotedMessage struct {
text string
images []core.ImageAttachment
}
// maxReplyChainDepth is the maximum number of parent messages to traverse
// when building a reply chain. This limits API calls per inbound reply.
const maxReplyChainDepth = 5
// fetchQuotedMessage retrieves the content of a parent message that the user
// is replying to, and returns formatted context plus downloaded attachments.
// For multi-level reply chains, it traces parent_id links up to maxReplyChainDepth
// levels and returns the full conversation chain.
// Returns empty content on any failure (graceful degradation — the user's own
// message is still delivered without the quote).
func (p *Platform) fetchQuotedMessage(ctx context.Context, parentID string) quotedMessage {
chain := p.fetchReplyChain(ctx, parentID, maxReplyChainDepth)
if len(chain) == 0 {
return quotedMessage{}
}
return quotedMessage{text: formatReplyChain(chain), images: collectReplyChainImages(chain)}
}
// resolveBotSenderName returns a display name for a bot sender in a quoted
// reply chain. Feishu sets sender.id to the bot's app_id (globally stable,
// not an open_id). We consult the peer_bots config to map app_id → alias;
// if the app is unknown, we surface the app_id so operators can add it to
// the config rather than seeing an ambiguous "Bot".
func (p *Platform) resolveBotSenderName(appID string) string {
if appID == "" {
return "Bot"
}
if alias := p.peerBots[appID]; alias != "" {
return alias
}
return "Bot[" + appID + "]"
}
// fetchSingleMessage retrieves one message by ID from the Feishu API and
// returns its extracted content as a chainMessage. Returns nil on any failure.
func (p *Platform) fetchSingleMessage(ctx context.Context, messageID string) *chainMessage {
apiPath := fmt.Sprintf("/open-apis/im/v1/messages/%s?card_msg_content_type=raw_card_content", messageID)
apiResp, err := p.client.Get(ctx, apiPath, nil, larkcore.AccessTokenTypeTenant)
if err != nil {
slog.Debug(p.tag()+": fetch single message failed", "message_id", messageID, "error", err)
return nil
}
var resp struct {
Code int `json:"code"`
Data struct {
Items []struct {
MsgType string `json:"msg_type"`
ParentID string `json:"parent_id"`
Sender struct {
ID string `json:"id"`
SenderType string `json:"sender_type"`
} `json:"sender"`
Body struct {
Content string `json:"content"`
} `json:"body"`
Mentions []*larkim.Mention `json:"mentions"`
} `json:"items"`
} `json:"data"`
}
if err := json.Unmarshal(apiResp.RawBody, &resp); err != nil || resp.Code != 0 || len(resp.Data.Items) == 0 {
slog.Debug(p.tag()+": fetch single message: parse failed or no data", "message_id", messageID)
return nil
}
item := resp.Data.Items[0]
content := item.Body.Content
if content == "" {
return nil
}
// Extract plain text based on message type.
var text string
var images []core.ImageAttachment
switch item.MsgType {
case "text":
var textBody struct {
Text string `json:"text"`
}
if err := json.Unmarshal([]byte(content), &textBody); err == nil {
text = replaceMentions(textBody.Text, item.Mentions)
}
case "post":
textParts, postImages := p.parsePostContent(messageID, content)
text = replaceMentions(strings.Join(textParts, "\n"), item.Mentions)
images = postImages
if text == "" && len(images) > 0 {
text = "[image]"
}
case "image":
text = "[image]"
var imgBody struct {
ImageKey string `json:"image_key"`
}
if err := json.Unmarshal([]byte(content), &imgBody); err == nil && imgBody.ImageKey != "" {
imgData, mimeType, err := p.downloadImage(messageID, imgBody.ImageKey)
if err != nil {
slog.Error(p.tag()+": download quoted image failed", "error", err, "message_id", messageID, "key", imgBody.ImageKey)
} else {
images = append(images, core.ImageAttachment{MimeType: mimeType, Data: imgData})
}
}
case "interactive":
text = extractInteractiveCardText(content)
default:
text = fmt.Sprintf("[%s]", item.MsgType)
}
if text == "" {
return nil
}
// Resolve sender name.
senderName := ""
if item.Sender.SenderType == "app" {
senderName = p.resolveBotSenderName(item.Sender.ID)
} else if item.Sender.ID != "" {
resolved := p.resolveUserName(item.Sender.ID)
if resolved != item.Sender.ID {
senderName = resolved
} else {
senderName = "User"
}
}
if senderName == "" {
senderName = "unknown"
}
return &chainMessage{
senderName: senderName,
senderType: item.Sender.SenderType,
text: text,
images: images,
parentID: item.ParentID,
}
}
func collectReplyChainImages(chain []chainMessage) []core.ImageAttachment {
var images []core.ImageAttachment
for _, msg := range chain {
images = append(images, msg.images...)
}
return images
}
// fetchReplyChain iteratively traverses parent_id links to build a reply chain.
// Returns messages in chronological order (oldest first). Stops on any failure,
// circular reference, or when maxDepth is reached.
func (p *Platform) fetchReplyChain(ctx context.Context, parentID string, maxDepth int) []chainMessage {
var chain []chainMessage
visited := make(map[string]struct{})
currentID := parentID
for currentID != "" && len(chain) < maxDepth {
if _, seen := visited[currentID]; seen {
slog.Debug(p.tag()+": reply chain: circular reference detected", "message_id", currentID)
break
}
visited[currentID] = struct{}{}
msg := p.fetchSingleMessage(ctx, currentID)
if msg == nil {
break
}
chain = append(chain, *msg)
currentID = msg.parentID
}
// Reverse to chronological order (oldest first).
for i, j := 0, len(chain)-1; i < j; i, j = i+1, j-1 {
chain[i], chain[j] = chain[j], chain[i]
}
return chain
}
// formatReplyChain formats a slice of chain messages into a readable string.
// Single-message chains use the legacy format for backward compatibility.
// Multi-message chains use a numbered format with role labels.
func formatReplyChain(chain []chainMessage) string {
if len(chain) == 0 {
return ""
}
// Single message: backward-compatible format.
if len(chain) == 1 {
return fmt.Sprintf("[Quoted message from %s]:\n%s\n\n", chain[0].senderName, chain[0].text)
}
// Multi-message: numbered chain format.
var b strings.Builder
fmt.Fprintf(&b, "--- Reply chain (%d messages) ---\n", len(chain))
for i, msg := range chain {
role := "user"
if msg.senderType == "app" {
role = "assistant"
}
fmt.Fprintf(&b, "[%d] %s (%s):\n%s\n\n", i+1, msg.senderName, role, msg.text)
}
b.WriteString("---\n\n")
return b.String()
}
// extractPostPlainText extracts plain text from a Lark post (rich text) JSON content.
func extractPostPlainText(content string) string {
var post struct {
Content [][]struct {
Tag string `json:"tag"`
Text string `json:"text"`
Href string `json:"href,omitempty"`
Language string `json:"language,omitempty"`
UserId string `json:"user_id,omitempty"`
UserName string `json:"user_name,omitempty"`
} `json:"content"`
Title string `json:"title"`
}
// Post content may be wrapped in a locale key like {"zh_cn": {...}}.
// Try direct parse first, then try extracting from locale wrapper.
if err := json.Unmarshal([]byte(content), &post); err != nil || len(post.Content) == 0 {
var localeWrapper map[string]json.RawMessage
if err2 := json.Unmarshal([]byte(content), &localeWrapper); err2 == nil {
for _, v := range localeWrapper {
if err3 := json.Unmarshal(v, &post); err3 == nil && len(post.Content) > 0 {
break
}
}
}
}
if len(post.Content) == 0 {
return ""
}
var parts []string
if post.Title != "" {
parts = append(parts, post.Title)
}
for _, para := range post.Content {
var line []string
for _, elem := range para {
switch elem.Tag {
case "text":
if elem.Text != "" {
line = append(line, elem.Text)
}
case "a":
if elem.Text != "" && elem.Href != "" {
line = append(line, fmt.Sprintf("[%s](%s)", elem.Text, elem.Href))
} else if elem.Text != "" {
line = append(line, elem.Text)
}
case "markdown":
if elem.Text != "" {
line = append(line, elem.Text)
}
case "at":
switch {
case elem.UserId == "all":
line = append(line, "@all")
case elem.UserName != "":
line = append(line, "@"+elem.UserName)
case elem.UserId != "":
line = append(line, "@user")
}
case "img":
line = append(line, "[image]")
case "code_block":
if elem.Text != "" {
lang := elem.Language
line = append(line, "```"+lang+"\n"+elem.Text+"\n```")
}
}
}
if len(line) > 0 {
parts = append(parts, strings.Join(line, ""))
}
}
return strings.Join(parts, "\n")
}
// extractInteractiveCardText extracts readable text from a Feishu interactive card JSON.
// With raw_card_content, the response wraps the card in {"json_card": "...", ...}.
// Supports schema 2.0 (body.property.elements with recursive nesting) and
// legacy format (top-level title + elements).
func extractInteractiveCardText(content string) string {
// Try raw_card_content format: {"json_card": "", ...}
var wrapper struct {
JsonCard string `json:"json_card"`
}
cardJSON := content
if json.Unmarshal([]byte(content), &wrapper) == nil && wrapper.JsonCard != "" {
cardJSON = wrapper.JsonCard
}
var card map[string]json.RawMessage
if err := json.Unmarshal([]byte(cardJSON), &card); err != nil {
return "[interactive card]"
}
var parts []string
// Schema 2.0: body may use property.elements (standard) or direct elements (simplified).
if raw, ok := card["body"]; ok {
var body struct {
Tag string `json:"tag"`
Elements []json.RawMessage `json:"elements"`
Property struct {
Elements []json.RawMessage `json:"elements"`
} `json:"property"`
}
if json.Unmarshal(raw, &body) == nil {
if body.Tag == "body" && len(body.Property.Elements) > 0 {
extractCardElements(body.Property.Elements, &parts)
} else if len(body.Elements) > 0 {
extractCardElements(body.Elements, &parts)
}
}
}
// Legacy: direct title string + flat/nested elements.
if len(parts) == 0 {
if raw, ok := card["header"]; ok {
var header struct {
Title struct {
Content string `json:"content"`
} `json:"title"`
}
if json.Unmarshal(raw, &header) == nil && header.Title.Content != "" {
parts = append(parts, header.Title.Content)
}
}
if len(parts) == 0 {
if raw, ok := card["title"]; ok {
var title string
if json.Unmarshal(raw, &title) == nil && title != "" {
parts = append(parts, title)
}
}
}
var elements []json.RawMessage
if raw, ok := card["elements"]; ok {
var nested [][]json.RawMessage
if json.Unmarshal(raw, &nested) == nil && len(nested) > 0 {
for _, row := range nested {
elements = append(elements, row...)
}
} else {
_ = json.Unmarshal(raw, &elements)
}
}
for _, raw := range elements {
var elem struct {
Tag string `json:"tag"`
Text string `json:"text"`
}
if json.Unmarshal(raw, &elem) == nil && elem.Tag == "text" && strings.TrimSpace(elem.Text) != "" {
parts = append(parts, elem.Text)
}
}
}
if len(parts) == 0 {
return "[interactive card]"
}
return strings.Join(parts, "\n")
}
// extractCardElements recursively extracts text from schema 2.0 card elements.
// Handles: property.content, property.text (nested element), property.elements (recursive),
// code_span, code_block (with tokenized contents), text_tag, hr, button (with open_url), etc.
func extractCardElements(elements []json.RawMessage, parts *[]string) {
for _, raw := range elements {
var elem struct {
Tag string `json:"tag"`
Content string `json:"content"`
Property struct {
Content string `json:"content"`
Contents json.RawMessage `json:"contents"`
Language string `json:"language"`
Elements []json.RawMessage `json:"elements"`
Text json.RawMessage `json:"text"`
Items json.RawMessage `json:"items"`
Columns json.RawMessage `json:"columns"`
Rows json.RawMessage `json:"rows"`
Behaviors json.RawMessage `json:"behaviors"`
} `json:"property"`
}
if json.Unmarshal(raw, &elem) != nil {
continue
}
switch elem.Tag {
case "button":
// Extract button label text and open_url from behaviors.
label := elem.Property.Content
if label == "" {
// label may be in property.text.property.content
var textElem struct {
Property struct {
Content string `json:"content"`
} `json:"property"`
}
if json.Unmarshal(elem.Property.Text, &textElem) == nil {
label = textElem.Property.Content
}
}
var openURL string
if len(elem.Property.Behaviors) > 0 {
var behaviors []struct {
Type string `json:"type"`
URL string `json:"url"`
}
if json.Unmarshal(elem.Property.Behaviors, &behaviors) == nil {
for _, b := range behaviors {
if b.Type == "open_url" && b.URL != "" {
openURL = b.URL
break
}
}
}
}
if label != "" && openURL != "" {
*parts = append(*parts, fmt.Sprintf("[%s](%s)", label, openURL))
} else if label != "" {
*parts = append(*parts, label)
}
case "code_block":
var lines []struct {
Contents []struct {
Content string `json:"content"`
} `json:"contents"`
}
if json.Unmarshal(elem.Property.Contents, &lines) == nil {
var codeLines []string
for _, line := range lines {
var lineText string
for _, tok := range line.Contents {
lineText += tok.Content
}
codeLines = append(codeLines, lineText)
}
code := strings.Join(codeLines, "")
if strings.TrimSpace(code) != "" {
lang := elem.Property.Language
if lang != "" {
*parts = append(*parts, fmt.Sprintf("```%s\n%s```", lang, code))
} else {
*parts = append(*parts, fmt.Sprintf("```\n%s```", code))
}
}
}
case "code_span":
if elem.Property.Content != "" {
*parts = append(*parts, "`"+elem.Property.Content+"`")
}
case "hr":
*parts = append(*parts, "---")
case "table":
extractCardTable(elem.Property.Columns, elem.Property.Rows, parts)
case "list":
extractCardListItems(elem.Property.Items, parts)
default:
content := elem.Property.Content
if content == "" {
content = elem.Content
}
if content != "" {
*parts = append(*parts, content)
}
if len(elem.Property.Text) > 0 {
var textElem struct {
Property struct {
Content string `json:"content"`
} `json:"property"`
}
if json.Unmarshal(elem.Property.Text, &textElem) == nil && textElem.Property.Content != "" {
*parts = append(*parts, textElem.Property.Content)
}
}
}
if len(elem.Property.Elements) > 0 {
extractCardElements(elem.Property.Elements, parts)
}
}
}
// extractCardTable extracts text from a Feishu card table element.
// Table structure: property.columns defines column names/headers,
// property.rows is an array of row objects where each key is the column name
// and the value has a "data" field containing a markdown/plain_text element.
func extractCardTable(columnsRaw, rowsRaw json.RawMessage, parts *[]string) {
var columns []struct {
DisplayName string `json:"displayName"`
Name string `json:"name"`
}
if err := json.Unmarshal(columnsRaw, &columns); err != nil || len(columns) == 0 {
return
}
var rows []map[string]struct {
Data json.RawMessage `json:"data"`
}
if err := json.Unmarshal(rowsRaw, &rows); err != nil {
return
}
// Build markdown table.
header := make([]string, len(columns))
for i, col := range columns {
header[i] = col.DisplayName
}
*parts = append(*parts, "| "+strings.Join(header, " | ")+" |")
sep := make([]string, len(columns))
for i := range sep {
sep[i] = "---"
}
*parts = append(*parts, "| "+strings.Join(sep, " | ")+" |")
for _, row := range rows {
cells := make([]string, len(columns))
for i, col := range columns {
cell := row[col.Name]
var cellParts []string
extractCardElements([]json.RawMessage{cell.Data}, &cellParts)
cells[i] = strings.Join(cellParts, " ")
}
*parts = append(*parts, "| "+strings.Join(cells, " | ")+" |")
}
}
// extractCardListItems extracts text from a Feishu card list element.
// List structure: property.items is an array of items, each with an "elements" array.
func extractCardListItems(itemsRaw json.RawMessage, parts *[]string) {
var items []struct {
Elements []json.RawMessage `json:"elements"`
}
if err := json.Unmarshal(itemsRaw, &items); err != nil {
return
}
for _, item := range items {
var itemParts []string
extractCardElements(item.Elements, &itemParts)
if len(itemParts) > 0 {
*parts = append(*parts, "- "+strings.Join(itemParts, " "))
}
}
}
// parseMergeForward fetches sub-messages of a merge_forward message via the
// GET /open-apis/im/v1/messages/{message_id} API, then formats them into
// readable text. Returns combined text, images, and files from the sub-messages.
func (p *Platform) parseMergeForward(rootMessageID string) (string, []core.ImageAttachment, []core.FileAttachment) {
resp, err := p.client.Im.Message.Get(context.Background(),
larkim.NewGetMessageReqBuilder().
MessageId(rootMessageID).
Build())
if err != nil {
slog.Error(p.tag()+": fetch merge_forward sub-messages failed", "error", err)
return "", nil, nil
}
if !resp.Success() {
slog.Error(p.tag()+": fetch merge_forward sub-messages failed", "code", resp.Code, "msg", resp.Msg)
return "", nil, nil
}
if resp.Data == nil || len(resp.Data.Items) == 0 {
slog.Warn(p.tag()+": merge_forward has no sub-messages", "message_id", rootMessageID)
return "", nil, nil
}
items := resp.Data.Items
slog.Info(p.tag()+": merge_forward sub-messages fetched", "message_id", rootMessageID, "count", len(items))
// Build tree: group children by upper_message_id, collect sender IDs
childrenMap := make(map[string][]*larkim.Message)
senderIDs := make(map[string]struct{})
for _, item := range items {
if item.MessageId != nil && *item.MessageId == rootMessageID {
continue // skip root container
}
parentID := ""
if item.UpperMessageId != nil {
parentID = *item.UpperMessageId
}
if parentID == "" || parentID == rootMessageID {
parentID = rootMessageID
}
childrenMap[parentID] = append(childrenMap[parentID], item)
if item.Sender != nil && item.Sender.Id != nil {
senderIDs[*item.Sender.Id] = struct{}{}
}
}
// Resolve sender IDs to display names
uniqueIDs := make([]string, 0, len(senderIDs))
for id := range senderIDs {
uniqueIDs = append(uniqueIDs, id)
}
nameMap := p.resolveUserNames(uniqueIDs)
var allImages []core.ImageAttachment
var allFiles []core.FileAttachment
var sb strings.Builder
sb.WriteString("\n")
p.formatMergeForwardTree(rootMessageID, childrenMap, nameMap, &sb, &allImages, &allFiles, 0)
sb.WriteString("")
return sb.String(), allImages, allFiles
}
// replaceMentions replaces @_user_N placeholders with real names from the Mentions list.
func replaceMentions(text string, mentions []*larkim.Mention) string {
for _, m := range mentions {
if m.Key != nil && m.Name != nil {
text = strings.ReplaceAll(text, *m.Key, "@"+*m.Name)
}
}
return text
}
// formatMergeForwardTree recursively formats the sub-message tree.
func (p *Platform) formatMergeForwardTree(parentID string, childrenMap map[string][]*larkim.Message, nameMap map[string]string, sb *strings.Builder, images *[]core.ImageAttachment, files *[]core.FileAttachment, depth int) {
if depth > 10 {
sb.WriteString(strings.Repeat(" ", depth) + "[nested forwarding truncated]\n")
return
}
children := childrenMap[parentID]
indent := strings.Repeat(" ", depth)
for _, item := range children {
msgID := ""
if item.MessageId != nil {
msgID = *item.MessageId
}
msgType := ""
if item.MsgType != nil {
msgType = *item.MsgType
}
senderID := ""
if item.Sender != nil && item.Sender.Id != nil {
senderID = *item.Sender.Id
}
senderName := senderID
if name, ok := nameMap[senderID]; ok {
senderName = name
}
// Format timestamp
ts := ""
if item.CreateTime != nil {
if ms, err := strconv.ParseInt(*item.CreateTime, 10, 64); err == nil {
ts = time.Unix(ms/1000, 0).Format("2006-01-02 15:04:05")
}
}
content := ""
if item.Body != nil && item.Body.Content != nil {
content = *item.Body.Content
}
switch msgType {
case "text":
var textBody struct {
Text string `json:"text"`
}
if err := json.Unmarshal([]byte(content), &textBody); err == nil && textBody.Text != "" {
msgText := replaceMentions(textBody.Text, item.Mentions)
sb.WriteString(fmt.Sprintf("%s[%s] %s:\n", indent, ts, senderName))
for _, line := range strings.Split(msgText, "\n") {
sb.WriteString(fmt.Sprintf("%s %s\n", indent, line))
}
}
case "post":
textParts, postImages := p.parsePostContent(msgID, content)
*images = append(*images, postImages...)
text := replaceMentions(strings.Join(textParts, "\n"), item.Mentions)
if text != "" {
sb.WriteString(fmt.Sprintf("%s[%s] %s:\n", indent, ts, senderName))
for _, line := range strings.Split(text, "\n") {
sb.WriteString(fmt.Sprintf("%s %s\n", indent, line))
}
}
case "image":
var imgBody struct {
ImageKey string `json:"image_key"`
}
if err := json.Unmarshal([]byte(content), &imgBody); err == nil && imgBody.ImageKey != "" {
imgData, mimeType, err := p.downloadImage(msgID, imgBody.ImageKey)
if err != nil {
slog.Error(p.tag()+": download merge_forward image failed", "error", err)
sb.WriteString(fmt.Sprintf("%s[%s] %s: [image - download failed]\n", indent, ts, senderName))
} else {
*images = append(*images, core.ImageAttachment{MimeType: mimeType, Data: imgData})
sb.WriteString(fmt.Sprintf("%s[%s] %s: [image]\n", indent, ts, senderName))
}
}
case "file":
var fileBody struct {
FileKey string `json:"file_key"`
FileName string `json:"file_name"`
}
if err := json.Unmarshal([]byte(content), &fileBody); err == nil && fileBody.FileKey != "" {
fileData, err := p.downloadResource(msgID, fileBody.FileKey, "file")
if err != nil {
slog.Error(p.tag()+": download merge_forward file failed", "error", err)
sb.WriteString(fmt.Sprintf("%s[%s] %s: [file: %s - download failed]\n", indent, ts, senderName, fileBody.FileName))
} else {
mt := detectMimeType(fileData)
*files = append(*files, core.FileAttachment{MimeType: mt, Data: fileData, FileName: fileBody.FileName})
sb.WriteString(fmt.Sprintf("%s[%s] %s: [file: %s]\n", indent, ts, senderName, fileBody.FileName))
}
}
case "merge_forward":
sb.WriteString(fmt.Sprintf("%s[%s] %s: [forwarded messages]\n", indent, ts, senderName))
p.formatMergeForwardTree(msgID, childrenMap, nameMap, sb, images, files, depth+1)
default:
sb.WriteString(fmt.Sprintf("%s[%s] %s: [%s message]\n", indent, ts, senderName, msgType))
}
}
}
func (p *Platform) Reply(ctx context.Context, rctx any, content string) error {
rc, ok := rctx.(replyContext)
if !ok {
return fmt.Errorf("%s: invalid reply context type %T", p.tag(), rctx)
}
content = p.resolveMentionsInContent(ctx, rc.chatID, content)
msgType, msgBody := buildReplyContent(content)
if !p.shouldUseThreadOrReplyAPI(rc) {
return p.sendNewMessageToChat(ctx, rc, msgType, msgBody)
}
return p.replyMessage(ctx, rc, msgType, msgBody)
}
// Send sends a message. When the original message ID is available, the message
// is sent as a reply (quoting the original) so the conversation stays threaded.
// Falls back to creating a standalone message when no message ID exists.
func (p *Platform) Send(ctx context.Context, rctx any, content string) error {
rc, ok := rctx.(replyContext)
if !ok {
return fmt.Errorf("%s: invalid reply context type %T", p.tag(), rctx)
}
if p.shouldUseThreadOrReplyAPI(rc) {
return p.Reply(ctx, rctx, content)
}
content = p.resolveMentionsInContent(ctx, rc.chatID, content)
msgType, msgBody := buildReplyContent(content)
return p.sendNewMessageToChat(ctx, rc, msgType, msgBody)
}
func (p *Platform) SendImage(ctx context.Context, rctx any, img core.ImageAttachment) error {
rc, ok := rctx.(replyContext)
if !ok {
return fmt.Errorf("%s: SendImage: invalid reply context type %T", p.tag(), rctx)
}
var uploadResp *larkim.CreateImageResp
if err := p.withTransientRetry(ctx, "upload image", func() error {
return p.withFreshTenantAccessTokenRetry(ctx, "upload image", func(client *lark.Client, options ...larkcore.RequestOptionFunc) error {
req := larkim.NewCreateImageReqBuilder().
Body(larkim.NewCreateImageReqBodyBuilder().
ImageType("message").
Image(bytes.NewReader(img.Data)).
Build()).
Build()
var err error
uploadResp, err = client.Im.Image.Create(ctx, req, options...)
if err != nil {
return fmt.Errorf("%s: upload image: %w", p.tag(), err)
}
if !uploadResp.Success() {
return fmt.Errorf("%s: upload image code=%d msg=%s", p.tag(), uploadResp.Code, uploadResp.Msg)
}
return nil
})
}); err != nil {
return err
}
if uploadResp.Data == nil || uploadResp.Data.ImageKey == nil {
return fmt.Errorf("%s: upload image: no image_key returned", p.tag())
}
imageContent, err := (&larkim.MessageImage{ImageKey: *uploadResp.Data.ImageKey}).String()
if err != nil {
return fmt.Errorf("%s: build image message: %w", p.tag(), err)
}
return p.sendMediaMessage(ctx, rc, larkim.MsgTypeImage, imageContent)
}
func (p *Platform) SendFile(ctx context.Context, rctx any, file core.FileAttachment) error {
rc, ok := rctx.(replyContext)
if !ok {
return fmt.Errorf("%s: SendFile: invalid reply context type %T", p.tag(), rctx)
}
fileName := file.FileName
if fileName == "" {
fileName = "attachment"
}
fileType := detectFeishuFileType(file.MimeType, fileName)
var uploadResp *larkim.CreateFileResp
if err := p.withTransientRetry(ctx, "upload file", func() error {
return p.withFreshTenantAccessTokenRetry(ctx, "upload file", func(client *lark.Client, options ...larkcore.RequestOptionFunc) error {
req := larkim.NewCreateFileReqBuilder().
Body(larkim.NewCreateFileReqBodyBuilder().
FileType(fileType).
FileName(fileName).
File(bytes.NewReader(file.Data)).
Build()).
Build()
var err error
uploadResp, err = client.Im.File.Create(ctx, req, options...)
if err != nil {
return fmt.Errorf("%s: upload file: %w", p.tag(), err)
}
if !uploadResp.Success() {
return fmt.Errorf("%s: upload file code=%d msg=%s", p.tag(), uploadResp.Code, uploadResp.Msg)
}
return nil
})
}); err != nil {
return err
}
if uploadResp.Data == nil || uploadResp.Data.FileKey == nil {
return fmt.Errorf("%s: upload file: no file_key returned", p.tag())
}
fileContent, err := (&larkim.MessageFile{FileKey: *uploadResp.Data.FileKey}).String()
if err != nil {
return fmt.Errorf("%s: build file message: %w", p.tag(), err)
}
return p.sendMediaMessage(ctx, rc, larkim.MsgTypeFile, fileContent)
}
func (p *Platform) sendMediaMessage(ctx context.Context, rc replyContext, msgType, content string) error {
if p.shouldUseThreadOrReplyAPI(rc) {
return p.replyMessage(ctx, rc, msgType, content)
}
return p.createMessage(ctx, rc.chatID, msgType, content, "send media message")
}
func detectFeishuFileType(mimeType, fileName string) string {
name := strings.ToLower(fileName)
switch {
case mimeType == "application/pdf" || strings.HasSuffix(name, ".pdf"):
return larkim.FileTypePdf
case strings.HasSuffix(name, ".doc") || strings.HasSuffix(name, ".docx"):
return larkim.FileTypeDoc
case strings.HasSuffix(name, ".xls") || strings.HasSuffix(name, ".xlsx") || strings.HasSuffix(name, ".csv"):
return larkim.FileTypeXls
case strings.HasSuffix(name, ".ppt") || strings.HasSuffix(name, ".pptx"):
return larkim.FileTypePpt
case mimeType == "video/mp4" || strings.HasSuffix(name, ".mp4"):
return larkim.FileTypeMp4
case mimeType == "audio/ogg" || mimeType == "audio/opus" || strings.HasSuffix(name, ".opus"):
return larkim.FileTypeOpus
default:
return larkim.FileTypeStream
}
}
func (p *Platform) downloadImage(messageID, imageKey string) ([]byte, string, error) {
resp, err := p.client.Im.MessageResource.Get(context.Background(),
larkim.NewGetMessageResourceReqBuilder().
MessageId(messageID).
FileKey(imageKey).
Type("image").
Build())
if err != nil {
return nil, "", fmt.Errorf("%s: image API: %w", p.tag(), err)
}
if !resp.Success() {
return nil, "", fmt.Errorf("%s: image API code=%d msg=%s", p.tag(), resp.Code, resp.Msg)
}
if resp.File == nil {
return nil, "", fmt.Errorf("%s: image API returned nil file body", p.tag())
}
data, err := io.ReadAll(resp.File)
if err != nil {
return nil, "", fmt.Errorf("%s: read image: %w", p.tag(), err)
}
mimeType := detectMimeType(data)
slog.Debug(p.tag()+": downloaded image", "key", imageKey, "size", len(data), "mime", mimeType)
return data, mimeType, nil
}
func (p *Platform) downloadResource(messageID, fileKey, resType string) ([]byte, error) {
resp, err := p.client.Im.MessageResource.Get(context.Background(),
larkim.NewGetMessageResourceReqBuilder().
MessageId(messageID).
FileKey(fileKey).
Type(resType).
Build())
if err != nil {
return nil, fmt.Errorf("%s: resource API: %w", p.tag(), err)
}
if !resp.Success() {
return nil, fmt.Errorf("%s: resource API code=%d msg=%s", p.tag(), resp.Code, resp.Msg)
}
if resp.File == nil {
return nil, fmt.Errorf("%s: resource API returned nil file body", p.tag())
}
data, err := io.ReadAll(resp.File)
if err != nil {
return nil, fmt.Errorf("%s: read resource: %w", p.tag(), err)
}
slog.Debug(p.tag()+": downloaded resource", "key", fileKey, "type", resType, "size", len(data))
return data, nil
}
func detectMimeType(data []byte) string {
if len(data) >= 8 {
if data[0] == 0x89 && data[1] == 'P' && data[2] == 'N' && data[3] == 'G' {
return "image/png"
}
if data[0] == 0xFF && data[1] == 0xD8 {
return "image/jpeg"
}
if string(data[:4]) == "GIF8" {
return "image/gif"
}
if string(data[:4]) == "RIFF" && string(data[8:12]) == "WEBP" {
return "image/webp"
}
}
return "image/png"
}
// predictMsgType returns the message type that buildReplyContent will choose,
// without actually building the content. Used to select the correct at syntax
// before building.
func predictMsgType(content string) string {
if !containsMarkdown(content) {
return larkim.MsgTypeText
}
if countMarkdownTables(content) <= maxCardTables {
return larkim.MsgTypeInteractive
}
return larkim.MsgTypePost
}
func buildReplyContent(content string) (msgType string, body string) {
if !containsMarkdown(content) {
b, _ := json.Marshal(map[string]string{"text": content})
return larkim.MsgTypeText, string(b)
}
// Prefer card for all markdown content — card schema 2.0 has the best
// markdown rendering (headings, blockquotes, code blocks, tables, links,
// strikethrough, etc.). Only fall back to post md tag when the content
// exceeds the card table limit (Feishu API error 11310: max 5 tables).
if countMarkdownTables(content) > maxCardTables {
return larkim.MsgTypePost, buildPostMdJSON(content)
}
return larkim.MsgTypeInteractive, buildCardJSON(sanitizeMarkdownURLs(preprocessFeishuMarkdown(content)))
}
// hasComplexMarkdown detects code blocks or tables that require card rendering.
func hasComplexMarkdown(s string) bool {
if strings.Contains(s, "```") {
return true
}
// Table: line starting and ending with |
for _, line := range strings.Split(s, "\n") {
trimmed := strings.TrimSpace(line)
if len(trimmed) > 1 && trimmed[0] == '|' && trimmed[len(trimmed)-1] == '|' {
return true
}
}
return false
}
// maxCardTables is the Feishu interactive card limit for table components.
// A single card supports at most 5 tables; exceeding this causes API error 11310.
const maxCardTables = 5
// countMarkdownTables counts the number of distinct markdown tables in s.
// A table is a group of consecutive lines where each line starts and ends with '|'.
func countMarkdownTables(s string) int {
count := 0
inTable := false
for _, line := range strings.Split(s, "\n") {
trimmed := strings.TrimSpace(line)
isTableLine := len(trimmed) > 1 && trimmed[0] == '|' && trimmed[len(trimmed)-1] == '|'
if isTableLine && !inTable {
count++
inTable = true
} else if !isTableLine {
inTable = false
}
}
return count
}
// buildPostMdJSON builds a Feishu post message using the md tag,
// which renders markdown at normal chat font size.
func buildPostMdJSON(content string) string {
content = sanitizeMarkdownURLs(content)
post := map[string]any{
"zh_cn": map[string]any{
"content": [][]map[string]any{
{
{"tag": "md", "text": content},
},
},
},
}
b, _ := json.Marshal(post)
return string(b)
}
// preprocessFeishuMarkdown ensures code fences have a newline before them,
// which prevents rendering issues in Feishu card markdown.
// Tables, headings, blockquotes, etc. are rendered natively by the card markdown element.
func preprocessFeishuMarkdown(md string) string {
// Ensure ``` has a newline before it (unless at start of text)
var b strings.Builder
b.Grow(len(md) + 32)
for i := 0; i < len(md); i++ {
if i > 0 && md[i] == '`' && i+2 < len(md) && md[i+1] == '`' && md[i+2] == '`' && md[i-1] != '\n' {
b.WriteByte('\n')
}
b.WriteByte(md[i])
}
return b.String()
}
var markdownIndicators = []string{
"```", "**", "~~", "`", "\n- ", "\n* ", "\n1. ", "\n# ", "---",
}
func containsMarkdown(s string) bool {
for _, ind := range markdownIndicators {
if strings.Contains(s, ind) {
return true
}
}
return false
}
// buildPostJSON converts markdown content to Feishu post (rich text) format.
func buildPostJSON(content string) string {
lines := strings.Split(content, "\n")
var postLines [][]map[string]any
inCodeBlock := false
var codeLines []string
codeLang := ""
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "```") {
if !inCodeBlock {
inCodeBlock = true
codeLang = strings.TrimPrefix(trimmed, "```")
codeLines = nil
} else {
inCodeBlock = false
postLines = append(postLines, []map[string]any{{
"tag": "code_block",
"language": codeLang,
"text": strings.Join(codeLines, "\n"),
}})
}
continue
}
if inCodeBlock {
codeLines = append(codeLines, line)
continue
}
// Convert # headers to bold
headerLine := line
for level := 6; level >= 1; level-- {
prefix := strings.Repeat("#", level) + " "
if strings.HasPrefix(line, prefix) {
headerLine = "**" + strings.TrimPrefix(line, prefix) + "**"
break
}
}
elements := parseInlineMarkdown(headerLine)
if len(elements) > 0 {
postLines = append(postLines, elements)
} else {
postLines = append(postLines, []map[string]any{{"tag": "text", "text": ""}})
}
}
// Handle unclosed code block
if inCodeBlock && len(codeLines) > 0 {
postLines = append(postLines, []map[string]any{{
"tag": "code_block",
"language": codeLang,
"text": strings.Join(codeLines, "\n"),
}})
}
post := map[string]any{
"zh_cn": map[string]any{
"content": postLines,
},
}
b, _ := json.Marshal(post)
return string(b)
}
// isValidFeishuHref checks whether a URL is acceptable as a Feishu post href.
// Feishu rejects non-HTTP(S) URLs with "invalid href" (code 230001).
func isValidFeishuHref(u string) bool {
return strings.HasPrefix(u, "http://") || strings.HasPrefix(u, "https://")
}
var mdLinkRe = regexp.MustCompile(`\[([^\]]*)\]\(([^)]+)\)`)
// sanitizeMarkdownURLs rewrites markdown links with non-HTTP(S) schemes
// to plain text, preventing Feishu API rejection (code 230001).
func sanitizeMarkdownURLs(md string) string {
return mdLinkRe.ReplaceAllStringFunc(md, func(match string) string {
parts := mdLinkRe.FindStringSubmatch(match)
if len(parts) < 3 {
return match
}
if isValidFeishuHref(parts[2]) {
return match
}
// Convert invalid-scheme link to "text (url)" plain text
return parts[1] + " (" + parts[2] + ")"
})
}
// parseInlineMarkdown parses a single line of markdown into Feishu post elements.
// Supports **bold** and `code` inline formatting.
func parseInlineMarkdown(line string) []map[string]any {
type markerDef struct {
pattern string
tag string
style string // for text elements with style
}
markers := []markerDef{
{pattern: "**", tag: "text", style: "bold"},
{pattern: "~~", tag: "text", style: "lineThrough"},
{pattern: "`", tag: "text", style: "code"},
{pattern: "*", tag: "text", style: "italic"},
}
var elements []map[string]any
remaining := line
for len(remaining) > 0 {
// Check for link [text](url)
linkIdx := strings.Index(remaining, "[")
if linkIdx >= 0 {
parenClose := -1
bracketClose := strings.Index(remaining[linkIdx:], "](")
if bracketClose >= 0 {
bracketClose += linkIdx
parenClose = strings.Index(remaining[bracketClose+2:], ")")
if parenClose >= 0 {
parenClose += bracketClose + 2
}
}
if parenClose >= 0 {
// Check if any marker comes before this link
foundEarlierMarker := false
for _, m := range markers {
idx := strings.Index(remaining, m.pattern)
if idx >= 0 && idx < linkIdx {
foundEarlierMarker = true
break
}
}
if !foundEarlierMarker {
linkText := remaining[linkIdx+1 : bracketClose]
linkURL := remaining[bracketClose+2 : parenClose]
if isValidFeishuHref(linkURL) {
if linkIdx > 0 {
elements = append(elements, map[string]any{"tag": "text", "text": remaining[:linkIdx]})
}
elements = append(elements, map[string]any{
"tag": "a",
"text": linkText,
"href": linkURL,
})
remaining = remaining[parenClose+1:]
continue
}
}
}
}
// Find the earliest formatting marker
bestIdx := -1
var bestMarker markerDef
for _, m := range markers {
idx := strings.Index(remaining, m.pattern)
if idx < 0 {
continue
}
// For single * marker, skip if it's actually ** (bold)
if m.pattern == "*" && idx+1 < len(remaining) && remaining[idx+1] == '*' {
idx = findSingleAsterisk(remaining)
if idx < 0 {
continue
}
}
if bestIdx < 0 || idx < bestIdx {
bestIdx = idx
bestMarker = m
}
}
if bestIdx < 0 {
if remaining != "" {
elements = append(elements, map[string]any{"tag": "text", "text": remaining})
}
break
}
if bestIdx > 0 {
elements = append(elements, map[string]any{"tag": "text", "text": remaining[:bestIdx]})
}
remaining = remaining[bestIdx+len(bestMarker.pattern):]
closeIdx := strings.Index(remaining, bestMarker.pattern)
// For single *, make sure we don't match ** as close
if bestMarker.pattern == "*" {
closeIdx = findSingleAsterisk(remaining)
}
if closeIdx < 0 {
elements = append(elements, map[string]any{"tag": "text", "text": bestMarker.pattern + remaining})
remaining = ""
break
}
inner := remaining[:closeIdx]
remaining = remaining[closeIdx+len(bestMarker.pattern):]
elements = append(elements, map[string]any{
"tag": bestMarker.tag,
"text": inner,
"style": []string{bestMarker.style},
})
}
return elements
}
// findSingleAsterisk finds the index of a single '*' not part of '**' in s.
func findSingleAsterisk(s string) int {
for i := 0; i < len(s); i++ {
if s[i] == '*' {
if i+1 < len(s) && s[i+1] == '*' {
i++ // skip **
continue
}
return i
}
}
return -1
}
// fetchBotOpenID retrieves the bot's open_id via the Feishu bot info API.
func (p *Platform) fetchBotOpenID() (string, error) {
resp, err := p.client.Get(context.Background(),
"/open-apis/bot/v3/info", nil, larkcore.AccessTokenTypeTenant)
if err != nil {
return "", fmt.Errorf("api call: %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("parse response: %w", err)
}
if result.Code != 0 {
return "", fmt.Errorf("api code=%d", result.Code)
}
return result.Bot.OpenID, nil
}
func isBotMentioned(mentions []*larkim.MentionEvent, botOpenID string) bool {
for _, m := range mentions {
if m.Id != nil && m.Id.OpenId != nil && *m.Id.OpenId == botOpenID {
return true
}
}
return false
}
// isAttachmentMsgType reports whether a Feishu message type carries only an
// attachment payload (no free-form text the user could use to address another
// human). These are the message types we are willing to admit into an
// already-engaged thread without an explicit @bot mention.
func isAttachmentMsgType(msgType string) bool {
switch msgType {
case "image", "file", "audio", "media":
return true
}
return false
}
// markThreadSessionActive records that a thread sessionKey has been engaged
// by an @bot message, enabling attachment-only follow-ups inside the thread.
// No-op when thread isolation is disabled or sessionKey is not a thread key.
func (p *Platform) markThreadSessionActive(sessionKey string) {
if !p.threadIsolation || !isThreadSessionKey(sessionKey) {
return
}
p.activeThreadSessions.Store(sessionKey, time.Now())
}
// isActiveThreadSession reports whether the given sessionKey corresponds to a
// thread that has previously been engaged by an @bot message.
func (p *Platform) isActiveThreadSession(sessionKey string) bool {
if !p.threadIsolation || !isThreadSessionKey(sessionKey) {
return false
}
_, ok := p.activeThreadSessions.Load(sessionKey)
return ok
}
// stripMentions processes @mention placeholders (e.g. @_user_1) in text.
// The bot's own mention is removed; other user mentions are replaced with
// their display name so the agent can see who was referenced.
func stripMentions(text string, mentions []*larkim.MentionEvent, botOpenID string) string {
if len(mentions) == 0 {
return text
}
for _, m := range mentions {
if m.Key == nil {
continue
}
if botOpenID != "" && m.Id != nil && m.Id.OpenId != nil && *m.Id.OpenId == botOpenID {
text = strings.ReplaceAll(text, *m.Key, "")
} else if m.Name != nil && *m.Name != "" {
text = strings.ReplaceAll(text, *m.Key, "@"+*m.Name)
} else {
text = strings.ReplaceAll(text, *m.Key, "")
}
}
return strings.TrimSpace(text)
}
// TODO: Session-key derivation and reply-thread behavior are split across multiple code paths here.
// Should revisit thread/root handling without changing thread_isolation=false behavior.
func (p *Platform) makeSessionKey(msg *larkim.EventMessage, chatID, userID string) string {
if p.threadIsolation && msg != nil && stringValue(msg.ChatType) == "group" {
rootID := stringValue(msg.RootId)
if rootID == "" {
rootID = stringValue(msg.MessageId)
}
if rootID != "" {
return fmt.Sprintf("%s:%s:root:%s", p.tag(), chatID, rootID)
}
}
if p.shareSessionInChannel {
return fmt.Sprintf("%s:%s", p.tag(), chatID)
}
return fmt.Sprintf("%s:%s:%s", p.tag(), chatID, userID)
}
func (p *Platform) sessionKeyFromCardAction(chatID, userID string, value map[string]any) string {
if value != nil {
if sessionKey, _ := value["session_key"].(string); sessionKey != "" {
return sessionKey
}
}
if p.shareSessionInChannel {
return fmt.Sprintf("%s:%s", p.tag(), chatID)
}
return fmt.Sprintf("%s:%s:%s", p.tag(), chatID, userID)
}
func (p *Platform) shouldReplyInThread(rc replyContext) bool {
if rc.messageID == "" {
return false
}
return p.threadIsolation && isThreadSessionKey(rc.sessionKey)
}
// shouldUseThreadOrReplyAPI is true when we should call Im.Message.Reply (optionally with ReplyInThread).
func (p *Platform) shouldUseThreadOrReplyAPI(rc replyContext) bool {
if rc.messageID == "" {
return false
}
return !p.noReplyToTrigger
}
func (p *Platform) sendNewMessageToChat(ctx context.Context, rc replyContext, msgType, content string) error {
if rc.chatID == "" {
return fmt.Errorf("%s: chatID is empty, cannot send new message", p.tag())
}
return p.createMessage(ctx, rc.chatID, msgType, content, "send")
}
func (p *Platform) buildReplyMessageReqBody(rc replyContext, msgType, content string) *larkim.ReplyMessageReqBody {
body := larkim.NewReplyMessageReqBodyBuilder().
MsgType(msgType).
Content(content)
if p.shouldReplyInThread(rc) {
body.ReplyInThread(true)
}
return body.Build()
}
func (p *Platform) replyMessage(ctx context.Context, rc replyContext, msgType, content string) error {
req := larkim.NewReplyMessageReqBuilder().
MessageId(rc.messageID).
Body(p.buildReplyMessageReqBody(rc, msgType, content)).
Build()
return p.withTransientRetry(ctx, "reply", func() error {
return p.withFreshTenantAccessTokenRetry(ctx, "reply", func(client *lark.Client, options ...larkcore.RequestOptionFunc) error {
resp, err := client.Im.Message.Reply(ctx, req, options...)
if err != nil {
return fmt.Errorf("%s: reply api call: %w", p.tag(), err)
}
if !resp.Success() {
return fmt.Errorf("%s: reply failed code=%d msg=%s", p.tag(), resp.Code, resp.Msg)
}
return nil
})
})
}
func (p *Platform) createMessage(ctx context.Context, chatID, msgType, content, op string) error {
req := larkim.NewCreateMessageReqBuilder().
ReceiveIdType(larkim.ReceiveIdTypeChatId).
Body(larkim.NewCreateMessageReqBodyBuilder().
ReceiveId(chatID).
MsgType(msgType).
Content(content).
Build()).
Build()
return p.withTransientRetry(ctx, op, func() error {
return p.withFreshTenantAccessTokenRetry(ctx, op, func(client *lark.Client, options ...larkcore.RequestOptionFunc) error {
resp, err := client.Im.Message.Create(ctx, req, options...)
if err != nil {
return fmt.Errorf("%s: %s api call: %w", p.tag(), op, err)
}
if !resp.Success() {
return fmt.Errorf("%s: %s failed code=%d msg=%s", p.tag(), op, resp.Code, resp.Msg)
}
return nil
})
})
}
func (p *Platform) withFreshTenantAccessTokenRetry(ctx context.Context, operation string, fn feishuRequestFunc) error {
err := fn(p.client)
if !isTenantAccessTokenInvalid(err) {
return err
}
freshToken, refreshErr := p.fetchFreshTenantAccessToken(ctx)
if refreshErr != nil {
return fmt.Errorf("%s: %s failed after token refresh attempt: %w (original error: %v)", p.tag(), operation, refreshErr, err)
}
slog.Warn(p.tag()+": retrying request with fresh tenant access token", "operation", operation)
return fn(p.replayAPIClient(), larkcore.WithTenantAccessToken(freshToken))
}
func (p *Platform) fetchFreshTenantAccessToken(ctx context.Context) (string, error) {
resp, err := p.replayAPIClient().GetTenantAccessTokenBySelfBuiltApp(ctx, &larkcore.SelfBuiltTenantAccessTokenReq{
AppID: p.appID,
AppSecret: p.appSecret,
})
if err != nil {
return "", fmt.Errorf("%s: fetch tenant access token: %w", p.tag(), err)
}
if !resp.Success() {
return "", fmt.Errorf("%s: fetch tenant access token code=%d msg=%s", p.tag(), resp.Code, resp.Msg)
}
if strings.TrimSpace(resp.TenantAccessToken) == "" {
return "", fmt.Errorf("%s: fetch tenant access token returned empty token", p.tag())
}
return resp.TenantAccessToken, nil
}
func (p *Platform) replayAPIClient() *lark.Client {
p.replayClientMu.Lock()
defer p.replayClientMu.Unlock()
if p.replayClient == nil {
p.replayClient = newFeishuReplayClient(p.appID, p.appSecret, p.domain)
}
return p.replayClient
}
func newFeishuReplayClient(appID, appSecret, domain string) *lark.Client {
var opts []lark.ClientOptionFunc
opts = append(opts, lark.WithEnableTokenCache(false))
if domain != "" && domain != lark.FeishuBaseUrl {
opts = append(opts, lark.WithOpenBaseUrl(domain))
}
return lark.NewClient(appID, appSecret, opts...)
}
func isTenantAccessTokenInvalid(err error) bool {
if err == nil {
return false
}
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "99991663") || strings.Contains(msg, "invalid access token")
}
// Transient retry constants for network-level failures.
const (
maxTransientRetries = 3
transientRetryInitial = 500 * time.Millisecond
transientRetryMaxDelay = 5 * time.Second
)
// isTransientError returns true if the error is a transient network error
// that warrants a retry (connection reset, timeout, EOF, etc.).
func isTransientError(err error) bool {
if err == nil {
return false
}
// Typed syscall checks — more robust than string matching.
if errors.Is(err, syscall.ECONNRESET) || errors.Is(err, syscall.EPIPE) {
return true
}
// net.Error covers timeouts and temporary errors from the stdlib.
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
return true
}
// EOF usually means the server closed the connection mid-response.
if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
return true
}
// Unwrapped string checks for common transient symptoms that may
// appear in wrapped Feishu SDK errors.
msg := err.Error()
for _, substr := range []string{
"connection reset by peer",
"broken pipe",
"i/o timeout",
"TLS handshake timeout",
"server misbehaving",
"connection refused",
} {
if strings.Contains(msg, substr) {
return true
}
}
return false
}
// withTransientRetry wraps an operation with exponential-backoff retry on
// transient network errors. Non-transient errors are returned immediately.
// Jitter (up to +25% of delay) is added to prevent thundering-herd retries.
func (p *Platform) withTransientRetry(ctx context.Context, operation string, fn func() error) error {
var lastErr error
delay := transientRetryInitial
for attempt := 0; attempt <= maxTransientRetries; attempt++ {
lastErr = fn()
if lastErr == nil {
if attempt > 0 {
slog.Info(p.tag()+": transient retry succeeded",
"operation", operation,
"attempt", attempt+1,
)
}
return nil
}
if !isTransientError(lastErr) {
return lastErr
}
if attempt == maxTransientRetries {
break
}
// Add jitter: up to +25% of delay to spread out concurrent retries.
jitter := time.Duration(rand.Int64N(int64(delay / 4)))
actualDelay := delay + jitter
slog.Warn(p.tag()+": transient error, retrying",
"operation", operation,
"attempt", attempt+1,
"max_retries", maxTransientRetries,
"delay", actualDelay,
"error", lastErr,
)
select {
case <-ctx.Done():
return fmt.Errorf("%s: %s retry cancelled: %w (last error: %v)", p.tag(), operation, ctx.Err(), lastErr)
case <-time.After(actualDelay):
}
delay = min(delay*2, transientRetryMaxDelay)
}
return fmt.Errorf("%s failed after %d retries: %w", operation, maxTransientRetries, lastErr)
}
func stringValue(v *string) string {
if v == nil {
return ""
}
return *v
}
func (p *Platform) ReconstructReplyCtx(sessionKey string) (any, error) {
// {platformName}:{chatID}:{userID}
parts := strings.SplitN(sessionKey, ":", 3)
if len(parts) < 2 || parts[0] != p.platformName {
return nil, fmt.Errorf("%s: invalid session key %q", p.tag(), sessionKey)
}
rc := replyContext{chatID: parts[1], sessionKey: sessionKey}
if len(parts) == 3 {
if rootID, ok := parseThreadRootID(parts[2]); ok {
rc.messageID = rootID
}
}
return rc, nil
}
func parseThreadRootID(sessionTail string) (string, bool) {
for _, prefix := range []string{"root:", "thread:"} {
if strings.HasPrefix(sessionTail, prefix) {
rootID := strings.TrimPrefix(sessionTail, prefix)
if rootID != "" {
return rootID, true
}
return "", false
}
}
return "", false
}
func isThreadSessionKey(sessionKey string) bool {
parts := strings.SplitN(sessionKey, ":", 3)
if len(parts) != 3 {
return false
}
_, ok := parseThreadRootID(parts[2])
return ok
}
// feishuPreviewHandle stores the message ID for an editable preview message.
// Card 2.0 path needs mu/status/lastContent to let SetPreviewStatus patch
// the header color without re-rendering the whole card.
type feishuPreviewHandle struct {
mu sync.Mutex
messageID string
chatID string
status core.CardStatus
lastContent string
}
// buildCardJSON builds a Feishu interactive card JSON string with a markdown element.
// Uses schema 2.0 which supports code blocks, tables, and inline formatting.
// Card font is inherently smaller than Post/Text — this is a Feishu platform limitation.
func buildCardJSON(content string) string {
card := map[string]any{
"schema": "2.0",
"config": map[string]any{
"wide_screen_mode": true,
},
"body": map[string]any{
"elements": []map[string]any{
{
"tag": "markdown",
"content": content,
},
},
},
}
b, _ := json.Marshal(card)
return string(b)
}
func isZhLikeProgressLang(lang string) bool {
l := strings.ToLower(strings.TrimSpace(lang))
return strings.HasPrefix(l, "zh")
}
func progressAgentLabel(agent string) string {
agent = strings.TrimSpace(agent)
if agent == "" {
return "Agent"
}
return agent
}
func progressStateMeta(state core.ProgressCardState, lang string, agent string) (title string, template string, footer string) {
zh := isZhLikeProgressLang(lang)
switch state {
case core.ProgressCardStateCompleted:
if zh {
return fmt.Sprintf("%s · 已完成", agent), "green", "本过程卡片已停止更新,完整答复见下一条消息。"
}
return fmt.Sprintf("%s · Completed", agent), "green", "This progress card is no longer updating. Full response is in the next message."
case core.ProgressCardStateFailed:
if zh {
return fmt.Sprintf("%s · 失败", agent), "red", "本过程卡片已停止更新(失败),完整错误说明见下一条消息。"
}
return fmt.Sprintf("%s · Failed", agent), "red", "This progress card has stopped (failed). See the next message for details."
default:
if zh {
return fmt.Sprintf("%s · 进行中", agent), "blue", ""
}
return fmt.Sprintf("%s · Running", agent), "blue", ""
}
}
func progressKindLabel(kind core.ProgressCardEntryKind, lang string) string {
zh := isZhLikeProgressLang(lang)
switch kind {
case core.ProgressEntryThinking:
if zh {
return "思考"
}
return "Thinking"
case core.ProgressEntryToolUse:
if zh {
return "工具调用"
}
return "Tool"
case core.ProgressEntryToolResult:
if zh {
return "工具结果"
}
return "Result"
case core.ProgressEntryError:
if zh {
return "错误"
}
return "Error"
default:
if zh {
return "更新"
}
return "Update"
}
}
func normalizeProgressItems(payload *core.ProgressCardPayload) []core.ProgressCardEntry {
if payload == nil {
return nil
}
if len(payload.Items) > 0 {
return payload.Items
}
out := make([]core.ProgressCardEntry, 0, len(payload.Entries))
for _, entry := range payload.Entries {
entry = strings.TrimSpace(entry)
if entry == "" {
continue
}
kind := core.ProgressEntryInfo
switch {
case strings.HasPrefix(entry, "💭"):
kind = core.ProgressEntryThinking
case strings.HasPrefix(entry, "🔧"), strings.Contains(entry, "**Tool #"):
kind = core.ProgressEntryToolUse
case strings.HasPrefix(entry, "🧾"):
kind = core.ProgressEntryToolResult
case strings.HasPrefix(entry, "❌"):
kind = core.ProgressEntryError
}
out = append(out, core.ProgressCardEntry{Kind: kind, Text: entry})
}
return out
}
func inlineCodeText(s string) string {
return strings.ReplaceAll(strings.TrimSpace(s), "`", "'")
}
func isBashToolName(toolName string) bool {
switch strings.ToLower(strings.TrimSpace(toolName)) {
case "bash", "shell", "run_shell_command":
return true
default:
return false
}
}
func isTodoWriteToolName(toolName string) bool {
return strings.EqualFold(strings.TrimSpace(toolName), "todowrite")
}
// todoItem represents a single todo item from TodoWrite tool input.
type todoItem struct {
ActiveForm string `json:"activeForm"`
Content string `json:"content"`
Status string `json:"status"`
}
// todoWriteInput represents the TodoWrite tool input structure.
type todoWriteInput struct {
Todos []todoItem `json:"todos"`
}
// formatTodoWriteInput formats TodoWrite JSON input into a readable markdown list.
// Returns empty string if parsing fails or input is invalid.
func formatTodoWriteInput(text string, lang string) string {
var input todoWriteInput
if err := json.Unmarshal([]byte(text), &input); err != nil {
return "" // Fall back to default formatting
}
if len(input.Todos) == 0 {
return ""
}
var sb strings.Builder
for _, todo := range input.Todos {
var icon string
switch strings.ToLower(strings.TrimSpace(todo.Status)) {
case "completed":
icon = "✅"
case "in_progress":
icon = "🔄"
case "pending":
icon = "⏳"
default:
icon = "•"
}
content := strings.TrimSpace(todo.Content)
if content == "" {
continue
}
// Escape markdown special characters
content = strings.ReplaceAll(content, "`", "'")
sb.WriteString(icon)
sb.WriteString(" ")
sb.WriteString(content)
activeForm := strings.TrimSpace(todo.ActiveForm)
if activeForm != "" && activeForm != content {
sb.WriteString(" _(")
sb.WriteString(strings.ReplaceAll(activeForm, "`", "'"))
sb.WriteString(")_")
}
sb.WriteString("\n")
}
return strings.TrimSuffix(sb.String(), "\n")
}
func formatProgressToolInput(toolName, text string) string {
text = strings.TrimSpace(text)
if text == "" {
return ""
}
// Special handling for TodoWrite tool - format JSON as readable list
if isTodoWriteToolName(toolName) {
if formatted := formatTodoWriteInput(text, ""); formatted != "" {
return formatted
}
// JSON parsing failed or empty todos - show raw input as text block
return fmt.Sprintf("```text\n%s\n```", text)
}
text = preprocessFeishuMarkdown(sanitizeMarkdownURLs(text))
if strings.Contains(text, "```") {
return text
}
if isBashToolName(toolName) {
return fmt.Sprintf("```bash\n%s\n```", text)
}
if strings.Contains(text, "\n") || len(text) > 180 {
return fmt.Sprintf("```text\n%s\n```", text)
}
return fmt.Sprintf("`%s`", inlineCodeText(text))
}
func formatProgressToolResult(text string) string {
text = strings.TrimSpace(text)
if text == "" {
return ""
}
text = preprocessFeishuMarkdown(sanitizeMarkdownURLs(text))
if strings.Contains(text, "```") {
return text
}
if strings.Contains(text, "\n") || len(text) > 220 {
return fmt.Sprintf("```\n%s\n```", text)
}
return text
}
func progressNoOutputText(lang string) string {
if isZhLikeProgressLang(lang) {
return "无输出"
}
return "No output"
}
func progressResultDot(item core.ProgressCardEntry) string {
if item.Success != nil {
if *item.Success {
return "🟢"
}
return "🔴"
}
if item.ExitCode != nil {
if *item.ExitCode == 0 {
return "🟢"
}
return "🔴"
}
if strings.EqualFold(strings.TrimSpace(item.Status), "completed") || strings.EqualFold(strings.TrimSpace(item.Status), "success") || strings.EqualFold(strings.TrimSpace(item.Status), "succeeded") || strings.EqualFold(strings.TrimSpace(item.Status), "ok") {
return "🟢"
}
if strings.EqualFold(strings.TrimSpace(item.Status), "failed") || strings.EqualFold(strings.TrimSpace(item.Status), "error") {
return "🔴"
}
return "⚪"
}
func renderProgressEntryElement(item core.ProgressCardEntry, lang string) map[string]any {
text := strings.TrimSpace(item.Text)
if text == "" {
text = " "
}
switch item.Kind {
case core.ProgressEntryThinking:
return map[string]any{
"tag": "div",
"text": map[string]any{
"tag": "plain_text",
"content": "💭 " + inlineCodeText(text),
"text_size": "notation",
"text_color": "grey",
},
}
case core.ProgressEntryToolUse:
toolName := strings.TrimSpace(item.Tool)
if toolName == "" {
toolName = "Tool"
}
content := fmt.Sprintf("%s `%s`", progressKindLabel(item.Kind, lang), inlineCodeText(toolName))
if body := formatProgressToolInput(toolName, text); body != "" {
content += "\n" + body
}
return map[string]any{
"tag": "markdown",
"content": content,
}
case core.ProgressEntryToolResult:
toolName := strings.TrimSpace(item.Tool)
content := fmt.Sprintf("%s", progressKindLabel(item.Kind, lang))
if toolName != "" {
content += " `" + inlineCodeText(toolName) + "`"
}
dot := progressResultDot(item)
meta := dot
if item.ExitCode != nil {
meta += fmt.Sprintf(" exit code: `%d`", *item.ExitCode)
}
content += "\n" + meta
if body := formatProgressToolResult(item.Text); body != "" {
content += "\n" + body
} else {
content += "\n_" + progressNoOutputText(lang) + "_"
}
return map[string]any{
"tag": "markdown",
"content": content,
}
case core.ProgressEntryError:
content := fmt.Sprintf("%s\n%s", progressKindLabel(item.Kind, lang), preprocessFeishuMarkdown(sanitizeMarkdownURLs(text)))
return map[string]any{
"tag": "markdown",
"content": content,
}
default:
return map[string]any{
"tag": "markdown",
"content": preprocessFeishuMarkdown(sanitizeMarkdownURLs(text)),
}
}
}
func buildProgressCardJSONFromPayload(payload *core.ProgressCardPayload) string {
items := normalizeProgressItems(payload)
if len(items) == 0 {
return buildCardJSON(" ")
}
agent := progressAgentLabel(payload.Agent)
title, template, footer := progressStateMeta(payload.State, payload.Lang, agent)
elements := make([]map[string]any, 0, len(items)+3)
if payload.Truncated {
truncatedText := "Showing latest updates only."
if isZhLikeProgressLang(payload.Lang) {
truncatedText = "仅显示最近更新。"
}
elements = append(elements, map[string]any{
"tag": "div",
"text": map[string]any{
"tag": "plain_text",
"content": truncatedText,
"text_size": "notation",
"text_color": "grey",
},
})
elements = append(elements, map[string]any{"tag": "hr"})
}
for i, item := range items {
elements = append(elements, renderProgressEntryElement(item, payload.Lang))
if i < len(items)-1 {
elements = append(elements, map[string]any{"tag": "hr"})
}
}
if footer != "" {
elements = append(elements, map[string]any{"tag": "hr"})
elements = append(elements, map[string]any{
"tag": "div",
"text": map[string]any{
"tag": "plain_text",
"content": footer,
"text_size": "notation",
"text_color": "grey",
},
})
}
card := map[string]any{
"schema": "2.0",
"config": map[string]any{
"wide_screen_mode": true,
},
"header": map[string]any{
"title": map[string]any{
"tag": "plain_text",
"content": title,
},
"template": template,
},
"body": map[string]any{
"elements": elements,
},
}
b, _ := json.Marshal(card)
return string(b)
}
func buildPreviewCardJSON(content string) string {
if payload, ok := core.ParseProgressCardPayload(content); ok {
return buildProgressCardJSONFromPayload(payload)
}
return buildCardJSON(sanitizeMarkdownURLs(content))
}
// SendPreviewStart sends a new card message and returns a handle for subsequent edits.
// Using card (interactive) type for both preview and final message so updates
// are in-place without needing to delete and resend.
func (p *Platform) SendPreviewStart(ctx context.Context, rctx any, content string) (any, error) {
if !p.useInteractiveCard {
return nil, core.ErrNotSupported
}
rc, ok := rctx.(replyContext)
if !ok {
return nil, fmt.Errorf("%s: invalid reply context type %T", p.tag(), rctx)
}
chatID := rc.chatID
if chatID == "" {
return nil, fmt.Errorf("%s: chatID is empty", p.tag())
}
// Card 2.0 path: engine passes a pre-built rich card JSON; pass it through.
var cardJSON string
if isCardJSON(content) {
cardJSON = content
} else {
cardJSON = buildPreviewCardJSON(content)
}
var msgID string
if p.shouldUseThreadOrReplyAPI(rc) {
req := larkim.NewReplyMessageReqBuilder().
MessageId(rc.messageID).
Body(p.buildReplyMessageReqBody(rc, larkim.MsgTypeInteractive, cardJSON)).
Build()
var resp *larkim.ReplyMessageResp
if err := p.withTransientRetry(ctx, "send preview", func() error {
return p.withFreshTenantAccessTokenRetry(ctx, "send preview", func(client *lark.Client, options ...larkcore.RequestOptionFunc) error {
var err error
resp, err = client.Im.Message.Reply(ctx, req, options...)
if err != nil {
return fmt.Errorf("%s: send preview (reply): %w", p.tag(), err)
}
if !resp.Success() {
return fmt.Errorf("%s: send preview (reply) code=%d msg=%s", p.tag(), resp.Code, resp.Msg)
}
return nil
})
}); err != nil {
return nil, err
}
if resp.Data != nil && resp.Data.MessageId != nil {
msgID = *resp.Data.MessageId
}
} else {
req := larkim.NewCreateMessageReqBuilder().
ReceiveIdType(larkim.ReceiveIdTypeChatId).
Body(larkim.NewCreateMessageReqBodyBuilder().
ReceiveId(chatID).
MsgType(larkim.MsgTypeInteractive).
Content(cardJSON).
Build()).
Build()
var resp *larkim.CreateMessageResp
if err := p.withTransientRetry(ctx, "send preview", func() error {
return p.withFreshTenantAccessTokenRetry(ctx, "send preview", func(client *lark.Client, options ...larkcore.RequestOptionFunc) error {
var err error
resp, err = client.Im.Message.Create(ctx, req, options...)
if err != nil {
return fmt.Errorf("%s: send preview: %w", p.tag(), err)
}
if !resp.Success() {
return fmt.Errorf("%s: send preview code=%d msg=%s", p.tag(), resp.Code, resp.Msg)
}
return nil
})
}); err != nil {
return nil, err
}
if resp.Data != nil && resp.Data.MessageId != nil {
msgID = *resp.Data.MessageId
}
}
if msgID == "" {
return nil, fmt.Errorf("%s: send preview: no message ID returned", p.tag())
}
return &feishuPreviewHandle{messageID: msgID, chatID: chatID}, nil
}
// UpdateMessage edits an existing card message identified by previewHandle.
// Uses the Patch API (HTTP PATCH) which is required for interactive card messages.
func (p *Platform) UpdateMessage(ctx context.Context, previewHandle any, content string) error {
if !p.useInteractiveCard {
return core.ErrNotSupported
}
h, ok := previewHandle.(*feishuPreviewHandle)
if !ok {
return fmt.Errorf("%s: invalid preview handle type %T", p.tag(), previewHandle)
}
cardJSON := ""
if isCardJSON(content) {
// Card 2.0: engine passes full card JSON directly, skip all processing.
cardJSON = content
h.mu.Lock()
h.lastContent = content
h.mu.Unlock()
} else if payload, ok := core.ParseProgressCardPayload(content); ok {
cardJSON = buildProgressCardJSONFromPayload(payload)
} else {
processed := content
if containsMarkdown(content) {
processed = preprocessFeishuMarkdown(content)
}
cardJSON = buildCardJSON(sanitizeMarkdownURLs(processed))
}
req := larkim.NewPatchMessageReqBuilder().
MessageId(h.messageID).
Body(larkim.NewPatchMessageReqBodyBuilder().
Content(cardJSON).
Build()).
Build()
return p.withTransientRetry(ctx, "patch message", func() error {
return p.withFreshTenantAccessTokenRetry(ctx, "patch message", func(client *lark.Client, options ...larkcore.RequestOptionFunc) error {
resp, err := client.Im.Message.Patch(ctx, req, options...)
if err != nil {
return fmt.Errorf("%s: patch message: %w", p.tag(), err)
}
if !resp.Success() {
return fmt.Errorf("%s: patch message code=%d msg=%s", p.tag(), resp.Code, resp.Msg)
}
return nil
})
})
}
func (p *Platform) Stop() error {
if p.isWSPrimary {
remaining := unregisterSharedWS(p)
if remaining > 0 {
slog.Warn(p.tag()+": primary shutting down, secondary platforms will lose event source",
"remaining", remaining)
}
if cancel := p.getCancel(); cancel != nil {
cancel()
}
} else {
unregisterSharedWS(p)
}
// Stop webhook server if running (Lark international version)
if svr := p.getServer(); svr != nil {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := svr.Shutdown(ctx); err != nil {
slog.Error(p.tag()+": webhook server shutdown error", "error", err)
}
}
return nil
}
// DeletePreviewMessage removes a preview message so the caller can send a
// separate final message without leaving a stale interactive card behind.
func (p *Platform) DeletePreviewMessage(ctx context.Context, previewHandle any) error {
if !p.useInteractiveCard {
return core.ErrNotSupported
}
h, ok := previewHandle.(*feishuPreviewHandle)
if !ok {
return fmt.Errorf("%s: invalid preview handle type %T", p.tag(), previewHandle)
}
req := larkim.NewDeleteMessageReqBuilder().
MessageId(h.messageID).
Build()
return p.withTransientRetry(ctx, "delete preview message", func() error {
return p.withFreshTenantAccessTokenRetry(ctx, "delete preview message", func(client *lark.Client, options ...larkcore.RequestOptionFunc) error {
resp, err := client.Im.Message.Delete(ctx, req, options...)
if err != nil {
return fmt.Errorf("%s: delete preview message: %w", p.tag(), err)
}
if !resp.Success() {
return fmt.Errorf("%s: delete preview message code=%d msg=%s", p.tag(), resp.Code, resp.Msg)
}
return nil
})
})
}
// SendAudio uploads audio bytes to Feishu and sends a voice message.
// Implements core.AudioSender interface.
// Feishu audio messages require opus format; non-opus input is converted via ffmpeg.
func (p *Platform) SendAudio(ctx context.Context, rctx any, audio []byte, format string) error {
rc, ok := rctx.(replyContext)
if !ok {
return fmt.Errorf("%s: SendAudio: invalid reply context type %T", p.tag(), rctx)
}
if format != "opus" {
converted, err := core.ConvertAudioToOpus(ctx, audio, format)
if err != nil {
return fmt.Errorf("%s: convert to opus: %w", p.tag(), err)
}
audio = converted
format = "opus"
}
var uploadResp *larkim.CreateFileResp
if err := p.withTransientRetry(ctx, "upload audio", func() error {
return p.withFreshTenantAccessTokenRetry(ctx, "upload audio", func(client *lark.Client, options ...larkcore.RequestOptionFunc) error {
req := larkim.NewCreateFileReqBuilder().
Body(larkim.NewCreateFileReqBodyBuilder().
FileType(larkim.FileTypeOpus).
FileName("tts_audio.opus").
File(bytes.NewReader(audio)).
Build()).
Build()
var err error
uploadResp, err = client.Im.File.Create(ctx, req, options...)
if err != nil {
return fmt.Errorf("%s: upload audio: %w", p.tag(), err)
}
if !uploadResp.Success() {
return fmt.Errorf("%s: upload audio code=%d msg=%s", p.tag(), uploadResp.Code, uploadResp.Msg)
}
return nil
})
}); err != nil {
return err
}
if uploadResp.Data == nil || uploadResp.Data.FileKey == nil {
return fmt.Errorf("%s: upload audio: no file_key returned", p.tag())
}
fileKey := *uploadResp.Data.FileKey
slog.Debug(p.tag()+": audio uploaded", "file_key", fileKey, "format", format, "size", len(audio))
audioMsg := larkim.MessageAudio{FileKey: fileKey}
audioContent, err := audioMsg.String()
if err != nil {
return fmt.Errorf("%s: build audio message: %w", p.tag(), err)
}
return p.sendMediaMessage(ctx, rc, larkim.MsgTypeAudio, audioContent)
}
type postElement struct {
Tag string `json:"tag"`
Text string `json:"text,omitempty"`
Language string `json:"language,omitempty"`
ImageKey string `json:"image_key,omitempty"`
Href string `json:"href,omitempty"`
UserId string `json:"user_id,omitempty"`
UserName string `json:"user_name,omitempty"`
}
type postLang struct {
Title string `json:"title"`
Content [][]postElement `json:"content"`
}
// parsePostContent handles both formats of feishu post content:
// 1. {"title":"...", "content":[[...]]} (receive event)
// 2. {"zh_cn":{"title":"...", "content":[[...]]}} (some SDK versions)
func (p *Platform) parsePostContent(messageID, raw string) ([]string, []core.ImageAttachment) {
// try flat format first
var flat postLang
if err := json.Unmarshal([]byte(raw), &flat); err == nil && flat.Content != nil {
return p.extractPostParts(messageID, &flat)
}
// try language-keyed format
var langMap map[string]postLang
if err := json.Unmarshal([]byte(raw), &langMap); err == nil {
for _, lang := range langMap {
return p.extractPostParts(messageID, &lang)
}
}
slog.Error(p.tag()+": failed to parse post content", "raw", raw)
return nil, nil
}
func (p *Platform) extractPostParts(messageID string, post *postLang) ([]string, []core.ImageAttachment) {
var textParts []string
var images []core.ImageAttachment
if post.Title != "" {
textParts = append(textParts, post.Title)
}
for _, line := range post.Content {
for _, elem := range line {
switch elem.Tag {
case "text":
if elem.Text != "" {
textParts = append(textParts, elem.Text)
}
case "a":
if elem.Text != "" && elem.Href != "" {
textParts = append(textParts, fmt.Sprintf("[%s](%s)", elem.Text, elem.Href))
} else if elem.Text != "" {
textParts = append(textParts, elem.Text)
}
case "code_block":
if elem.Text != "" {
lang := elem.Language
textParts = append(textParts, "```"+lang+"\n"+elem.Text+"\n```")
}
case "markdown":
if elem.Text != "" {
textParts = append(textParts, elem.Text)
}
case "at":
if p.getBotOpenID() != "" && elem.UserId == p.getBotOpenID() {
continue
}
switch {
case elem.UserId == "all":
textParts = append(textParts, "@all")
case elem.UserName != "":
textParts = append(textParts, "@"+elem.UserName)
case elem.UserId != "":
textParts = append(textParts, "@"+p.resolveUserName(elem.UserId))
}
case "img":
if elem.ImageKey != "" {
imgData, mimeType, err := p.downloadImage(messageID, elem.ImageKey)
if err != nil {
slog.Error(p.tag()+": download post image failed", "error", err, "key", elem.ImageKey)
continue
}
images = append(images, core.ImageAttachment{MimeType: mimeType, Data: imgData})
}
}
}
}
return textParts, images
}
// onBotMenu handles bot custom menu click events. When a menu item's
// event_key starts with "/", it is dispatched as a slash command.
// This allows users to configure menu items in the Feishu developer
// console with event_key set to commands like "/help", "/status", etc.
func (p *Platform) onBotMenu(event *larkapplication.P2BotMenuV6) error {
if event == nil || event.Event == nil || event.Event.EventKey == nil {
return nil
}
eventKey := *event.Event.EventKey
userID := ""
if event.Event.Operator != nil && event.Event.Operator.OperatorId != nil && event.Event.Operator.OperatorId.OpenId != nil {
userID = *event.Event.Operator.OperatorId.OpenId
}
if userID == "" {
slog.Debug(p.tag()+": bot menu event without user id", "event_key", eventKey)
return nil
}
if !core.AllowList(p.allowFrom, userID) {
slog.Debug(p.tag()+": menu event from unauthorized user", "user", userID, "event_key", eventKey)
return nil
}
if p.groupOnly {
slog.Debug(p.tag()+": bot menu skipped (group_only=true)", "user", userID)
return nil
}
slog.Info(p.tag()+": bot menu clicked", "event_key", eventKey, "user", userID)
content := eventKey
if !strings.HasPrefix(content, "/") {
content = "/" + content
}
userName := p.resolveUserName(userID)
sessionKey := p.platformName + ":" + userID + ":" + userID
p.getHandler()(p.dispatchPlatform(), &core.Message{
SessionKey: sessionKey,
Platform: p.platformName,
Content: content,
UserID: userID,
UserName: userName,
ReplyCtx: replyContext{chatID: userID, sessionKey: sessionKey},
})
return nil
}
// ═══════════════════════════════════════════════════════════════
// Card 2.0 rich card support (based on upstream PR #309 + #306,
// extended with "agent reply elapsed time" in the footer).
// ═══════════════════════════════════════════════════════════════
const defaultToolIcon = "setting-inter_outlined"
var toolIconMap = map[string]string{
"Bash": "terminal-two_outlined",
"Edit": "edit_outlined",
"Read": "file-open_outlined",
"Write": "notes_outlined",
"Glob": "folder-open_outlined",
"Grep": "search_outlined",
"WebFetch": "internet_outlined",
"WebSearch": "internet_outlined",
"Agent": "robot_outlined",
"Skill": "code_outlined",
"LSP": "code_outlined",
}
var thinkingVerbs = []string{
"Churning", "Clauding", "Coalescing", "Cogitating", "Computing",
"Combobulating", "Concocting", "Conjuring", "Considering", "Contemplating",
"Cooking", "Crafting", "Creating", "Crunching", "Deciphering",
"Deliberating", "Divining", "Effecting", "Elucidating", "Enchanting",
"Envisioning", "Finagling", "Forging", "Generating", "Germinating",
"Hatching", "Ideating", "Imagining", "Incubating", "Inferring",
"Manifesting", "Marinating", "Meandering", "Mulling", "Musing",
"Noodling", "Percolating", "Perusing", "Pondering", "Processing",
"Puzzling", "Reticulating", "Ruminating", "Scheming", "Simmering",
"Spelunking", "Spinning", "Stewing", "Sussing", "Synthesizing",
"Thinking", "Tinkering", "Transmuting", "Unfurling", "Unravelling",
"Vibing", "Wandering", "Whirring", "Wizarding", "Working", "Wrangling",
}
func pickThinkingVerb() string {
idx := time.Now().Unix() % int64(len(thinkingVerbs))
return thinkingVerbs[idx] + "..."
}
var markdownTablePattern = regexp.MustCompile(`(?m)^\|.+\|\s*\n\|[\s:|-]+\|\s*\n(?:\|.+\|\s*\n?)+`)
func getToolIcon(toolName string) string {
if icon, ok := toolIconMap[toolName]; ok {
return icon
}
return defaultToolIcon
}
func richStepDisplayName(step core.ToolStep) string {
if step.Kind == core.ToolStepKindThinking {
return "Thinking"
}
name := strings.TrimSpace(step.Name)
if name == "" {
return "Tool"
}
return name
}
func richStepBody(step core.ToolStep) string {
name := richStepDisplayName(step)
summary := strings.TrimSpace(step.Summary)
if summary == "" {
summary = name
}
if step.Kind == core.ToolStepKindThinking {
return summary
}
lines := []string{summary}
var statusParts []string
status := strings.TrimSpace(step.Status)
if status != "" {
statusParts = append(statusParts, "status: "+status)
} else if step.Success != nil {
if *step.Success {
statusParts = append(statusParts, "status: ok")
} else {
statusParts = append(statusParts, "status: failed")
}
}
if step.ExitCode != nil {
statusParts = append(statusParts, fmt.Sprintf("exit: %d", *step.ExitCode))
}
if len(statusParts) > 0 {
lines = append(lines, strings.Join(statusParts, " | "))
}
if result := strings.TrimSpace(step.Result); result != "" {
lines = append(lines, result)
}
return strings.Join(lines, "\n")
}
// isCardJSON returns true if content looks like a complete Feishu card JSON
// (has "schema" and "body"). Used to avoid double-wrapping rich card output.
func isCardJSON(content string) bool {
if len(content) < 10 || content[0] != '{' {
return false
}
return strings.Contains(content, `"schema"`) && strings.Contains(content, `"body"`)
}
// buildCardJSONWithStatus builds a Feishu card JSON with a colored header
// reflecting the given status. Used as a fallback when rich-card assembly fails.
func buildCardJSONWithStatus(content string, status core.CardStatus) string {
template := "grey"
switch status {
case core.CardStatusWorking, core.CardStatusThinking:
template = "blue"
case core.CardStatusDone:
template = "green"
case core.CardStatusError:
template = "red"
}
card := map[string]any{
"schema": "2.0",
"config": map[string]any{
"wide_screen_mode": true,
},
"header": map[string]any{
"template": template,
"title": map[string]any{"tag": "plain_text", "content": ""},
},
"body": map[string]any{
"elements": []map[string]any{
{
"tag": "markdown",
"content": content,
},
},
},
}
b, _ := json.Marshal(card)
return string(b)
}
// formatElapsedCN renders a human-readable duration in Chinese.
// Examples: "3.2 秒", "1 分 23 秒", "1 小时 05 分"。
func formatElapsedCN(d time.Duration) string {
if d < 0 {
d = 0
}
totalSec := int64(d / time.Second)
switch {
case d < time.Minute:
return fmt.Sprintf("%.1f 秒", d.Seconds())
case d < time.Hour:
m := totalSec / 60
s := totalSec % 60
return fmt.Sprintf("%d 分 %02d 秒", m, s)
default:
h := totalSec / 3600
m := (totalSec % 3600) / 60
return fmt.Sprintf("%d 小时 %02d 分", h, m)
}
}
// buildRichCard renders a Card 2.0 "single-card" turn with collapsible
// tool-step panel, streaming markdown body, status-colored header, and
// an elapsed-time footer.
func buildRichCard(status core.CardStatus, _ string, steps []core.ToolStep, markdown string, streaming bool, elapsed time.Duration) string {
panelTitle := "Thinking..."
if len(steps) > 0 {
if streaming {
toolCount := 0
for _, step := range steps {
if step.Kind != core.ToolStepKindThinking {
toolCount++
}
}
if toolCount > 0 {
panelTitle = fmt.Sprintf("Working on it (%d steps)", len(steps))
}
} else {
toolCounts := make(map[string]int)
var toolOrder []string
for _, s := range steps {
name := richStepDisplayName(s)
if toolCounts[name] == 0 {
toolOrder = append(toolOrder, name)
}
toolCounts[name]++
}
var toolParts []string
for _, name := range toolOrder {
if toolCounts[name] > 1 {
toolParts = append(toolParts, fmt.Sprintf("%s×%d", name, toolCounts[name]))
} else {
toolParts = append(toolParts, name)
}
}
toolSummary := strings.Join(toolParts, ", ")
preview := strings.TrimSpace(markdown)
if idx := strings.IndexByte(preview, '\n'); idx > 0 {
preview = preview[:idx]
}
if runes := []rune(preview); len(runes) > 20 {
preview = string(runes[:20]) + "..."
}
if preview != "" {
panelTitle = fmt.Sprintf("%s · %s", toolSummary, preview)
} else {
panelTitle = toolSummary
}
}
}
panelCap := len(steps)
if panelCap < 1 {
panelCap = 1
}
panelElements := make([]map[string]any, 0, panelCap)
if len(steps) == 0 {
panelElements = append(panelElements, map[string]any{
"tag": "div",
"text": map[string]any{"tag": "plain_text", "content": "Thinking..."},
})
} else {
// Cap the number of step rows so the collapsible panel doesn't
// balloon into hundreds of elements (lark client renders that
// poorly and the whole card can hit the ~30KB API limit).
const maxPanelSteps = 30
visible := steps
overflow := 0
if len(steps) > maxPanelSteps {
visible = steps[:maxPanelSteps]
overflow = len(steps) - maxPanelSteps
}
for _, step := range visible {
summary := richStepBody(step)
panelElements = append(panelElements, map[string]any{
"tag": "div",
"icon": map[string]any{"tag": "standard_icon", "token": getToolIcon(step.Name)},
"text": map[string]any{"tag": "plain_text", "content": summary},
})
}
if overflow > 0 {
panelElements = append(panelElements, map[string]any{
"tag": "div",
"text": map[string]any{"tag": "plain_text", "content": fmt.Sprintf("… and %d more steps", overflow)},
})
}
}
panelMap := map[string]any{
"tag": "collapsible_panel",
"expanded": streaming,
"background_color": "grey",
"header": map[string]any{
"title": map[string]any{"tag": "plain_text", "content": panelTitle},
},
"border": map[string]any{"color": "grey"},
"vertical_spacing": "8px",
"padding": "4px 8px",
"elements": panelElements,
}
markdownMap := map[string]any{
"tag": "markdown",
"content": preprocessFeishuMarkdown(markdown),
}
// Footer shows elapsed time: "⏱ 运行中 12.3 秒..." during streaming,
// "⏱ 用时 1 分 23 秒" on completion. Skip when elapsed == 0 to avoid noise.
var footerMap map[string]any
if elapsed > 0 {
var footerText string
if streaming {
footerText = fmt.Sprintf("⏱ 运行中 %s...", formatElapsedCN(elapsed))
} else {
footerText = fmt.Sprintf("⏱ 用时 %s", formatElapsedCN(elapsed))
}
footerMap = map[string]any{
"tag": "div",
"text": map[string]any{
"tag": "plain_text",
"content": footerText,
},
}
}
var elements []map[string]any
if len(steps) > 0 || streaming {
elements = append(elements, panelMap, markdownMap)
} else {
elements = append(elements, markdownMap)
}
if footerMap != nil {
elements = append(elements, footerMap)
}
// Header template color follows status.
headerTemplate := "blue"
headerTitle := pickThinkingVerb()
switch status {
case core.CardStatusDone:
headerTemplate = "green"
headerTitle = "Done"
case core.CardStatusError:
headerTemplate = "red"
headerTitle = "Error"
case core.CardStatusThinking, core.CardStatusWorking:
headerTemplate = "blue"
headerTitle = pickThinkingVerb()
}
card := map[string]any{
"schema": "2.0",
"config": map[string]any{
"streaming_mode": streaming,
"update_multi": true,
"enable_forward_interaction": true,
},
"header": map[string]any{
"template": headerTemplate,
"title": map[string]any{"tag": "plain_text", "content": headerTitle},
},
"body": map[string]any{"elements": elements},
}
b, err := json.Marshal(card)
if err != nil {
slog.Debug("feishu: build rich card marshal failed, fallback to basic card", "error", err)
return buildCardJSONWithStatus(preprocessFeishuMarkdown(markdown), status)
}
// Feishu interactive card payload limit is ~30KB; over that the API
// rejects the whole card and the lark client may render it as a
// mangled JSON dump. Drop the panel and keep just the markdown body.
const maxCardJSONBytes = 28000
if len(b) > maxCardJSONBytes {
slog.Debug("feishu: rich card exceeds size limit, fallback to basic card", "size", len(b))
return buildCardJSONWithStatus(preprocessFeishuMarkdown(markdown), status)
}
return string(b)
}
func splitMarkdownByTables(md string, maxTables int) []string {
if maxTables <= 0 {
return []string{md}
}
matches := markdownTablePattern.FindAllStringIndex(md, -1)
if len(matches) <= maxTables {
return []string{md}
}
parts := make([]string, 0, len(matches)-maxTables+1)
firstEnd := len(md)
if len(matches) > maxTables {
firstEnd = matches[maxTables][0]
}
first := strings.TrimSpace(md[:firstEnd])
if first != "" {
parts = append(parts, first)
}
for _, match := range matches[maxTables:] {
block := strings.TrimSpace(md[match[0]:match[1]])
if block != "" {
parts = append(parts, block)
}
}
return parts
}
// BuildRichCard implements core.RichCardSupporter. Feishu engine passes an
// elapsed duration via the preview handle; buildRichCard itself is the
// renderer and must be called with the duration from engine state.
func (p *Platform) BuildRichCard(status core.CardStatus, title string, steps []core.ToolStep, markdown string, streaming bool, elapsed time.Duration) string {
return buildRichCard(status, title, steps, markdown, streaming, elapsed)
}
// SplitMarkdownByTables implements core.MarkdownTableSplitter.
func (p *Platform) SplitMarkdownByTables(md string, maxTables int) []string {
return splitMarkdownByTables(md, maxTables)
}
// SetPreviewStatus updates the card header color to reflect the agent's current state.
func (p *Platform) SetPreviewStatus(previewHandle any, status core.CardStatus) {
h, ok := previewHandle.(*feishuPreviewHandle)
if !ok {
return
}
h.mu.Lock()
h.status = status
lastContent := h.lastContent
h.mu.Unlock()
if lastContent == "" {
return
}
cardJSON := buildCardJSONWithStatus(lastContent, status)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
resp, err := p.client.Im.Message.Patch(ctx, larkim.NewPatchMessageReqBuilder().
MessageId(h.messageID).
Body(larkim.NewPatchMessageReqBodyBuilder().
Content(cardJSON).
Build()).
Build())
if err != nil {
slog.Debug("feishu: set preview status patch failed", "error", err)
return
}
if !resp.Success() {
slog.Debug("feishu: set preview status patch failed", "code", resp.Code, "msg", resp.Msg)
}
}