• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

smallnest / goclaw / 22518818095

28 Feb 2026 10:18AM UTC coverage: 8.86% (-0.02%) from 8.879%
22518818095

push

github

smallnest
fix cron

12 of 538 new or added lines in 30 files covered. (2.23%)

9 existing lines in 8 files now uncovered.

2868 of 32371 relevant lines covered (8.86%)

0.52 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

0.0
/channels/feishu.go
1
package channels
2

3
import (
4
        "bytes"
5
        "context"
6
        "encoding/base64"
7
        "encoding/json"
8
        "fmt"
9
        "io"
10
        "net/http"
11
        "strconv"
12
        "strings"
13
        "sync"
14
        "time"
15

16
        lark "github.com/larksuite/oapi-sdk-go/v3"
17
        larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
18
        "github.com/larksuite/oapi-sdk-go/v3/event/dispatcher"
19
        larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
20
        larkws "github.com/larksuite/oapi-sdk-go/v3/ws"
21
        "github.com/smallnest/goclaw/bus"
22
        "github.com/smallnest/goclaw/config"
23
        "github.com/smallnest/goclaw/internal/logger"
24
        "go.uber.org/zap"
25
)
26

27
// FeishuChannel 飞书通道 - WebSocket 模式
28
type FeishuChannel struct {
29
        *BaseChannelImpl
30
        appID             string
31
        appSecret         string
32
        domain            string
33
        encryptKey        string
34
        verificationToken string
35
        wsClient          *larkws.Client
36
        eventDispatcher   *dispatcher.EventDispatcher
37
        httpClient        *lark.Client
38
        // typing indicator state: messageID -> reactionID mapping
39
        typingReactions   map[string]string
40
        typingReactionsMu sync.RWMutex
41
        // bot open_id for mention checking
42
        botOpenId string
43
}
44

45
// NewFeishuChannel 创建飞书通道
46
func NewFeishuChannel(cfg config.FeishuChannelConfig, bus *bus.MessageBus) (*FeishuChannel, error) {
×
47
        if cfg.AppID == "" || cfg.AppSecret == "" {
×
48
                return nil, fmt.Errorf("feishu app_id and app_secret are required")
×
49
        }
×
50

51
        // 创建 HTTP client for sending messages
52
        client := lark.NewClient(
×
53
                cfg.AppID,
×
54
                cfg.AppSecret,
×
55
                lark.WithAppType(larkcore.AppTypeSelfBuilt),
×
56
                lark.WithOpenBaseUrl(resolveDomain(cfg.Domain)),
×
57
        )
×
58

×
59
        baseCfg := BaseChannelConfig{
×
60
                Enabled:    cfg.Enabled,
×
61
                AllowedIDs: cfg.AllowedIDs,
×
62
        }
×
63

×
64
        return &FeishuChannel{
×
65
                BaseChannelImpl:   NewBaseChannelImpl("feishu", "default", baseCfg, bus),
×
66
                appID:             cfg.AppID,
×
67
                appSecret:         cfg.AppSecret,
×
68
                domain:            cfg.Domain,
×
69
                encryptKey:        cfg.EncryptKey,
×
70
                verificationToken: cfg.VerificationToken,
×
71
                httpClient:        client,
×
72
                typingReactions:   make(map[string]string),
×
73
        }, nil
×
74
}
75

76
// Start 启动飞书通道
77
func (c *FeishuChannel) Start(ctx context.Context) error {
×
78
        if err := c.BaseChannelImpl.Start(ctx); err != nil {
×
79
                return err
×
80
        }
×
81

82
        logger.Info("Starting Feishu channel (WebSocket mode)",
×
83
                zap.String("app_id", c.appID),
×
84
                zap.String("domain", c.domain))
×
85

×
86
        // 获取机器人的 open_id(用于 @ 检查)
×
87
        if err := c.fetchBotOpenId(); err != nil {
×
88
                logger.Warn("Failed to fetch bot open_id, mention checking will be disabled", zap.Error(err))
×
89
        } else {
×
90
                logger.Info("Feishu bot open_id resolved", zap.String("bot_open_id", c.botOpenId))
×
91
        }
×
92

93
        // 创建事件分发器
94
        c.eventDispatcher = dispatcher.NewEventDispatcher(
×
95
                c.verificationToken,
×
96
                c.encryptKey,
×
97
        )
×
98

×
99
        // 注册事件处理器
×
100
        c.registerEventHandlers(ctx)
×
101

×
102
        // 创建 WebSocket 客户端
×
103
        c.wsClient = larkws.NewClient(
×
104
                c.appID,
×
105
                c.appSecret,
×
106
                larkws.WithEventHandler(c.eventDispatcher),
×
107
                larkws.WithDomain(resolveDomain(c.domain)),
×
108
                larkws.WithLogLevel(larkcore.LogLevelInfo),
×
109
        )
×
110

×
111
        // 启动 WebSocket 连接
×
112
        go c.startWebSocket(ctx)
×
113

×
114
        return nil
×
115
}
116

