| 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 |