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

smallnest / goclaw / 22357794772

24 Feb 2026 03:32PM UTC coverage: 4.278% (-0.005%) from 4.283%
22357794772

push

github

smallnest
#7 merge pr from @qiangmzsx

0 of 42 new or added lines in 1 file covered. (0.0%)

2 existing lines in 1 file now uncovered.

1059 of 24752 relevant lines covered (4.28%)

0.32 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
        "context"
5
        "encoding/json"
6
        "fmt"
7
        "strconv"
8
        "time"
9

10
        "github.com/smallnest/goclaw/bus"
11
        "github.com/smallnest/goclaw/config"
12
        "github.com/smallnest/goclaw/internal/logger"
13
        lark "github.com/larksuite/oapi-sdk-go/v3"
14
        larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
15
        larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
16
        "github.com/larksuite/oapi-sdk-go/v3/event/dispatcher"
17
        larkws "github.com/larksuite/oapi-sdk-go/v3/ws"
18
        "go.uber.org/zap"
19
)
20

21
// FeishuChannel 飞书通道 - WebSocket 模式
22
type FeishuChannel struct {
23
        *BaseChannelImpl
24
        appID             string
25
        appSecret         string
26
        domain            string
27
        encryptKey        string
28
        verificationToken string
29
        wsClient          *larkws.Client
30
        eventDispatcher   *dispatcher.EventDispatcher
31
        httpClient        *lark.Client
32
}
33

34
// NewFeishuChannel 创建飞书通道
35
func NewFeishuChannel(cfg config.FeishuChannelConfig, bus *bus.MessageBus) (*FeishuChannel, error) {
×
36
        if cfg.AppID == "" || cfg.AppSecret == "" {
×
37
                return nil, fmt.Errorf("feishu app_id and app_secret are required")
×
38
        }
×
39

40
        // 创建 HTTP client for sending messages
41
        client := lark.NewClient(
×
42
                cfg.AppID,
×
43
                cfg.AppSecret,
×
44
                lark.WithAppType(larkcore.AppTypeSelfBuilt),
×
45
                lark.WithOpenBaseUrl(resolveDomain(cfg.Domain)),
×
46
        )
×
47

×
48
        baseCfg := BaseChannelConfig{
×
49
                Enabled:    cfg.Enabled,
×
50
                AllowedIDs: cfg.AllowedIDs,
×
51
        }
×
52

×
53
        return &FeishuChannel{
×
54
                BaseChannelImpl:   NewBaseChannelImpl("feishu", "default", baseCfg, bus),
×
55
                appID:             cfg.AppID,
×
56
                appSecret:         cfg.AppSecret,
×
57
                domain:            cfg.Domain,
×
58
                encryptKey:        cfg.EncryptKey,
×
59
                verificationToken: cfg.VerificationToken,
×
60
                httpClient:        client,
×
61
        }, nil
×
62
}
63

64
// Start 启动飞书通道
65
func (c *FeishuChannel) Start(ctx context.Context) error {
×
66
        if err := c.BaseChannelImpl.Start(ctx); err != nil {
×
67
                return err
×
68
        }
×
69

70
        logger.Info("Starting Feishu channel (WebSocket mode)",
×
71
                zap.String("app_id", c.appID),
×
72
                zap.String("domain", c.domain))
×
73

×
74
        // 创建事件分发器
×
75
        c.eventDispatcher = dispatcher.NewEventDispatcher(
×
76
                c.verificationToken,
×
77
                c.encryptKey,
×
78
        )
×
79

×
80
        // 注册事件处理器
×
81
        c.registerEventHandlers(ctx)
×
82

×
83
        // 创建 WebSocket 客户端
×
84
        c.wsClient = larkws.NewClient(
×
85
                c.appID,
×
86
                c.appSecret,
×
87
                larkws.WithEventHandler(c.eventDispatcher),
×
88
                larkws.WithDomain(resolveDomain(c.domain)),
×
89
                larkws.WithLogLevel(larkcore.LogLevelInfo),
×
90
        )
×
91

×
92
        // 启动 WebSocket 连接
×
93
        go c.startWebSocket(ctx)
×
94

×
95
        return nil
×
96
}
97

98
// resolveDomain 解析域名
99
func resolveDomain(domain string) string {
×
100
        if domain == "lark" {
×
101
                return lark.LarkBaseUrl
×
102
        }
×
103
        return lark.FeishuBaseUrl
×
104
}
105