117
// resolveDomain 解析域名
118
func resolveDomain(domain string) string {
×
119
        if domain == "lark" {
×
120
                return lark.LarkBaseUrl
×
121
        }
×
122
        return lark.FeishuBaseUrl
×
123
}
124

125
// fetchBotOpenId 获取机器人的 open_id
126
func (c *FeishuChannel) fetchBotOpenId() error {
×
127
        ctx := context.Background()
×
128

×
129
        // 1. 获取 app_access_token
×
130
        tokenReq := &larkcore.SelfBuiltAppAccessTokenReq{
×
131
                AppID:     c.appID,
×
132
                AppSecret: c.appSecret,
×
133
        }
×
134

×
135
        tokenResp, err := c.httpClient.GetAppAccessTokenBySelfBuiltApp(ctx, tokenReq)
×
136
        if err != nil {
×
137
                return fmt.Errorf("failed to get app access token: %w", err)
×
138
        }
×
139
        if !tokenResp.Success() || tokenResp.AppAccessToken == "" {
×
140
                return fmt.Errorf("app access token error: code=%d msg=%s", tokenResp.Code, tokenResp.Msg)
×
141
        }
×
142

143
        // 2. 使用 app_access_token 调用 bot/info API
144
        apiResp, err := c.httpClient.Get(ctx, "/open-apis/bot/v3/info", nil, larkcore.AccessTokenTypeApp)
×
145
        if err != nil {
×
146
                return fmt.Errorf("failed to fetch bot info: %w", err)
×
147
        }
×
148

149
        var result struct {
×
150
                Code int    `json:"code"`
×
151
                Msg  string `json:"msg"`
×
152
                Bot  struct {
×
153
                        OpenId  string `json:"open_id"`
×
154
                        BotName string `json:"bot_name"`
×
155
                } `json:"bot"`
×
156
        }
×
157

×
158
        if err := json.Unmarshal(apiResp.RawBody, &result); err != nil {
×
159
                return fmt.Errorf("failed to decode bot info response: %w", err)
×
160
        }
×
161

162
        if result.Code != 0 {
×
163
                return fmt.Errorf("bot info API error: code=%d msg=%s", result.Code, result.Msg)
×
164
        }
×
165

166
        c.botOpenId = result.Bot.OpenId
×
167
        logger.Info("Fetched bot info",
×
168
                zap.String("bot_name", result.Bot.BotName),
×
169
                zap.String("bot_open_id", c.botOpenId))
×
170
        return nil
×
171
}
172

