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

smallnest / goclaw / 22356926405

24 Feb 2026 03:11PM UTC coverage: 4.3% (-0.004%) from 4.304%
22356926405

push

github

smallnest
#7 support feishu

0 of 152 new or added lines in 2 files covered. (0.0%)

6 existing lines in 1 file now uncovered.

1059 of 24626 relevant lines covered (4.3%)

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
NEW
41
        client := lark.NewClient(
×
NEW
42
                cfg.AppID,
×
NEW
43
                cfg.AppSecret,
×
NEW
44
                lark.WithAppType(larkcore.AppTypeSelfBuilt),
×
NEW
45
                lark.WithOpenBaseUrl(resolveDomain(cfg.Domain)),
×
NEW
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,
×
NEW
57
                domain:            cfg.Domain,
×
58
                encryptKey:        cfg.EncryptKey,
×
59
                verificationToken: cfg.VerificationToken,
×
NEW
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

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

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

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

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

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

×
95
        return nil
×
96
}
97

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

246
// Send 发送消息
247
func (c *FeishuChannel) Send(msg *bus.OutboundMessage) error {
×
NEW
248
        // 构建 content JSON 对象
×
249
        contentMap := map[string]string{"text": msg.Content}
×
250
        contentBytes, err := json.Marshal(contentMap)
×
251
        if err != nil {
×
252
                return fmt.Errorf("failed to marshal content: %w", err)
×
253
        }
×
254

255
        // 构建消息请求
NEW
256
        resp, err := c.httpClient.Im.Message.Create(context.Background(),
×
NEW
257
                larkim.NewCreateMessageReqBuilder().
×
NEW
258
                        ReceiveIdType(larkim.ReceiveIdTypeChatId).
×
NEW
259
                        Body(larkim.NewCreateMessageReqBodyBuilder().
×
NEW
260
                                ReceiveId(msg.ChatID).
×
NEW
261
                                MsgType(larkim.MsgTypeText).
×
NEW
262
                                Content(string(contentBytes)).
×
NEW
263
                                Build()).
×
NEW
264
                        Build())
×
265

×
266
        if err != nil {
×
NEW
267
                return fmt.Errorf("failed to create message request: %w", err)
×
268
        }
×
269

270
        if !resp.Success() {
×
271
                return fmt.Errorf("feishu api error: %d %s", resp.Code, resp.Msg)
×
272
        }
×
273

NEW
274
        logger.Debug("Sent Feishu message",
×
NEW
275
                zap.String("chat_id", msg.ChatID),
×
NEW
276
                zap.Int("content_length", len(msg.Content)))
×
NEW
277

×
UNCOV
278
        return nil
×
279
}
280

281
// Stop 停止飞书通道
NEW
282
func (c *FeishuChannel) Stop() error {
×
NEW
283
        logger.Info("Stopping Feishu channel")
×
NEW
284

×
NEW
285
        // WebSocket 客户端没有 explicit Stop 方法
×
NEW
286
        // 当 context 被 cancel 时,Start 方法会自动返回
×
NEW
287

×
NEW
288
        return c.BaseChannelImpl.Stop()
×
NEW
289
}
×
290

291
// Helper function
NEW
292
func getStringPtr(s *string) string {
×
NEW
293
        if s == nil {
×
NEW
294
                return ""
×
NEW
295
        }
×
NEW
296
        return *s
×
297
}
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