106
// registerEventHandlers 注册事件处理器
107
func (c *FeishuChannel) registerEventHandlers(ctx context.Context) {
×
108
        // 处理接收消息事件
×
109
        c.eventDispatcher.OnP2MessageReceiveV1(func(ctx context.Context, event *larkim.P2MessageReceiveV1) error {
×
110
                c.handleMessageReceived(ctx, event)
×
111
                return nil
×
112
        })
×
113

114
        // 处理机器人被添加到群聊事件
115
        c.eventDispatcher.OnP2ChatMemberBotAddedV1(func(ctx context.Context, event *larkim.P2ChatMemberBotAddedV1) error {
×
116
                logger.Info("Feishu bot added to chat",
×
117
                        zap.String("chat_id", *event.Event.ChatId))
×
118
                return nil
×
119
        })
×
120

121
        // 处理机器人被移出群聊事件
122
        c.eventDispatcher.OnP2ChatMemberBotDeletedV1(func(ctx context.Context, event *larkim.P2ChatMemberBotDeletedV1) error {
×
123
                logger.Info("Feishu bot removed from chat",
×
124
                        zap.String("chat_id", *event.Event.ChatId))
×
125
                return nil
×
126
        })
×
127
}
128

129
// startWebSocket 启动 WebSocket 连接
130
func (c *FeishuChannel) startWebSocket(ctx context.Context) {
×
131
        logger.Info("Starting Feishu WebSocket connection")
×
132

×
133
        // Start blocks forever, so run it in the goroutine
×
134
        // The wsClient will handle reconnection automatically
×
135
        if err := c.wsClient.Start(ctx); err != nil {
×
136
                logger.Error("Feishu WebSocket error", zap.Error(err))
×
137
        }
×
138

139
        logger.Info("Feishu WebSocket connection stopped")
×
140
}
141

142
// handleMessageReceived 处理接收到的消息
143
func (c *FeishuChannel) handleMessageReceived(ctx context.Context, event *larkim.P2MessageReceiveV1) {
×
144
        if event.Event == nil || event.Event.Sender == nil || event.Event.Message == nil {
×
145
                logger.Debug("Feishu message event has nil fields")
×
146
                return
×
147
        }
×
148

149
        senderID := ""
×
150
        if event.Event.Sender.SenderId != nil {
×
151
                if event.Event.Sender.SenderId.OpenId != nil {
×
152
                        senderID = *event.Event.Sender.SenderId.OpenId
×
153
                } else if event.Event.Sender.SenderId.UserId != nil {
×
154
                        senderID = *event.Event.Sender.SenderId.UserId
×
155
                }
×
156
        }
157

158
        chatID := ""
×
159
        if event.Event.Message.ChatId != nil {
×
160
                chatID = *event.Event.Message.ChatId
×
161
        }
×
162

163
        messageID := ""
×
164
        if event.Event.Message.MessageId != nil {
×
165
                messageID = *event.Event.Message.MessageId
×
166
        }
×
167

168
        logger.Debug("Feishu message received",
×
169
                zap.String("chat_id", chatID),
×
170
                zap.String("message_id", messageID),
×
171
                zap.String("sender_id", senderID))
×
172

×
173
        // 检查发送者权限
×
174
        if senderID != "" && !c.IsAllowed(senderID) {
×
175
                logger.Debug("Feishu message filtered (not allowed)",
×
176
                        zap.String("sender_id", senderID))
×
177
                return
×
178
        }
×
179

180
        // 解析消息内容
181
        content := c.extractMessageContent(event.Event.Message)
×
182
        if content == "" {
×
183
                logger.Debug("Feishu message has no extractable text content")
×
184
                return
×
185
        }
×
186

187
        // 解析时间戳
188
        var timestamp time.Time
×
189
        if event.Event.Message.CreateTime != nil {
×
190
                if ms, err := strconv.ParseInt(*event.Event.Message.CreateTime, 10, 64); err == nil {
×
191
                        timestamp = time.UnixMilli(ms)
×
192
                } else {
×
193
                        timestamp = time.Now()
×
194
                }
×
195
        } else {
×
196
                timestamp = time.Now()
×
197
        }
×
198

199
        // 发布到消息总线
200
        inbound := &bus.InboundMessage{
×
201
                ID:        messageID,
×
202
                Content:   content,
×
203
                SenderID:  senderID,
×
204
                ChatID:    chatID,
×
205
                Channel:   c.Name(),
×
206
                AccountID: "default",
×
207
                Timestamp: timestamp,
×
208
                Metadata: map[string]interface{}{
×
209
                        "msg_type": getStringPtr(event.Event.Message.MessageType),
×
210
                },
×
211
        }
×
212

×
213
        if err := c.PublishInbound(ctx, inbound); err != nil {
×
214
                logger.Error("Failed to publish inbound message",
×
215
                        zap.String("message_id", messageID),
×
216
                        zap.Error(err))
×
217
                return
×
218
        }
×
219

220
        logger.Debug("Processed Feishu message",
×
221
                zap.String("message_id", messageID),
×
222
                zap.String("chat_id", chatID),
×
223
                zap.String("sender_id", senderID))
×
224
}
225