173
// registerEventHandlers 注册事件处理器
174
func (c *FeishuChannel) registerEventHandlers(ctx context.Context) {
×
175
        // 处理接收消息事件
×
176
        c.eventDispatcher.OnP2MessageReceiveV1(func(ctx context.Context, event *larkim.P2MessageReceiveV1) error {
×
177
                c.handleMessageReceived(ctx, event)
×
178
                return nil
×
179
        })
×
180

181
        // 处理消息已读事件(忽略)
182
        c.eventDispatcher.OnP2MessageReadV1(func(ctx context.Context, event *larkim.P2MessageReadV1) error {
×
183
                logger.Debug("Feishu message read event ignored", zap.Strings("message_ids", event.Event.MessageIdList))
×
184
                return nil
×
185
        })
×
186

187
        // 处理机器人进入私聊事件(忽略)
188
        c.eventDispatcher.OnP2ChatAccessEventBotP2pChatEnteredV1(func(ctx context.Context, event *larkim.P2ChatAccessEventBotP2pChatEnteredV1) error {
×
189
                logger.Debug("Feishu bot p2p chat entered event ignored")
×
190
                return nil
×
191
        })
×
192

193
        // 处理消息撤回事件(忽略)
194
        c.eventDispatcher.OnP2MessageRecalledV1(func(ctx context.Context, event *larkim.P2MessageRecalledV1) error {
×
195
                logger.Debug("Feishu message recalled event ignored", zap.String("message_id", *event.Event.MessageId))
×
196
                return nil
×
197
        })
×
198

199
        // 处理消息表情反应创建事件(忽略)
200
        c.eventDispatcher.OnP2MessageReactionCreatedV1(func(ctx context.Context, event *larkim.P2MessageReactionCreatedV1) error {
×
201
                logger.Debug("Feishu message reaction created event ignored")
×
202
                return nil
×
203
        })
×
204

205
        // 处理消息表情反应删除事件(忽略)
206
        c.eventDispatcher.OnP2MessageReactionDeletedV1(func(ctx context.Context, event *larkim.P2MessageReactionDeletedV1) error {
×
207
                logger.Debug("Feishu message reaction deleted event ignored")
×
208
                return nil
×
209
        })
×
210

211
        // 处理机器人被添加到群聊事件
212
        c.eventDispatcher.OnP2ChatMemberBotAddedV1(func(ctx context.Context, event *larkim.P2ChatMemberBotAddedV1) error {
×
213
                logger.Info("Feishu bot added to chat",
×
214
                        zap.String("chat_id", *event.Event.ChatId))
×
215
                return nil
×
216
        })
×
217

218
        // 处理机器人被移出群聊事件
219
        c.eventDispatcher.OnP2ChatMemberBotDeletedV1(func(ctx context.Context, event *larkim.P2ChatMemberBotDeletedV1) error {
×
220
                logger.Info("Feishu bot removed from chat",
×
221
                        zap.String("chat_id", *event.Event.ChatId))
×
222
                return nil
×
223
        })
×
224
}
225

226
// startWebSocket 启动 WebSocket 连接
227
func (c *FeishuChannel) startWebSocket(ctx context.Context) {
×
228
        logger.Info("Starting Feishu WebSocket connection")
×
229

×
230
        // Start blocks forever, so run it in the goroutine
×
231
        // The wsClient will handle reconnection automatically
×
232
        if err := c.wsClient.Start(ctx); err != nil {
×
233
                logger.Error("Feishu WebSocket error", zap.Error(err))
×
234
        }
×
235

236
        logger.Info("Feishu WebSocket connection stopped")
×
237
}
238

