返回 DeepSeek-Reasonix
feishu.go
根目录 / internal / bot / feishu / feishu.go
1 // Package feishu 实现飞书自建应用 Bot 适配器。
2 // 参考 Hermes Agent 的 feishu adapter:
3 // - 长连接 WebSocket(默认)或 Webhook 模式
4 // - @mention gating
5 // - open_id / user_id / union_id 映射
6 // - 消息去重
7 // - interactive card 审批/问答
8 package feishu
9
10 import (
11 "context"
12 "crypto/sha256"
13 "crypto/subtle"
14 "encoding/hex"
15 "encoding/json"
16 "errors"
17 "fmt"
18 "io"
19 "log/slog"
20 "net/http"
21 "os"
22 "strings"
23 "sync"
24 "time"
25
26 "reasonix/internal/bot"
27 "reasonix/internal/config"
28
29 lark "github.com/larksuite/oapi-sdk-go/v3"
30 larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
31 "github.com/larksuite/oapi-sdk-go/v3/event/dispatcher"
32 "github.com/larksuite/oapi-sdk-go/v3/event/dispatcher/callback"
33 larkcontact "github.com/larksuite/oapi-sdk-go/v3/service/contact/v3"
34 larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
35 larkws "github.com/larksuite/oapi-sdk-go/v3/ws"
36 )
37
38 // textContent 飞书消息文本内容结构。
39 type textContent struct {
40 Text string `json:"text"`
41 }
42
43 const feishuPendingReactionEmoji = "OnIt"
44
45 // feishuEvent 飞书事件结构。
46 type feishuEvent struct {
47 Schema string `json:"schema"`
48 Header feishuHeader `json:"header"`
49 Event json.RawMessage `json:"event"`
50 }
51
52 type feishuHeader struct {
53 EventID string `json:"event_id"`
54 EventType string `json:"event_type"`
55 Token string `json:"token"`
56 CreateTime string `json:"create_time"`
57 }
58
59 type feishuMsgEvent struct {
60 MessageID string `json:"message_id"`
61 RootID string `json:"root_id"`
62 ParentID string `json:"parent_id"`
63 ThreadID string `json:"thread_id"`
64 ChatID string `json:"chat_id"`
65 ChatType string `json:"chat_type"`
66 MsgType string `json:"msg_type"`
67 Content string `json:"content"`
68 Sender feishuSender `json:"sender"`
69 Mentions []feishuMention `json:"mentions"`
70 }
71
72 type feishuSender struct {
73 SenderID struct {
74 UserID string `json:"user_id"`
75 OpenID string `json:"open_id"`
76 UnionID string `json:"union_id"`
77 } `json:"sender_id"`
78 }
79
80 type feishuMention struct {
81 Key string `json:"key"`
82 Name string `json:"name"`
83 ID struct {
84 OpenID string `json:"open_id"`
85 } `json:"id"`
86 }
87
88 func webhookMentionRefs(mentions []feishuMention) []mentionRef {
89 refs := make([]mentionRef, 0, len(mentions))
90 for _, m := range mentions {
91 refs = append(refs, mentionRef{Key: m.Key, OpenID: m.ID.OpenID, Name: m.Name})
92 }
93 return refs
94 }
95
96 // adapter 飞书适配器实现。
97 type adapter struct {
98 cfg config.FeishuBotConfig
99 logger *slog.Logger
100 msgCh chan bot.InboundMessage
101 cancel context.CancelFunc
102 client *lark.Client
103 wsClient *larkws.Client
104
105 // fetchResource 覆盖消息资源下载(测试注入);nil 时用 sdkFetchResource。
106 fetchResource func(ctx context.Context, messageID, key, typ string) ([]byte, string, error)
107
108 clientMu sync.Mutex // 保护 client 懒初始化
109
110 seenMu sync.Mutex
111 seen map[string]bool // 消息去重
112
113 botMu sync.Mutex
114 botID string // bot 自身 open_id,用于群聊 @ 门控与占位符剔除
115
116 nameMu sync.Mutex
117 names map[string]nameCacheEntry // open_id -> 显示名缓存
118 }
119
120 type nameCacheEntry struct {
121 name string
122 expires time.Time
123 }
124
125 const (
126 userNameCacheTTL = time.Hour
127 userNameFallbackCacheTTL = 5 * time.Minute
128 )
129
130 // New 创建飞书 Bot 适配器。
131 func New(cfg config.FeishuBotConfig, logger *slog.Logger) bot.Adapter {
132 return &adapter{
133 cfg: cfg,
134 logger: logger.With("platform", "feishu"),
135 seen: make(map[string]bool),
136 }
137 }
138
139 func (a *adapter) Platform() bot.Platform { return bot.PlatformFeishu }
140 func (a *adapter) Name() string { return "feishu" }
141
142 func (a *adapter) Start(ctx context.Context) error {
143 a.msgCh = make(chan bot.InboundMessage, 64)
144 ctx, a.cancel = context.WithCancel(ctx)
145
146 mode := a.cfg.Mode
147 if mode == "" {
148 mode = "webhook"
149 }
150
151 switch mode {
152 case "webhook":
153 // Webhook mode exposes a public HTTP endpoint; without a verification
154 // token verificationTokenValid accepts every caller, so fail closed
155 // rather than let anyone drive the agent.
156 if strings.TrimSpace(a.cfg.VerificationToken) == "" {
157 return fmt.Errorf("feishu: webhook mode needs verification_token set — refusing to expose an unauthenticated event endpoint")
158 }
159 go a.runWebhook(ctx)
160 default:
161 if _, err := a.appSecret(); err != nil {
162 return err
163 }
164 go a.runWebSocket(ctx)
165 }
166 // bot open_id 用于把群聊 @ 门控收紧为“必须 @ 本 bot”;拉取失败只降级为
167 // 旧行为(任意 @ 放行),不阻塞启动。
168 go a.fetchBotOpenID(ctx)
169 return nil
170 }
171
172 func (a *adapter) botOpenID() string {
173 a.botMu.Lock()
174 defer a.botMu.Unlock()
175 return a.botID
176 }
177
178 func (a *adapter) fetchBotOpenID(ctx context.Context) {
179 client, err := a.sdkClient()
180 if err != nil {
181 return
182 }
183 ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
184 defer cancel()
185 resp, err := client.Get(ctx, "/open-apis/bot/v3/info", nil, larkcore.AccessTokenTypeTenant)
186 if err != nil {
187 a.logger.Warn("feishu bot info fetch failed; group mention gating stays permissive", "err", err)
188 return
189 }
190 var payload struct {
191 Code int `json:"code"`
192 Bot struct {
193 OpenID string `json:"open_id"`
194 } `json:"bot"`
195 }
196 if err := json.Unmarshal(resp.RawBody, &payload); err != nil || payload.Code != 0 || payload.Bot.OpenID == "" {
197 a.logger.Warn("feishu bot info unavailable; group mention gating stays permissive", "code", payload.Code, "err", err)
198 return
199 }
200 a.botMu.Lock()
201 a.botID = payload.Bot.OpenID
202 a.botMu.Unlock()
203 a.logger.Info("feishu bot identity resolved", "open_id", logHash(payload.Bot.OpenID))
204 }
205
206 // resolveUserName 把 open_id 解析为显示名(1 小时缓存)。缺少 contact 权限或
207 // 调用失败时回退 open_id 本身,并短暂缓存回退值避免每条消息都打一次 API。
208 func (a *adapter) resolveUserName(ctx context.Context, openID string) string {
209 openID = strings.TrimSpace(openID)
210 if openID == "" {
211 return ""
212 }
213 now := time.Now()
214 a.nameMu.Lock()
215 if entry, ok := a.names[openID]; ok && now.Before(entry.expires) {
216 a.nameMu.Unlock()
217 return entry.name
218 }
219 a.nameMu.Unlock()
220 name, ttl := a.lookupUserName(ctx, openID)
221 a.nameMu.Lock()
222 if a.names == nil {
223 a.names = make(map[string]nameCacheEntry)
224 }
225 if len(a.names) > 10000 {
226 a.names = make(map[string]nameCacheEntry)
227 }
228 a.names[openID] = nameCacheEntry{name: name, expires: now.Add(ttl)}
229 a.nameMu.Unlock()
230 return name
231 }
232
233 func (a *adapter) lookupUserName(ctx context.Context, openID string) (string, time.Duration) {
234 client, err := a.sdkClient()
235 if err != nil {
236 return openID, userNameFallbackCacheTTL
237 }
238 ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
239 defer cancel()
240 req := larkcontact.NewGetUserReqBuilder().
241 UserId(openID).
242 UserIdType(larkcontact.UserIdTypeOpenId).
243 Build()
244 resp, err := client.Contact.User.Get(ctx, req)
245 if err != nil || resp == nil || !resp.Success() || resp.Data == nil || resp.Data.User == nil {
246 return openID, userNameFallbackCacheTTL
247 }
248 name := stringPtrValue(resp.Data.User.Name)
249 if name == "" {
250 return openID, userNameFallbackCacheTTL
251 }
252 return name, userNameCacheTTL
253 }
254
255 func (a *adapter) Stop() error {
256 if a.cancel != nil {
257 a.cancel()
258 }
259 if a.wsClient != nil {
260 a.wsClient.Close()
261 }
262 return nil
263 }
264
265 func (a *adapter) Send(ctx context.Context, msg bot.OutboundMessage) (bot.SendResult, error) {
266 return a.sendMessage(ctx, msg)
267 }
268
269 func (a *adapter) SendTyping(ctx context.Context, chatID string) error {
270 return nil
271 }
272
273 func (a *adapter) Messages() <-chan bot.InboundMessage {
274 return a.msgCh
275 }
276
277 func (a *adapter) appSecret() (string, error) {
278 secret := os.Getenv(a.cfg.AppSecretEnv)
279 if a.cfg.AppID == "" || secret == "" {
280 return "", fmt.Errorf("feishu app_id or %s is not configured", a.cfg.AppSecretEnv)
281 }
282 return secret, nil
283 }
284
285 // runWebSocket 启动飞书 WebSocket 长连接。
286 func (a *adapter) runWebSocket(ctx context.Context) {
287 secret, err := a.appSecret()
288 if err != nil {
289 a.logger.Error("feishu websocket config error", "err", err)
290 return
291 }
292 eventHandler := a.newEventDispatcher()
293 bot.RunWithRetry(ctx, a.logger, "feishu sdk websocket", bot.RetryConfig{}, func(ctx context.Context) error {
294 opts := []larkws.ClientOption{
295 larkws.WithEventHandler(eventHandler),
296 larkws.WithLogLevel(larkcore.LogLevelError),
297 larkws.WithAutoReconnect(true),
298 larkws.WithOnReady(func() { a.logger.Info("feishu sdk websocket connected") }),
299 larkws.WithOnReconnecting(func() { a.logger.Warn("feishu sdk websocket reconnecting") }),
300 larkws.WithOnReconnected(func() { a.logger.Info("feishu sdk websocket reconnected") }),
301 larkws.WithOnError(func(err error) { a.logger.Error("feishu sdk websocket error", "err", err) }),
302 }
303 if feishuDomain(a.cfg.Domain) == "lark" {
304 opts = append(opts, larkws.WithDomain(lark.LarkBaseUrl))
305 }
306 client := larkws.NewClient(a.cfg.AppID, secret, opts...)
307 a.wsClient = client
308 // client.Start blocks; run it off-loop so cancellation closes the client
309 // immediately rather than waiting for Start to notice ctx. RunWithRetry
310 // handles the reconnect backoff.
311 errCh := make(chan error, 1)
312 go func() { errCh <- client.Start(ctx) }()
313 select {
314 case <-ctx.Done():
315 client.Close()
316 return nil
317 case err := <-errCh:
318 client.Close()
319 return err
320 }
321 })
322 }
323
324 func (a *adapter) newEventDispatcher() *dispatcher.EventDispatcher {
325 return dispatcher.NewEventDispatcher(a.cfg.VerificationToken, "").
326 OnP2MessageReceiveV1(func(ctx context.Context, event *larkim.P2MessageReceiveV1) error {
327 a.handleSDKMessage(ctx, event)
328 return nil
329 }).
330 OnP2MessageReadV1(func(ctx context.Context, event *larkim.P2MessageReadV1) error {
331 return nil
332 }).
333 OnP2MessageReactionCreatedV1(func(ctx context.Context, event *larkim.P2MessageReactionCreatedV1) error {
334 return nil
335 }).
336 OnP2MessageReactionDeletedV1(func(ctx context.Context, event *larkim.P2MessageReactionDeletedV1) error {
337 return nil
338 }).
339 OnP2CardActionTrigger(func(ctx context.Context, event *callback.CardActionTriggerEvent) (*callback.CardActionTriggerResponse, error) {
340 if event == nil || event.EventReq == nil || !a.handleCardAction(event.Body) {
341 a.logger.Warn("feishu card action ignored", "reason", "invalid_payload")
342 return cardActionToast("warning", "操作无效或已过期"), nil
343 }
344 return cardActionToast("success", "操作已提交"), nil
345 })
346 }
347
348 func (a *adapter) handleSDKMessage(ctx context.Context, event *larkim.P2MessageReceiveV1) {
349 if event == nil || event.Event == nil || event.Event.Message == nil {
350 return
351 }
352 eventID := ""
353 if event.EventV2Base != nil && event.EventV2Base.Header != nil {
354 eventID = event.EventV2Base.Header.EventID
355 }
356 if eventID != "" {
357 if a.markSeen(eventID) {
358 return
359 }
360 }
361 msg := event.Event.Message
362 messageID := stringPtrValue(msg.MessageId)
363 mentions := sdkMentionRefs(msg.Mentions)
364 chatType := bot.ChatDM
365 if stringPtrValue(msg.ChatType) == "group" || stringPtrValue(msg.ChatType) == "topic_group" {
366 chatType = bot.ChatGroup
367 if a.cfg.RequireMention && !a.mentionsBot(mentions) {
368 a.logger.Info("feishu message ignored", "reason", "missing_mention", "chat", logHash(stringPtrValue(msg.ChatId)), "message", logHash(messageID))
369 return
370 }
371 }
372 msgType := stringPtrValue(msg.MessageType)
373 text, media, ok := a.parseInboundContent(msgType, stringPtrValue(msg.Content), messageID)
374 if !ok {
375 a.logger.Info("feishu message ignored", "reason", "unsupported_type", "msg_type", msgType, "chat_type", stringPtrValue(msg.ChatType), "message", logHash(messageID))
376 return
377 }
378 text = a.replaceMentionPlaceholders(text, mentions)
379 if strings.TrimSpace(text) == "" && len(media) == 0 {
380 a.logger.Info("feishu message ignored", "reason", "empty_after_parse", "msg_type", msgType, "message", logHash(messageID))
381 return
382 }
383 userID := ""
384 senderOpenID := ""
385 if event.Event.Sender != nil && event.Event.Sender.SenderId != nil {
386 senderOpenID = stringPtrValue(event.Event.Sender.SenderId.OpenId)
387 userID = firstNonEmpty(
388 senderOpenID,
389 stringPtrValue(event.Event.Sender.SenderId.UnionId),
390 stringPtrValue(event.Event.Sender.SenderId.UserId),
391 )
392 }
393 userName := userID
394 var resolveUserName func(context.Context) string
395 if senderOpenID != "" {
396 resolveUserName = func(ctx context.Context) string {
397 return a.resolveUserName(ctx, senderOpenID)
398 }
399 }
400 ib := bot.InboundMessage{
401 Platform: bot.PlatformFeishu,
402 ChatType: chatType,
403 ChatID: stringPtrValue(msg.ChatId),
404 UserID: userID,
405 UserName: userName,
406 Text: text,
407 MessageID: messageID,
408 ThreadID: stringPtrValue(msg.ThreadId),
409 Media: media,
410 ResolveUserName: resolveUserName,
411 Raw: event,
412 }
413 select {
414 case a.msgCh <- ib:
415 a.logger.Info("feishu inbound queued", "chat_type", chatType, "msg_type", msgType, "chat", logHash(ib.ChatID), "user", logHash(ib.UserID), "message", logHash(ib.MessageID), "text_chars", len([]rune(ib.Text)), "media_items", len(media))
416 default:
417 a.logger.Warn("feishu message channel full")
418 }
419 }
420
421 func (a *adapter) handleWSEvent(ctx context.Context, raw json.RawMessage) {
422 var evt feishuEvent
423 if err := json.Unmarshal(raw, &evt); err != nil {
424 return
425 }
426
427 if a.markSeen(evt.Header.EventID) {
428 return
429 }
430
431 switch evt.Header.EventType {
432 case "im.message.receive_v1":
433 var msg feishuMsgEvent
434 if err := json.Unmarshal(evt.Event, &msg); err != nil {
435 return
436 }
437 a.handleMessage(ctx, msg)
438 }
439 }
440
441 func (a *adapter) handleCardAction(raw []byte) bool {
442 var payload struct {
443 Header feishuHeader `json:"header"`
444 Event struct {
445 Operator struct {
446 UserID string `json:"user_id"`
447 OpenID string `json:"open_id"`
448 UnionID string `json:"union_id"`
449 OperatorID struct {
450 UserID string `json:"user_id"`
451 OpenID string `json:"open_id"`
452 UnionID string `json:"union_id"`
453 } `json:"operator_id"`
454 } `json:"operator"`
455 Context struct {
456 OpenMessageID string `json:"open_message_id"`
457 OpenChatID string `json:"open_chat_id"`
458 } `json:"context"`
459 Action struct {
460 Value map[string]string `json:"value"`
461 } `json:"action"`
462 } `json:"event"`
463 }
464 if err := json.Unmarshal(raw, &payload); err != nil {
465 return false
466 }
467 command := payload.Event.Action.Value["command"]
468 if command == "" || payload.Event.Context.OpenChatID == "" {
469 return false
470 }
471 if a.markSeen(payload.Header.EventID) {
472 return true
473 }
474 chatType := cardActionChatType(payload.Event.Action.Value["chat_type"])
475 operatorID := firstNonEmpty(
476 payload.Event.Operator.OperatorID.UnionID,
477 payload.Event.Operator.OperatorID.OpenID,
478 payload.Event.Operator.OperatorID.UserID,
479 payload.Event.Operator.UnionID,
480 payload.Event.Operator.OpenID,
481 payload.Event.Operator.UserID,
482 )
483 routeUserID := firstNonEmpty(payload.Event.Action.Value["user_id"], operatorID)
484 ib := bot.InboundMessage{
485 Platform: bot.PlatformFeishu,
486 ChatType: chatType,
487 ChatID: payload.Event.Context.OpenChatID,
488 UserID: routeUserID,
489 UserName: routeUserID,
490 OperatorID: operatorID,
491 Text: command,
492 MessageID: payload.Event.Context.OpenMessageID,
493 }
494 select {
495 case a.msgCh <- ib:
496 default:
497 a.logger.Warn("feishu card action channel full")
498 }
499 return true
500 }
501
502 func (a *adapter) markSeen(eventID string) bool {
503 if eventID == "" {
504 return false
505 }
506 a.seenMu.Lock()
507 defer a.seenMu.Unlock()
508 if a.seen == nil {
509 a.seen = make(map[string]bool)
510 }
511 if a.seen[eventID] {
512 return true
513 }
514 a.seen[eventID] = true
515 if len(a.seen) > 10000 {
516 a.seen = make(map[string]bool)
517 a.seen[eventID] = true
518 }
519 return false
520 }
521
522 func cardActionChatType(raw string) bot.ChatType {
523 switch bot.ChatType(raw) {
524 case bot.ChatDM, bot.ChatGroup, bot.ChatGuild, bot.ChatDirect, bot.ChatThread:
525 return bot.ChatType(raw)
526 default:
527 return bot.ChatGroup
528 }
529 }
530
531 func cardActionToast(toastType, content string) *callback.CardActionTriggerResponse {
532 return &callback.CardActionTriggerResponse{
533 Toast: &callback.Toast{
534 Type: toastType,
535 Content: content,
536 },
537 }
538 }
539
540 func (a *adapter) verificationTokenValid(token string) bool {
541 if a.cfg.VerificationToken == "" {
542 return false
543 }
544 return subtle.ConstantTimeCompare([]byte(token), []byte(a.cfg.VerificationToken)) == 1
545 }
546
547 func firstNonEmpty(vals ...string) string {
548 for _, v := range vals {
549 if v != "" {
550 return v
551 }
552 }
553 return ""
554 }
555
556 func logHash(id string) string {
557 if id == "" {
558 return ""
559 }
560 sum := sha256.Sum256([]byte(id))
561 return hex.EncodeToString(sum[:])[:12]
562 }
563
564 func (a *adapter) handleMessage(ctx context.Context, msg feishuMsgEvent) {
565 mentions := webhookMentionRefs(msg.Mentions)
566
567 // @mention gating:仅在群聊中检查是否 @了 bot
568 chatType := bot.ChatDM
569 if msg.ChatType == "group" || msg.ChatType == "topic_group" {
570 chatType = bot.ChatGroup
571 if a.cfg.RequireMention && !a.mentionsBot(mentions) {
572 a.logger.Info("feishu message ignored", "reason", "missing_mention", "chat", logHash(msg.ChatID), "message", logHash(msg.MessageID))
573 return
574 }
575 }
576
577 text, media, ok := a.parseInboundContent(msg.MsgType, msg.Content, msg.MessageID)
578 if !ok {
579 a.logger.Info("feishu message ignored", "reason", "unsupported_type", "msg_type", msg.MsgType, "chat_type", msg.ChatType, "message", logHash(msg.MessageID))
580 return
581 }
582 text = a.replaceMentionPlaceholders(text, mentions)
583 if strings.TrimSpace(text) == "" && len(media) == 0 {
584 a.logger.Info("feishu message ignored", "reason", "empty_after_parse", "msg_type", msg.MsgType, "message", logHash(msg.MessageID))
585 return
586 }
587
588 userName := msg.Sender.SenderID.OpenID
589 var resolveUserName func(context.Context) string
590 if userName != "" {
591 openID := msg.Sender.SenderID.OpenID
592 resolveUserName = func(ctx context.Context) string {
593 return a.resolveUserName(ctx, openID)
594 }
595 }
596 ib := bot.InboundMessage{
597 Platform: bot.PlatformFeishu,
598 ChatType: chatType,
599 ChatID: msg.ChatID,
600 UserID: msg.Sender.SenderID.OpenID,
601 UserName: userName,
602 Text: text,
603 MessageID: msg.MessageID,
604 ThreadID: msg.ThreadID,
605 Media: media,
606 ResolveUserName: resolveUserName,
607 }
608
609 select {
610 case a.msgCh <- ib:
611 a.logger.Info("feishu inbound queued", "chat_type", chatType, "msg_type", msg.MsgType, "chat", logHash(ib.ChatID), "user", logHash(ib.UserID), "message", logHash(ib.MessageID), "text_chars", len([]rune(ib.Text)), "media_items", len(media))
612 default:
613 a.logger.Warn("feishu message channel full")
614 }
615 }
616
617 // SendText sends an interactive card with markdown content to a Feishu/Lark chat_id using the SDK.
618 // It is used by the desktop settings panel as an actual connection test.
619 func SendText(ctx context.Context, cfg config.FeishuBotConfig, chatID, text string) (bot.SendResult, error) {
620 a := &adapter{cfg: cfg, logger: slog.Default().With("platform", "feishu")}
621 return a.sendMessage(ctx, bot.OutboundMessage{ChatID: chatID, Text: text})
622 }
623
624 // sendMessage 使用飞书/Lark SDK 以 Interactive Card (JSON 2.0) 发送消息。
625 // Card 内嵌 markdown 元素,支持 CommonMark 标准语法。
626 // 当卡片体积超过 30KB 限制(如大段代码),自动降级为纯文本消息。
627 // MediaURLs are bare filenames staged in an operator-configured outbound media
628 // root. URL fetching and arbitrary-path reads are intentionally unsupported.
629 func (a *adapter) sendMessage(ctx context.Context, msg bot.OutboundMessage) (bot.SendResult, error) {
630 if msg.Card != nil {
631 return a.sendCard(ctx, msg)
632 }
633 if len(msg.MediaURLs) == 0 {
634 return a.sendRenderedText(ctx, msg)
635 }
636 media, err := a.loadOutboundMedia(msg.MediaURLs)
637 if err != nil {
638 return bot.SendResult{}, err
639 }
640
641 var result bot.SendResult
642 if strings.TrimSpace(msg.Text) != "" {
643 textResult, err := a.sendRenderedText(ctx, msg)
644 result.Merge(textResult)
645 if err != nil {
646 return result, err
647 }
648 }
649 mediaResult, err := a.sendMedia(ctx, msg, media)
650 result.Merge(mediaResult)
651 return result, err
652 }
653
654 func (a *adapter) sendRenderedText(ctx context.Context, msg bot.OutboundMessage) (bot.SendResult, error) {
655 cardContent, err := buildMarkdownCard(msg.Text)
656 if err != nil {
657 a.logger.Warn("build markdown card failed, falling back to text", "err", err)
658 return a.sendSDKContent(ctx, msg, larkim.MsgTypeText, feishuTextContent(msg.Text))
659 }
660 result, err := a.sendSDKContent(ctx, msg, larkim.MsgTypeInteractive, cardContent)
661 if err != nil && isCardLimitError(err) {
662 a.logger.Warn("card send failed (size limit), retrying as text", "err", err)
663 return a.sendSDKContent(ctx, msg, larkim.MsgTypeText, feishuTextContent(msg.Text))
664 }
665 return result, err
666 }
667
668 func buildMarkdownCard(content string) (string, error) {
669 card := map[string]any{
670 "schema": "2.0",
671 // update_multi marks the card as a shared card that can be patched for
672 // all recipients after sending; without it Im.Message.Patch (used by
673 // EditMessage for streaming) is rejected, which would collapse
674 // streaming into a flood of new messages. See references/desktop-ui.
675 "config": map[string]any{
676 "update_multi": true,
677 },
678 "body": map[string]any{
679 "elements": []map[string]any{
680 {
681 "tag": "markdown",
682 "content": content,
683 },
684 },
685 },
686 }
687 data, err := json.Marshal(card)
688 if err != nil {
689 return "", err
690 }
691 return string(data), nil
692 }
693
694 func feishuTextContent(text string) string {
695 content, _ := json.Marshal(textContent{Text: text})
696 return string(content)
697 }
698
699 func isCardLimitError(err error) bool {
700 if err == nil {
701 return false
702 }
703 s := err.Error()
704 return strings.Contains(s, "11310") || strings.Contains(s, "11325")
705 }
706
707 const feishuReplyRecalledCode = 230011
708
709 type feishuAPIError struct {
710 op string
711 code int
712 msg string
713 }
714
715 func (e *feishuAPIError) Error() string {
716 return fmt.Sprintf("feishu %s error: %s", e.op, feishuCodeError(e.code, e.msg))
717 }
718
719 func isReplyFallbackError(err error) bool {
720 var apiErr *feishuAPIError
721 return errors.As(err, &apiErr) && apiErr.op == "reply" && apiErr.code == feishuReplyRecalledCode
722 }
723
724 // sdkClient lazily builds the shared lark client. It is called concurrently —
725 // the fetchBotOpenID goroutine, per-message resolveUserName, and per-resource
726 // downloads all race on first use at startup — so the check-and-build is guarded
727 // by clientMu (a bare a.client read/write would data-race, tripping -race).
728 func (a *adapter) sdkClient() (*lark.Client, error) {
729 a.clientMu.Lock()
730 defer a.clientMu.Unlock()
731 if a.client != nil {
732 return a.client, nil
733 }
734 secret, err := a.appSecret()
735 if err != nil {
736 return nil, err
737 }
738 opts := []lark.ClientOptionFunc{
739 lark.WithLogLevel(larkcore.LogLevelError),
740 lark.WithReqTimeout(15 * time.Second),
741 lark.WithSource("reasonix"),
742 }
743 if feishuDomain(a.cfg.Domain) == "lark" {
744 opts = append(opts, lark.WithOpenBaseUrl(lark.LarkBaseUrl), lark.WithOAuthBaseUrl(lark.OAuthBaseUrlLark))
745 }
746 a.client = lark.NewClient(a.cfg.AppID, secret, opts...)
747 return a.client, nil
748 }
749
750 func (a *adapter) sendSDKContent(ctx context.Context, msg bot.OutboundMessage, msgType, content string) (bot.SendResult, error) {
751 client, err := a.sdkClient()
752 if err != nil {
753 return bot.SendResult{}, err
754 }
755 chatID := strings.TrimSpace(msg.ChatID)
756 if chatID == "" {
757 return bot.SendResult{}, fmt.Errorf("feishu chat_id is empty")
758 }
759 // 带触发消息 ID 时用 Reply 引用回复:话题群里回复会落到对应话题,
760 // 普通群里带引用上下文。只有飞书明确返回“消息已撤回”时才回退普通
761 // 发送;传输错误的提交结果不确定,回退 Create 可能产生重复消息。
762 if replyTo := strings.TrimSpace(msg.ReplyToMsgID); replyTo != "" {
763 result, err := a.replySDKContent(ctx, replyTo, msgType, content)
764 if err == nil {
765 return result, nil
766 }
767 if !isReplyFallbackError(err) {
768 return bot.SendResult{}, err
769 }
770 a.logger.Warn("feishu reply failed; falling back to create", "message", logHash(replyTo), "err", err)
771 }
772 // Stable across retries so a retry after a post-commit connection drop does
773 // not send a duplicate visible message (Feishu dedups on uuid).
774 uuid := newIdempotencyKey()
775 var result bot.SendResult
776 err = withTransientRetry(ctx, a.logger, "create message", func(ctx context.Context) error {
777 body := larkim.NewCreateMessageReqBodyBuilder().ReceiveId(chatID).MsgType(msgType).Content(content)
778 if uuid != "" {
779 body = body.Uuid(uuid)
780 }
781 req := larkim.NewCreateMessageReqBuilder().
782 ReceiveIdType(larkim.CreateMessageV1ReceiveIDTypeChatId).
783 Body(body.Build()).
784 Build()
785 resp, err := client.Im.Message.Create(ctx, req)
786 if err != nil {
787 return err
788 }
789 if resp == nil {
790 return fmt.Errorf("feishu send error: empty response")
791 }
792 if !resp.Success() {
793 return fmt.Errorf("feishu send error: %s", feishuCodeError(resp.Code, resp.Msg))
794 }
795 if resp.Data != nil {
796 result = bot.SendResult{MessageID: stringPtrValue(resp.Data.MessageId)}
797 }
798 return nil
799 })
800 if err != nil {
801 return bot.SendResult{}, err
802 }
803 return result, nil
804 }
805
806 func (a *adapter) replySDKContent(ctx context.Context, replyTo, msgType, content string) (bot.SendResult, error) {
807 client, err := a.sdkClient()
808 if err != nil {
809 return bot.SendResult{}, err
810 }
811 uuid := newIdempotencyKey()
812 var result bot.SendResult
813 err = withTransientRetry(ctx, a.logger, "reply message", func(ctx context.Context) error {
814 body := larkim.NewReplyMessageReqBodyBuilder().MsgType(msgType).Content(content)
815 if uuid != "" {
816 body = body.Uuid(uuid)
817 }
818 req := larkim.NewReplyMessageReqBuilder().
819 MessageId(replyTo).
820 Body(body.Build()).
821 Build()
822 resp, err := client.Im.Message.Reply(ctx, req)
823 if err != nil {
824 return err
825 }
826 if resp == nil {
827 return fmt.Errorf("feishu reply error: empty response")
828 }
829 if !resp.Success() {
830 return &feishuAPIError{op: "reply", code: resp.Code, msg: resp.Msg}
831 }
832 if resp.Data != nil {
833 result = bot.SendResult{MessageID: stringPtrValue(resp.Data.MessageId)}
834 }
835 return nil
836 })
837 if err != nil {
838 return bot.SendResult{}, err
839 }
840 return result, nil
841 }
842
843 func (a *adapter) AddPendingReaction(ctx context.Context, messageID string) (func(), error) {
844 messageID = strings.TrimSpace(messageID)
845 if messageID == "" {
846 return nil, nil
847 }
848 client, err := a.sdkClient()
849 if err != nil {
850 return nil, err
851 }
852 req := larkim.NewCreateMessageReactionReqBuilder().
853 MessageId(messageID).
854 Body(larkim.NewCreateMessageReactionReqBodyBuilder().
855 ReactionType(larkim.NewEmojiBuilder().EmojiType(feishuPendingReactionEmoji).Build()).
856 Build()).
857 Build()
858 resp, err := client.Im.MessageReaction.Create(ctx, req)
859 if err != nil {
860 return nil, err
861 }
862 if resp == nil || !resp.Success() {
863 if resp != nil {
864 return nil, fmt.Errorf("feishu reaction error: %s", feishuCodeError(resp.Code, resp.Msg))
865 }
866 return nil, fmt.Errorf("feishu reaction error: empty response")
867 }
868 reactionID := ""
869 if resp.Data != nil && resp.Data.ReactionId != nil {
870 reactionID = *resp.Data.ReactionId
871 }
872 if reactionID == "" {
873 return nil, nil
874 }
875 cleanup := func() {
876 delReq := larkim.NewDeleteMessageReactionReqBuilder().
877 MessageId(messageID).
878 ReactionId(reactionID).
879 Build()
880 if _, err := client.Im.MessageReaction.Delete(context.Background(), delReq); err != nil {
881 a.logger.Warn("feishu reaction cleanup failed", "message", logHash(messageID), "err", err)
882 }
883 }
884 return cleanup, nil
885 }
886
887 // sendCard 发送 interactive card 消息(用于审批/问答)。
888 func (a *adapter) sendCard(ctx context.Context, msg bot.OutboundMessage) (bot.SendResult, error) {
889 card := msg.Card
890
891 elements := make([]map[string]interface{}, 0)
892 for _, el := range card.Elements {
893 item := map[string]interface{}{"tag": el.Tag}
894 if el.Content != "" {
895 item["content"] = el.Content
896 }
897 if actions, ok := el.Extra["actions"]; ok && el.Tag == "action" {
898 item["actions"] = actions
899 } else {
900 for k, v := range el.Extra {
901 item[k] = v
902 }
903 }
904 elements = append(elements, item)
905 }
906
907 cardPayload := map[string]interface{}{
908 "header": map[string]interface{}{
909 "title": map[string]string{
910 "tag": "plain_text",
911 "content": card.Header,
912 },
913 },
914 "elements": elements,
915 }
916
917 cardJSON, _ := json.Marshal(cardPayload)
918 return a.sendSDKContent(ctx, msg, larkim.MsgTypeInteractive, string(cardJSON))
919 }
920
921 func feishuDomain(domain string) string {
922 if strings.EqualFold(strings.TrimSpace(domain), "lark") {
923 return "lark"
924 }
925 return "feishu"
926 }
927
928 func stringPtrValue(ptr *string) string {
929 if ptr == nil {
930 return ""
931 }
932 return strings.TrimSpace(*ptr)
933 }
934
935 func feishuCodeError(code int, msg string) string {
936 msg = strings.TrimSpace(msg)
937 if msg == "" {
938 msg = "unknown error"
939 }
940 if code == 0 {
941 return msg
942 }
943 return fmt.Sprintf("%s (code %d)", msg, code)
944 }
945
946 // runWebhook 启动飞书 Webhook 模式。
947 func (a *adapter) runWebhook(ctx context.Context) {
948 port := a.cfg.WebhookPort
949 if port == 0 {
950 port = 8080
951 }
952
953 mux := http.NewServeMux()
954 mux.HandleFunc("/feishu/event", func(w http.ResponseWriter, r *http.Request) {
955 body, err := io.ReadAll(io.LimitReader(r.Body, 1024*1024))
956 if err != nil {
957 http.Error(w, "bad request", 400)
958 return
959 }
960 var challenge struct {
961 Challenge string `json:"challenge"`
962 Token string `json:"token"`
963 Type string `json:"type"`
964 }
965 _ = json.Unmarshal(body, &challenge)
966 if challenge.Type == "url_verification" {
967 if !a.verificationTokenValid(challenge.Token) {
968 http.Error(w, "forbidden", http.StatusForbidden)
969 return
970 }
971 w.Header().Set("Content-Type", "application/json")
972 if err := json.NewEncoder(w).Encode(map[string]string{"challenge": challenge.Challenge}); err != nil {
973 a.logger.Error("feishu challenge response error", "err", err)
974 }
975 return
976 }
977
978 var evt feishuEvent
979 if err := json.Unmarshal(body, &evt); err != nil {
980 http.Error(w, "bad request", 400)
981 return
982 }
983 if !a.verificationTokenValid(evt.Header.Token) {
984 http.Error(w, "forbidden", http.StatusForbidden)
985 return
986 }
987
988 if !a.handleCardAction(body) {
989 raw, _ := json.Marshal(evt)
990 a.handleWSEvent(ctx, raw)
991 }
992 w.WriteHeader(200)
993 })
994
995 server := &http.Server{
996 Addr: fmt.Sprintf(":%d", port),
997 Handler: mux,
998 }
999
1000 go func() {
1001 <-ctx.Done()
1002 if err := server.Shutdown(context.Background()); err != nil && err != http.ErrServerClosed {
1003 a.logger.Error("feishu webhook shutdown error", "err", err)
1004 }
1005 }()
1006
1007 a.logger.Info("feishu webhook listening", "port", port)
1008 if err := server.ListenAndServe(); err != http.ErrServerClosed {
1009 a.logger.Error("feishu webhook server error", "err", err)
1010 }
1011 }
1012
1012 lines GO