226
// extractMessageContent 从消息中提取文本内容
227
func (c *FeishuChannel) extractMessageContent(msg *larkim.EventMessage) string {
×
228
        if msg.MessageType == nil || *msg.MessageType != "text" {
×
229
                return ""
×
230
        }
×
231

232
        if msg.Content == nil {
×
233
                return ""
×
234
        }
×
235

236
        // 解析 content JSON
237
        var content map[string]string
×
238
        if err := json.Unmarshal([]byte(*msg.Content), &content); err != nil {
×
239
                logger.Error("Failed to parse message content", zap.Error(err))
×
240
                return ""
×
241
        }
×
242

243
        return content["text"]
×
244
}
245

246
// Send 发送消息
247
func (c *FeishuChannel) Send(msg *bus.OutboundMessage) error {
×
NEW
248
        logger.Info("Feishu sending message",
×
NEW
249
                zap.String("chat_id", msg.ChatID),
×
NEW
250
                zap.String("content", msg.Content))
×
NEW
251

×
NEW
252
        // 判断接收者类型
×
NEW
253
        receiveIDType := larkim.ReceiveIdTypeChatId
×
NEW
254
        if len(msg.ChatID) > 3 && msg.ChatID[:3] == "ou_" {
×
NEW
255
                receiveIDType = larkim.ReceiveIdTypeOpenId
×
UNCOV
256
        }
×
257

NEW
258
        return c.sendCardMessage(msg, receiveIDType)
×
259
}
260

261
// sendCardMessage 发送卡片消息(使用 markdown 格式)
NEW
262
func (c *FeishuChannel) sendCardMessage(msg *bus.OutboundMessage, receiveIDType string) error {
×
NEW
263
        // 构建交互式卡片,使用 markdown 元素渲染内容
×
NEW
264
        cardContent := fmt.Sprintf(`{
×
NEW
265
                "elements": [
×
NEW
266
                        {
×
NEW
267
                                "tag": "markdown",
×
NEW
268
                                "content": %s
×
NEW
269
                        }
×
NEW
270
                ]
×
NEW
271
        }`, jsonEscape(msg.Content))
×
NEW
272

×
NEW
273
        req := larkim.NewCreateMessageReqBuilder().
×
NEW
274
                ReceiveIdType(receiveIDType).
×
NEW
275
                Body(larkim.NewCreateMessageReqBodyBuilder().
×
NEW
276
                        ReceiveId(msg.ChatID).
×
NEW
277
                        MsgType(larkim.MsgTypeInteractive).
×
NEW
278
                        Content(cardContent).
×
NEW
279
                        Build()).
×
NEW
280
                Build()
×
NEW
281

×
NEW
282
        resp, err := c.httpClient.Im.Message.Create(context.Background(), req)
×
283
        if err != nil {
×
NEW
284
                logger.Error("Feishu send message error", zap.Error(err), zap.String("chat_id", msg.ChatID))
×
NEW
285
                return err
×
UNCOV
286
        }
×
287

288
        if !resp.Success() {
×
NEW
289
                logger.Error("Feishu API error",
×
NEW
290
                        zap.Int("code", int(resp.Code)),
×
NEW
291
                        zap.String("msg", resp.Msg),
×
NEW
292
                        zap.String("chat_id", msg.ChatID),
×
NEW
293
                )
×
294
                return fmt.Errorf("feishu api error: %d %s", resp.Code, resp.Msg)
×
295
        }
×
296

NEW
297
        logger.Debug("Sent Feishu card message",
×
298
                zap.String("chat_id", msg.ChatID),
×
299
                zap.Int("content_length", len(msg.Content)))
×
300

×
301
        return nil
×
302
}
303

304
// jsonEscape 转义 JSON 字符串
NEW
305
func jsonEscape(s string) string {
×
NEW
306
        b, _ := json.Marshal(s)
×
NEW
307
        return string(b)
×
NEW
308
}
×
309

310
// Stop 停止飞书通道
311
func (c *FeishuChannel) Stop() error {
×
312
        logger.Info("Stopping Feishu channel")
×
313

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

×
317
        return c.BaseChannelImpl.Stop()
×
318
}
×
319

320
// Helper function
321
func getStringPtr(s *string) string {
×
322
        if s == nil {
×
323
                return ""
×
324
        }
×
325
        return *s
×
326
}
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