239
// handleMessageReceived 处理接收到的消息
240
func (c *FeishuChannel) handleMessageReceived(ctx context.Context, event *larkim.P2MessageReceiveV1) {
×
241
        if event.Event == nil || event.Event.Sender == nil || event.Event.Message == nil {
×
242
                logger.Debug("Feishu message event has nil fields")
×
243
                return
×
244
        }
×
245

246
        senderID := ""
×
247
        if event.Event.Sender.SenderId != nil {
×
248
                if event.Event.Sender.SenderId.OpenId != nil {
×
249
                        senderID = *event.Event.Sender.SenderId.OpenId
×
250
                } else if event.Event.Sender.SenderId.UserId != nil {
×
251
                        senderID = *event.Event.Sender.SenderId.UserId
×
252
                }
×
253
        }
254

255
        chatID := ""
×
256
        if event.Event.Message.ChatId != nil {
×
257
                chatID = *event.Event.Message.ChatId
×
258
        }
×
259

260
        messageID := ""
×
261
        if event.Event.Message.MessageId != nil {
×
262
                messageID = *event.Event.Message.MessageId
×
263
        }
×
264

265
        chatType := "unknown"
×
266
        if event.Event.Message.ChatType != nil {
×
267
                chatType = *event.Event.Message.ChatType
×
268
        }
×
269

270
        messageType := "unknown"
×
271
        if event.Event.Message.MessageType != nil {
×
272
                messageType = *event.Event.Message.MessageType
×
273
        }
×
274

275
        messageContent := ""
×
276
        if event.Event.Message.Content != nil {
×
277
                messageContent = *event.Event.Message.Content
×
278
        }
×
279

280
        // 打印收到的消息关键信息
281
        logger.Info("[Feishu] Received message",
×
282
                zap.String("chat_id", chatID),
×
283
                zap.String("chat_type", chatType),
×
284
                zap.String("sender_id", senderID),
×
285
                zap.String("sender_type", getStringPtr(event.Event.Sender.SenderType)),
×
286
                zap.String("message_id", messageID),
×
287
                zap.String("message_type", messageType),
×
288
                zap.String("message_content", messageContent),
×
289
                zap.Int("mentions_count", len(event.Event.Message.Mentions)),
×
290
        )
×
291

×
292
        // 检查发送者权限
×
293
        if senderID != "" && !c.IsAllowed(senderID) {
×
294
                return
×
295
        }
×
296

297
        // 检查群聊消息是否 @ 了机器人
298
        isGroupChat := chatType == "group"
×
299

×
300
        if isGroupChat {
×
301
                if c.botOpenId == "" {
×
302
                        return
×
303
                }
×
304
                mentionedBot := c.checkBotMentioned(event.Event.Message)
×
305
                if !mentionedBot {
×
306
                        return
×
307
                }
×
308
        }
309

310
        // 解析消息内容和媒体
311
        content, media := c.extractMessageContentAndMedia(event.Event.Message)
×
312
        if content == "" && len(media) == 0 {
×
313
                return
×
314
        }
×
315

316
        // 解析时间戳
317
        var timestamp time.Time
×
318
        if event.Event.Message.CreateTime != nil {
×
319
                if ms, err := strconv.ParseInt(*event.Event.Message.CreateTime, 10, 64); err == nil {
×
320
                        timestamp = time.UnixMilli(ms)
×
321
                } else {
×
322
                        timestamp = time.Now()
×
323
                }
×
324
        } else {
×
325
                timestamp = time.Now()
×
326
        }
×
327

328
        // 发布到消息总线前,先添加 typing indicator
329
        // 使用 messageID 来匹配用户消息
330
        if err := c.addTypingIndicator(messageID); err != nil {
×
331
                logger.Debug("Failed to add typing indicator (non-critical)", zap.Error(err))
×
332
        }
×
333

334
        // 发布到消息总线
335
        inbound := &bus.InboundMessage{
×
336
                ID:        messageID,
×
337
                Content:   content,
×
338
                SenderID:  senderID,
×
339
                ChatID:    chatID,
×
340
                Channel:   c.Name(),
×
341
                AccountID: "default",
×
342
                Timestamp: timestamp,
×
343
                Metadata: map[string]interface{}{
×
344
                        "msg_type": getStringPtr(event.Event.Message.MessageType),
×
345
                },
×
346
                Media: media,
×
347
        }
×
348

×
349
        if err := c.PublishInbound(ctx, inbound); err != nil {
×
350
                logger.Error("Failed to publish inbound message",
×
351
                        zap.String("message_id", messageID),
×
352
                        zap.Error(err))
×
353
                // 清除 typing indicator
×
NEW
354
                if err := c.removeTypingIndicator(messageID); err != nil {
×
NEW
355
                        logger.Debug("failed to remove typing indicator", zap.Error(err))
×
NEW
356
                }
×
357
                return
×
358
        }
359
}
360

361
// extractMessageContentAndMedia 从消息中提取文本内容和媒体文件
362
func (c *FeishuChannel) extractMessageContentAndMedia(msg *larkim.EventMessage) (string, []bus.Media) {
×
363
        if msg.Content == nil {
×
364
                logger.Debug("Message content is nil")
×
365
                return "", nil
×
366
        }
×
367

368
        contentRaw := *msg.Content
×
369
        logger.Debug("Extracting message content", zap.String("message_type", getStringPtr(msg.MessageType)), zap.String("content", contentRaw))
×
370

×
371
        // 支持多种消息类型
×
372
        msgType := "text"
×
373
        if msg.MessageType != nil {
×
374
                msgType = *msg.MessageType
×
375
        }
×
376

377
        switch msgType {
×
378
        case "text":
×
379
                // 文本消息格式: {"text":"内容"}
×
380
                var content map[string]string
×
381
                if err := json.Unmarshal([]byte(contentRaw), &content); err != nil {
×
382
                        logger.Error("Failed to parse text message content", zap.Error(err))
×
383
                        return "", nil
×
384
                }
×
385
                return content["text"], nil
×
386

387
        case "image":
×
388
                // 图片消息格式: {"image_key":"img_xxx"}
×
389
                var content map[string]string
×
390
                if err := json.Unmarshal([]byte(contentRaw), &content); err != nil {
×
391
                        logger.Error("Failed to parse image message content", zap.Error(err))
×
392
                        return "", nil
×
393
                }
×
394
                imageKey := content["image_key"]
×
395
                if imageKey == "" {
×
396
                        return "", nil
×
397
                }
×
398
                // 使用 feishu: 前缀格式存储 image_key,用于后续通过 GetImage API 获取
399
                media := []bus.Media{
×
400
                        {
×
401
                                Type: "image",
×
402
                                URL:  "feishu:" + imageKey,
×
403
                        },
×
404
                }
×
405
                return "[图片]", media
×
406

407
        case "post":
×
408
                // 富文本消息格式: {"post":{"zh_cn":[{"tag":"text","text":"内容"}]}}
×
409
                var content map[string]interface{}
×
410
                if err := json.Unmarshal([]byte(contentRaw), &content); err != nil {
×
411
                        logger.Error("Failed to parse post message content", zap.Error(err))
×
412
                        return "", nil
×
413
                }
×
414
                if post, ok := content["post"].(map[string]interface{}); ok {
×
415
                        if zhCn, ok := post["zh_cn"].([]interface{}); ok && len(zhCn) > 0 {
×
416
                                // 提取所有文本元素和图片
×
417
                                var result strings.Builder
×
418
                                var media []bus.Media
×
419
                                for _, elem := range zhCn {
×
420
                                        if elemMap, ok := elem.(map[string]interface{}); ok {
×
421
                                                if tag, ok := elemMap["tag"].(string); ok {
×
422
                                                        if tag == "text" {
×
423
                                                                if text, ok := elemMap["text"].(string); ok {
×
424
                                                                        result.WriteString(text)
×
425
                                                                }
×
426
                                                        } else if tag == "img" {
×
427
                                                                // 提取富文本中的图片
×
428
                                                                if imageKey, ok := elemMap["image_key"].(string); ok && imageKey != "" {
×
429
                                                                        media = append(media, bus.Media{
×
430
                                                                                Type: "image",
×
431
                                                                                URL:  "feishu:" + imageKey,
×
432
                                                                        })
×
433
                                                                        result.WriteString("[图片]")
×
434
                                                                }
×
435
                                                        }
436
                                                }
437
                                        }
438
                                }
439
                                return result.String(), media
×
440
                        }
441
                }
442

443
        default:
×
444
                logger.Debug("Unsupported message type", zap.String("type", msgType))
×
445
        }
446

447
        return "", nil
×
448
}
449

450
// checkBotMentioned 检查消息是否 @ 了机器人
451
func (c *FeishuChannel) checkBotMentioned(msg *larkim.EventMessage) bool {
×
452
        mentions := msg.Mentions
×
453

×
454
        // 如果不 AT 任何机器人,就当废话
×
455
        // if len(mentions) == 0 {
×
456
        //         logger.Debug("No mentions in message", zap.String("bot_open_id", c.botOpenId))
×
457
        //         return false
×
458
        // }
×
459

×
460
        // 遍历 mentions,检查是否有机器人的 open_id
×
461
        for _, mention := range mentions {
×
462
                mentionOpenId := ""
×
463
                if mention.Id != nil && mention.Id.OpenId != nil {
×
464
                        mentionOpenId = *mention.Id.OpenId
×
465
                }
×
466
                logger.Debug("Checking mention",
×
467
                        zap.String("bot_open_id", c.botOpenId),
×
468
                        zap.String("mention_open_id", mentionOpenId),
×
469
                        zap.Bool("matches", mentionOpenId == c.botOpenId))
×
470

×
471
                if mention.Id != nil && mention.Id.OpenId != nil {
×
472
                        if *mention.Id.OpenId == c.botOpenId {
×
473
                                return true
×
474
                        }
×
475
                }
476
        }
477

478
        logger.Debug("Bot not mentioned in message",
×
479
                zap.String("bot_open_id", c.botOpenId),
×
480
                zap.Int("mentions_count", len(mentions)))
×
481
        return false
×
482
}
483

484
// Send 发送消息
485
func (c *FeishuChannel) Send(msg *bus.OutboundMessage) error {
×
486
        logger.Debug("Feishu sending message",
×
487
                zap.String("chat_id", msg.ChatID),
×
488
                zap.String("reply_to", msg.ReplyTo),
×
489
                zap.Int("content_length", len(msg.Content)),
×
490
                zap.Int("media_count", len(msg.Media)))
×
491

×
492
        // 判断接收者类型
×
493
        receiveIDType := larkim.ReceiveIdTypeChatId
×
494
        if len(msg.ChatID) > 3 && msg.ChatID[:3] == "ou_" {
×
495
                receiveIDType = larkim.ReceiveIdTypeOpenId
×
496
        }
×
497

498
        var err error
×
499
        // 优先发送图片消息
×
500
        if len(msg.Media) > 0 {
×
501
                for _, media := range msg.Media {
×
502
                        if media.Type == "image" {
×
503
                                if err = c.sendImageMessage(msg, media, receiveIDType); err != nil {
×
504
                                        logger.Error("Failed to send image message", zap.Error(err))
×
505
                                }
×
506
                        }
507
                }
508
        }
509

510
        // 如果有文本内容,发送卡片消息
511
        if msg.Content != "" {
×
512
                if err = c.sendCardMessage(msg, receiveIDType); err != nil {
×
513
                        logger.Error("Failed to send card message", zap.Error(err))
×
514
                }
×
515
        }
516

517
        // 清除 typing indicator(无论成功或失败)
518
        if msg.ReplyTo != "" {
×
519
                rmErr := c.removeTypingIndicator(msg.ReplyTo)
×
520
                if rmErr != nil {
×
521
                        logger.Debug("Failed to remove typing indicator (non-critical)", zap.Error(rmErr))
×
522
                }
×
523
        }
524

525
        return err
×
526
}
527

528
// downloadFeishuImage 从飞书下载图片,返回 io.ReadCloser
529
func (c *FeishuChannel) downloadFeishuImage(imageKey string) (io.ReadCloser, error) {
×
530
        req := larkim.NewGetImageReqBuilder().
×
531
                ImageKey(imageKey).
×
532
                Build()
×
533

×
534
        resp, err := c.httpClient.Im.Image.Get(context.Background(), req)
×
535
        if err != nil {
×
536
                return nil, fmt.Errorf("failed to get image: %w", err)
×
537
        }
×
538

539
        if !resp.Success() || resp.File == nil {
×
540
                return nil, fmt.Errorf("get image failed: code=%d msg=%s", resp.Code, resp.Msg)
×
541
        }
×
542

543
        // resp.File 是 io.Reader,读取所有数据
544
        data, err := io.ReadAll(resp.File)
×
545
        if err != nil {
×
546
                return nil, fmt.Errorf("failed to read image data: %w", err)
×
547
        }
×
548
        return io.NopCloser(bytes.NewReader(data)), nil
×
549
}
550

551
// uploadImage 上传图片到飞书,返回 image_key
552
func (c *FeishuChannel) uploadImage(imageData io.Reader) (string, error) {
×
553
        imageType := "message" // message 类型的图片可以用于发送消息
×
554
        req := larkim.NewCreateImageReqBuilder().
×
555
                Body(
×
556
                        larkim.NewCreateImageReqBodyBuilder().
×
557
                                ImageType(imageType).
×
558
                                Image(imageData).
×
559
                                Build(),
×
560
                ).
×
561
                Build()
×
562

×
563
        resp, err := c.httpClient.Im.Image.Create(context.Background(), req)
×
564
        if err != nil {
×
565
                return "", fmt.Errorf("failed to upload image: %w", err)
×
566
        }
×
567

568
        if !resp.Success() || resp.Data == nil {
×
569
                return "", fmt.Errorf("upload image failed: code=%d msg=%s", resp.Code, resp.Msg)
×
570
        }
×
571

572
        if resp.Data.ImageKey == nil {
×
573
                return "", fmt.Errorf("upload image response missing image_key")
×
574
        }
×
575

576
        logger.Debug("Image uploaded successfully", zap.String("image_key", *resp.Data.ImageKey))
×
577
        return *resp.Data.ImageKey, nil
×
578
}
579

580
// sendImageMessage 发送图片消息
581
func (c *FeishuChannel) sendImageMessage(msg *bus.OutboundMessage, media bus.Media, receiveIDType string) error {
×
582
        var imageReader io.Reader
×
583

×
584
        // 根据不同来源获取图片数据
×
585
        if media.URL != "" {
×
586
                var imageBody io.ReadCloser
×
587
                var err error
×
588

×
589
                // 检查是否是 Feishu 图片 (feishu:image_key 格式)
×
590
                if strings.HasPrefix(media.URL, "feishu:") {
×
591
                        imageKey := strings.TrimPrefix(media.URL, "feishu:")
×
592
                        imageBody, err = c.downloadFeishuImage(imageKey)
×
593
                        if err != nil {
×
594
                                return fmt.Errorf("failed to download feishu image: %w", err)
×
595
                        }
×
596
                } else {
×
597
                        // 从普通 URL 下载图片
×
598
                        req, err := http.NewRequest("GET", media.URL, nil)
×
599
                        if err != nil {
×
600
                                return fmt.Errorf("failed to create download request: %w", err)
×
601
                        }
×
602

603
                        resp, err := http.DefaultClient.Do(req)
×
604
                        if err != nil {
×
605
                                return fmt.Errorf("failed to download image from URL: %w", err)
×
606
                        }
×
607
                        if resp.StatusCode != http.StatusOK {
×
608
                                resp.Body.Close()
×
609
                                return fmt.Errorf("failed to download image, status: %d", resp.StatusCode)
×
610
                        }
×
611
                        imageBody = resp.Body
×
612
                }
613
                defer imageBody.Close()
×
614
                imageReader = imageBody
×
615

616
        } else if media.Base64 != "" {
×
617
                // 从 Base64 解码图片
×
618
                data, err := base64.StdEncoding.DecodeString(media.Base64)
×
619
                if err != nil {
×
620
                        return fmt.Errorf("failed to decode base64 image: %w", err)
×
621
                }
×
622
                imageReader = bytes.NewReader(data)
×
623
        } else {
×
624
                return fmt.Errorf("no valid image data (URL or Base64) provided")
×
625
        }
×
626

627
        // 上传图片获取 image_key
628
        imageKey, err := c.uploadImage(imageReader)
×
629
        if err != nil {
×
630
                return fmt.Errorf("failed to upload image: %w", err)
×
631
        }
×
632

633
        // 构建图片消息内容: {"image_key":"xxx"}
634
        content := fmt.Sprintf(`{"image_key":"%s"}`, imageKey)
×
635

×
636
        imageMsgReq := larkim.NewCreateMessageReqBuilder().
×
637
                ReceiveIdType(receiveIDType).
×
638
                Body(
×
639
                        larkim.NewCreateMessageReqBodyBuilder().
×
640
                                ReceiveId(msg.ChatID).
×
641
                                MsgType(larkim.MsgTypeImage).
×
642
                                Content(content).
×
643
                                Build(),
×
644
                ).
×
645
                Build()
×
646

×
647
        resp, err := c.httpClient.Im.Message.Create(context.Background(), imageMsgReq)
×
648
        if err != nil {
×
649
                logger.Error("Feishu send image message error", zap.Error(err), zap.String("chat_id", msg.ChatID), zap.String("image_key", imageKey))
×
650
                return err
×
651
        }
×
652

653
        if !resp.Success() {
×
654
                logger.Error("Feishu API error for image message",
×
655
                        zap.Int("code", int(resp.Code)),
×
656
                        zap.String("msg", resp.Msg),
×
657
                        zap.String("chat_id", msg.ChatID),
×
658
                        zap.String("image_key", imageKey),
×
659
                )
×
660
                return fmt.Errorf("feishu api error: %d %s", resp.Code, resp.Msg)
×
661
        }
×
662

663
        logger.Debug("Sent Feishu image message",
×
664
                zap.String("chat_id", msg.ChatID),
×
665
                zap.String("image_key", imageKey))
×
666

×
667
        return nil
×
668
}
669

670
// addTypingIndicator 添加 typing indicator(使用 "Typing" emoji reaction)
671
func (c *FeishuChannel) addTypingIndicator(messageID string) error {
×
672
        emojiType := "Typing"
×
673
        req := larkim.NewCreateMessageReactionReqBuilder().
×
674
                MessageId(messageID).
×
675
                Body(larkim.NewCreateMessageReactionReqBodyBuilder().
×
676
                        ReactionType(&larkim.Emoji{EmojiType: &emojiType}).
×
677
                        Build()).
×
678
                Build()
×
679

×
680
        resp, err := c.httpClient.Im.MessageReaction.Create(context.Background(), req)
×
681
        if err != nil {
×
682
                logger.Debug("Feishu add typing indicator error", zap.Error(err))
×
683
                return err
×
684
        }
×
685

686
        if !resp.Success() {
×
687
                logger.Debug("Feishu API error for typing indicator",
×
688
                        zap.Int("code", int(resp.Code)),
×
689
                        zap.String("msg", resp.Msg))
×
690
                return fmt.Errorf("feishu api error: %d %s", resp.Code, resp.Msg)
×
691
        }
×
692

693
        if resp.Data.ReactionId != nil {
×
694
                reactionID := *resp.Data.ReactionId
×
695
                c.typingReactionsMu.Lock()
×
696
                c.typingReactions[messageID] = reactionID
×
697
                c.typingReactionsMu.Unlock()
×
698
                logger.Debug("Added typing indicator",
×
699
                        zap.String("message_id", messageID),
×
700
                        zap.String("reaction_id", reactionID))
×
701
        }
×
702

703
        return nil
×
704
}
705

706
// removeTypingIndicator 移除 typing indicator
707
func (c *FeishuChannel) removeTypingIndicator(messageID string) error {
×
708
        c.typingReactionsMu.Lock()
×
709
        reactionID, ok := c.typingReactions[messageID]
×
710
        if !ok {
×
711
                c.typingReactionsMu.Unlock()
×
712
                return nil
×
713
        }
×
714
        delete(c.typingReactions, messageID)
×
715
        c.typingReactionsMu.Unlock()
×
716

×
717
        req := larkim.NewDeleteMessageReactionReqBuilder().
×
718
                MessageId(messageID).
×
719
                ReactionId(reactionID).
×
720
                Build()
×
721

×
722
        resp, err := c.httpClient.Im.MessageReaction.Delete(context.Background(), req)
×
723
        if err != nil {
×
724
                logger.Debug("Feishu remove typing indicator error", zap.Error(err))
×
725
                return err
×
726
        }
×
727

728
        if !resp.Success() {
×
729
                logger.Debug("Feishu API error for removing typing indicator",
×
730
                        zap.Int("code", int(resp.Code)),
×
731
                        zap.String("msg", resp.Msg))
×
732
                return fmt.Errorf("feishu api error: %d %s", resp.Code, resp.Msg)
×
733
        }
×
734

735
        logger.Debug("Removed typing indicator",
×
736
                zap.String("message_id", messageID),
×
737
                zap.String("reaction_id", reactionID))
×
738

×
739
        return nil
×
740
}
741

742
// sendCardMessage 发送卡片消息(使用 markdown 格式)
743
func (c *FeishuChannel) sendCardMessage(msg *bus.OutboundMessage, receiveIDType string) error {
×
744
        // 构建交互式卡片,使用 markdown 元素渲染内容
×
745
        // 使用 schema 2.0 格式以支持完整的 markdown 渲染(包括 heading 和 code fence)
×
746
        cardContent := fmt.Sprintf(`{
×
747
                "schema": "2.0",
×
748
                "config": {
×
749
                        "wide_screen_mode": true
×
750
                },
×
751
                "body": {
×
752
                        "elements": [
×
753
                                {
×
754
                                        "tag": "markdown",
×
755
                                        "content": %s
×
756
                                }
×
757
                        ]
×
758
                }
×
759
        }`, jsonEscape(msg.Content))
×
760

×
761
        req := larkim.NewCreateMessageReqBuilder().
×
762
                ReceiveIdType(receiveIDType).
×
763
                Body(larkim.NewCreateMessageReqBodyBuilder().
×
764
                        ReceiveId(msg.ChatID).
×
765
                        MsgType(larkim.MsgTypeInteractive).
×
766
                        Content(cardContent).
×
767
                        Build()).
×
768
                Build()
×
769

×
770
        resp, err := c.httpClient.Im.Message.Create(context.Background(), req)
×
771
        if err != nil {
×
772
                logger.Error("Feishu send message error", zap.Error(err), zap.String("chat_id", msg.ChatID))
×
773
                return err
×
774
        }
×
775

776
        if !resp.Success() {
×
777
                logger.Error("Feishu API error",
×
778
                        zap.Int("code", int(resp.Code)),
×
779
                        zap.String("msg", resp.Msg),
×
780
                        zap.String("chat_id", msg.ChatID),
×
781
                )
×
782
                return fmt.Errorf("feishu api error: %d %s", resp.Code, resp.Msg)
×
783
        }
×
784

785
        logger.Debug("Sent Feishu card message",
×
786
                zap.String("chat_id", msg.ChatID),
×
787
                zap.Int("content_length", len(msg.Content)))
×
788

×
789
        return nil
×
790
}
791

792
// jsonEscape 转义 JSON 字符串
793
func jsonEscape(s string) string {
×
794
        b, _ := json.Marshal(s)
×
795
        return string(b)
×
796
}
×
797

798
// Stop 停止飞书通道
799
func (c *FeishuChannel) Stop() error {
×
800
        logger.Info("Stopping Feishu channel")
×
801

×
802
        // WebSocket 客户端没有 explicit Stop 方法
×
803
        // 当 context 被 cancel 时,Start 方法会自动返回
×
804

×
805
        return c.BaseChannelImpl.Stop()
×
806
}
×
807

808
// Helper function
809
func getStringPtr(s *string) string {
×
810
        if s == nil {
×
811
                return ""
×
812
        }
×
813
        return *s
×
814
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc