返回 DeepSeek-Reasonix
dingtalk.go
根目录 / internal / bot / dingtalk / dingtalk.go
1 // Package dingtalk 实现钉钉企业内部应用机器人(Stream 模式)适配器。
2 // 参考 dsh-dingtalk-channel 的设计:
3 // - Stream 模式(WebSocket 长连接),无需公网回调地址
4 // - @mention gating(群聊)
5 // - 消息去重(msgId)
6 // - 会话 webhook 回复(markdown/text)
7 package dingtalk
8
9 import (
10 "bytes"
11 "context"
12 "crypto/sha256"
13 "encoding/hex"
14 "encoding/json"
15 "fmt"
16 "io"
17 "log/slog"
18 "net/http"
19 "net/url"
20 "os"
21 "strings"
22 "sync"
23 "time"
24
25 "reasonix/internal/bot"
26 "reasonix/internal/config"
27
28 "github.com/gorilla/websocket"
29 )
30
31 const (
32 // getTokenURL 获取应用 access_token(旧版接口,GET query 传参)。
33 getTokenURL = "https://oapi.dingtalk.com/gettoken"
34 // gatewayURL 换取 WebSocket 连接端点。
35 gatewayURL = "https://api.dingtalk.com/v1.0/gateway/connections/open"
36 // robotMessagesTopic 机器人消息推送 topic。
37 robotMessagesTopic = "/v1.0/im/bot/messages/get"
38
39 // tokenTTL 钉钉 access_token 有效期(秒),提前 60s 刷新。
40 tokenTTL = 7200 * time.Second
41 // connTimeout 建连/HTTP 超时。
42 connTimeout = 15 * time.Second
43 // sendTimeout 单条回复超时。
44 sendTimeout = 15 * time.Second
45 // maxMessageBytes 入站消息上限。
46 maxMessageBytes = 1 << 20
47 // thinkingEmotion 收到消息时贴的表情("已收到,处理中"信号,回复完成后撤回)。
48 // 与 dsh-dingtalk-channel transport.ts 的 THINKING_EMOTION 一致。
49 thinkingEmotion = "🤔思考中"
50 )
51
52 // emotionURL 贴/撤机器人消息表情的基址(reply=贴,recall=撤)。包级变量
53 // 以便测试覆盖为 httptest 桩地址。
54 var emotionURL = "https://api.dingtalk.com/v1.0/robot/emotion"
55
56 // adapter 钉钉适配器实现。
57 type adapter struct {
58 cfg config.DingtalkBotConfig
59 logger *slog.Logger
60 msgCh chan bot.InboundMessage
61 cancel context.CancelFunc
62
63 // httpClient 复用连接,避免每消息重建。
64 httpClient *http.Client
65 // webhookHosts 与 allowHTTPWebhook 属于 adapter,避免测试修改包级安全策略。
66 webhookHosts []string
67 allowHTTPWebhook bool
68 // tokenMu 保护 token 缓存。
69 tokenMu sync.Mutex
70 token string
71 tokenAt time.Time
72
73 // seenMu 保护消息去重。
74 seenMu sync.Mutex
75 seen map[string]bool
76
77 // conn 当前 WebSocket 连接。
78 connMu sync.Mutex
79 conn *websocket.Conn
80
81 // webhookMu 保护 chatID→webhook 映射(钉钉无全局发送 API,回复必须
82 // POST 到会话 webhook;映射从入站消息学习,DSH 的 sessionWebhooks 模式)。
83 webhookMu sync.Mutex
84 webhooks map[string]string
85 // lastChatID 最近一次学到 webhook 的会话,供桌面端测试发送使用。
86 lastChatID string
87
88 // msgChats 记录 messageID→chatID,供 AddPendingReaction 贴/撤表情时
89 // 还原会话(gateway 的 reaction 接口只传 messageID)。
90 msgChatsMu sync.Mutex
91 msgChats map[string]string
92 }
93
94 // robotMessage 钉钉 Stream 模式机器人消息载荷(/v1.0/im/bot/messages/get)。
95 type robotMessage struct {
96 SenderStaffID string `json:"senderStaffId"`
97 SenderNick string `json:"senderNick"`
98 ConversationID string `json:"conversationId"`
99 ConversationType string `json:"conversationType"` // "1"=单聊 "2"=群聊
100 MsgID string `json:"msgId"`
101 MsgType string `json:"msgtype"`
102 Text *robotTextContent `json:"text"`
103 SessionWebhook string `json:"sessionWebhook"`
104 // IsInAtList 官方回调的结构化 @ 标记:true 表示本消息 @ 了机器人。
105 // 正文示例不保留前导 @token,须以该字段为准(见 dingtalk 开放平台文档)。
106 IsInAtList bool `json:"isInAtList"`
107 }
108
109 // robotTextContent 钉钉文本消息内容。
110 type robotTextContent struct {
111 Content string `json:"content"`
112 }
113
114 // New 创建钉钉适配器。
115 func New(cfg config.DingtalkBotConfig, logger *slog.Logger) bot.Adapter {
116 return &adapter{
117 cfg: cfg,
118 logger: logger.With("platform", "dingtalk"),
119 seen: make(map[string]bool),
120 webhooks: make(map[string]string),
121 msgChats: make(map[string]string),
122 httpClient: &http.Client{Timeout: connTimeout},
123 webhookHosts: append([]string(nil), dingtalkWebhookHosts...),
124 }
125 }
126
127 func (a *adapter) Platform() bot.Platform { return bot.PlatformDingtalk }
128 func (a *adapter) Name() string { return "dingtalk" }
129
130 func (a *adapter) Start(ctx context.Context) error {
131 a.msgCh = make(chan bot.InboundMessage, 64)
132 ctx, cancel := context.WithCancel(ctx)
133 // 同步校验凭据与连接,失败直接报错而非标记 running 后由重连循环静默失败。
134 checkCtx, checkCancel := context.WithTimeout(ctx, connTimeout)
135 defer checkCancel()
136 if id := a.clientID(); strings.TrimSpace(id) == "" {
137 cancel()
138 return fmt.Errorf("dingtalk client_id is not configured")
139 }
140 if secret := a.clientSecret(); strings.TrimSpace(secret) == "" {
141 cancel()
142 return fmt.Errorf("dingtalk client_secret is not configured")
143 }
144 if _, err := a.accessToken(checkCtx); err != nil {
145 cancel()
146 return fmt.Errorf("dingtalk credentials rejected: %w", err)
147 }
148 conn, err := a.dialConnection(checkCtx)
149 if err != nil {
150 cancel()
151 return fmt.Errorf("dingtalk connection failed: %w", err)
152 }
153 a.cancel = cancel
154 a.setConn(conn)
155 go a.runWithRetry(ctx, conn)
156 return nil
157 }
158
159 func (a *adapter) Stop() error {
160 if a.cancel != nil {
161 a.cancel()
162 }
163 a.closeConn()
164 return nil
165 }
166
167 func (a *adapter) Send(ctx context.Context, msg bot.OutboundMessage) (bot.SendResult, error) {
168 return a.sendMessage(ctx, msg)
169 }
170
171 func (a *adapter) SendTyping(ctx context.Context, chatID string) error {
172 return nil
173 }
174
175 func (a *adapter) Messages() <-chan bot.InboundMessage {
176 return a.msgCh
177 }
178
179 func (a *adapter) closeConn() {
180 a.connMu.Lock()
181 defer a.connMu.Unlock()
182 if a.conn != nil {
183 _ = a.conn.Close()
184 a.conn = nil
185 }
186 }
187
188 func (a *adapter) setConn(conn *websocket.Conn) {
189 a.connMu.Lock()
190 a.conn = conn
191 a.connMu.Unlock()
192 }
193
194 func (a *adapter) releaseConn(conn *websocket.Conn) {
195 a.connMu.Lock()
196 if a.conn == conn {
197 a.conn = nil
198 }
199 a.connMu.Unlock()
200 _ = conn.Close()
201 }
202
203 // clientID 返回配置或环境变量中的 AppKey。
204 func (a *adapter) clientID() string {
205 if v := strings.TrimSpace(a.cfg.ClientID); v != "" {
206 return v
207 }
208 return os.Getenv(a.cfg.ClientIDEnv)
209 }
210
211 // clientSecret 返回配置或环境变量中的 AppSecret。
212 func (a *adapter) clientSecret() string {
213 if v := strings.TrimSpace(a.cfg.ClientSecret); v != "" {
214 return v
215 }
216 return os.Getenv(a.cfg.SecretEnv)
217 }
218
219 // accessToken 获取并缓存钉钉应用 access_token。
220 func (a *adapter) accessToken(ctx context.Context) (string, error) {
221 a.tokenMu.Lock()
222 defer a.tokenMu.Unlock()
223 if a.token != "" && time.Since(a.tokenAt) < tokenTTL-60*time.Second {
224 return a.token, nil
225 }
226 id := a.clientID()
227 secret := a.clientSecret()
228 if id == "" || secret == "" {
229 return "", fmt.Errorf("dingtalk client_id or client_secret is not configured")
230 }
231 req, err := http.NewRequestWithContext(ctx, http.MethodGet,
232 fmt.Sprintf("%s?appkey=%s&appsecret=%s", getTokenURL, url.QueryEscape(id), url.QueryEscape(secret)), nil)
233 if err != nil {
234 return "", err
235 }
236 resp, err := a.httpClient.Do(req)
237 if err != nil {
238 return "", err
239 }
240 defer resp.Body.Close()
241 var body struct {
242 AccessToken string `json:"access_token"`
243 ErrCode int `json:"errcode"`
244 ErrMsg string `json:"errmsg"`
245 }
246 if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&body); err != nil {
247 return "", err
248 }
249 if resp.StatusCode != http.StatusOK || body.AccessToken == "" {
250 return "", fmt.Errorf("dingtalk gettoken failed: code=%d msg=%s", body.ErrCode, body.ErrMsg)
251 }
252 a.token = body.AccessToken
253 a.tokenAt = time.Now()
254 return a.token, nil
255 }
256
257 // gatewayEndpoint 换取 WebSocket 连接端点与 ticket。
258 type gatewayEndpoint struct {
259 Endpoint string `json:"endpoint"`
260 Ticket string `json:"ticket"`
261 }
262
263 // openConnection 向网关换取 WebSocket 地址。
264 func (a *adapter) openConnection(ctx context.Context) (string, error) {
265 payload := map[string]any{
266 "clientId": a.clientID(),
267 "clientSecret": a.clientSecret(),
268 "ua": "reasonix",
269 "subscriptions": []map[string]string{
270 {"type": "CALLBACK", "topic": robotMessagesTopic},
271 },
272 }
273 raw, err := json.Marshal(payload)
274 if err != nil {
275 return "", err
276 }
277 req, err := http.NewRequestWithContext(ctx, http.MethodPost, gatewayURL, bytes.NewReader(raw))
278 if err != nil {
279 return "", err
280 }
281 req.Header.Set("Content-Type", "application/json")
282 req.Header.Set("Accept", "application/json")
283 resp, err := a.httpClient.Do(req)
284 if err != nil {
285 return "", err
286 }
287 defer resp.Body.Close()
288 body, err := io.ReadAll(io.LimitReader(resp.Body, maxMessageBytes))
289 if err != nil {
290 return "", err
291 }
292 var ep gatewayEndpoint
293 if err := json.Unmarshal(body, &ep); err != nil {
294 return "", fmt.Errorf("dingtalk open connection: bad response: %s", truncate(string(body), 200))
295 }
296 if ep.Endpoint == "" || ep.Ticket == "" {
297 return "", fmt.Errorf("dingtalk open connection: endpoint/ticket empty: %s", truncate(string(body), 200))
298 }
299 return ep.Endpoint + "?ticket=" + url.QueryEscape(ep.Ticket), nil
300 }
301
302 // dialConnection 换取一次连接地址并完成 WebSocket 握手。
303 func (a *adapter) dialConnection(ctx context.Context) (*websocket.Conn, error) {
304 wsURL, err := a.openConnection(ctx)
305 if err != nil {
306 return nil, err
307 }
308 dialer := websocket.Dialer{HandshakeTimeout: connTimeout}
309 conn, _, err := dialer.DialContext(ctx, wsURL, nil)
310 return conn, err
311 }
312
313 // runWithRetry 先处理 Start 已握手的连接,断线后带退避重连。
314 func (a *adapter) runWithRetry(ctx context.Context, conn *websocket.Conn) {
315 backoff := time.Second
316 for {
317 if conn == nil {
318 var err error
319 conn, err = a.dialConnection(ctx)
320 if err != nil {
321 if ctx.Err() != nil {
322 return
323 }
324 a.logger.Warn("dingtalk connection failed; reconnecting", "err", err, "backoff", backoff)
325 if !waitForRetry(ctx, backoff) {
326 return
327 }
328 if backoff < 30*time.Second {
329 backoff *= 2
330 }
331 continue
332 }
333 if ctx.Err() != nil {
334 _ = conn.Close()
335 return
336 }
337 a.setConn(conn)
338 backoff = time.Second
339 }
340 if err := a.serveConnection(ctx, conn); err != nil && ctx.Err() == nil {
341 a.logger.Warn("dingtalk connection closed; reconnecting", "err", err, "backoff", backoff)
342 }
343 conn = nil
344 if ctx.Err() != nil || !waitForRetry(ctx, backoff) {
345 return
346 }
347 if backoff < 30*time.Second {
348 backoff *= 2
349 }
350 }
351 }
352
353 func waitForRetry(ctx context.Context, delay time.Duration) bool {
354 timer := time.NewTimer(delay)
355 defer timer.Stop()
356 select {
357 case <-ctx.Done():
358 return false
359 case <-timer.C:
360 return true
361 }
362 }
363
364 // serveConnection 处理一条已握手连接,直到断开或 ctx 取消。
365 func (a *adapter) serveConnection(ctx context.Context, conn *websocket.Conn) error {
366 defer a.releaseConn(conn)
367 a.logger.Info("dingtalk stream connected")
368
369 // 读消息循环(处理 SYSTEM/CALLBACK)。
370 readErr := make(chan error, 1)
371 go func() {
372 for {
373 if _, data, err := conn.ReadMessage(); err != nil {
374 readErr <- err
375 return
376 } else {
377 a.handleDownstream(ctx, conn, data)
378 }
379 }
380 }()
381 select {
382 case <-ctx.Done():
383 return nil
384 case err := <-readErr:
385 return err
386 }
387 }
388
389 // downstreamFrame 服务端推送帧。
390 type downstreamFrame struct {
391 Type string `json:"type"` // SYSTEM | CALLBACK | EVENT
392 Headers struct {
393 Topic string `json:"topic"`
394 MessageID string `json:"messageId"`
395 } `json:"headers"`
396 Data json.RawMessage `json:"data"`
397 }
398
399 // handleDownstream 处理一帧服务端消息。
400 func (a *adapter) handleDownstream(ctx context.Context, conn *websocket.Conn, data []byte) {
401 var frame downstreamFrame
402 if err := json.Unmarshal(data, &frame); err != nil {
403 a.logger.Warn("dingtalk bad frame", "err", err)
404 return
405 }
406 switch frame.Type {
407 case "SYSTEM":
408 a.handleSystem(conn, frame)
409 case "CALLBACK":
410 a.handleCallback(ctx, conn, frame)
411 }
412 }
413
414 // handleSystem 处理 SYSTEM 帧(ping 心跳回执、REGISTERED 等)。
415 func (a *adapter) handleSystem(conn *websocket.Conn, frame downstreamFrame) {
416 switch frame.Headers.Topic {
417 case "ping":
418 // 服务端 ping 需原样回包。
419 reply, _ := json.Marshal(map[string]any{
420 "code": 200,
421 "headers": frame.Headers,
422 "message": "OK",
423 "data": frame.Data,
424 })
425 _ = conn.WriteMessage(websocket.TextMessage, reply)
426 case "disconnect":
427 a.logger.Warn("dingtalk server requested disconnect")
428 _ = conn.Close()
429 }
430 }
431
432 // handleCallback 处理机器人消息回调:归一化、去重、入队。
433 func (a *adapter) handleCallback(ctx context.Context, conn *websocket.Conn, frame downstreamFrame) {
434 // 立即回执,避免服务端 60s 后重试。
435 ack, _ := json.Marshal(map[string]any{
436 "code": 200,
437 "headers": map[string]string{
438 "contentType": "application/json",
439 "messageId": frame.Headers.MessageID,
440 },
441 "message": "OK",
442 "data": `{"response":{"status":"SUCCESS"}}`,
443 })
444 _ = conn.WriteMessage(websocket.TextMessage, ack)
445
446 if frame.Headers.Topic != robotMessagesTopic {
447 return
448 }
449 raw, ok := decodeRobotMessage(frame.Data)
450 if !ok {
451 a.logger.Warn("dingtalk bad robot message", "err", "cannot decode payload")
452 return
453 }
454 msg := a.normalizeMessage(raw)
455 if msg == nil {
456 return
457 }
458 select {
459 case a.msgCh <- *msg:
460 case <-ctx.Done():
461 }
462 }
463
464 // decodeRobotMessage 解码 Stream 回调的机器人消息载荷。钉钉回调的 data 是
465 // JSON 编码的字符串:先解码出字符串再解析为消息对象(与 dsh-dingtalk-channel
466 // transport.ts 的 JSON.parse(res.data) 一致);个别网关版本直接下发对象,
467 // 兼容之。
468 func decodeRobotMessage(data json.RawMessage) (robotMessage, bool) {
469 var raw robotMessage
470 var rawStr string
471 if err := json.Unmarshal(data, &rawStr); err == nil {
472 if err := json.Unmarshal([]byte(rawStr), &raw); err != nil {
473 return robotMessage{}, false
474 }
475 return raw, true
476 }
477 if err := json.Unmarshal(data, &raw); err != nil {
478 return robotMessage{}, false
479 }
480 return raw, true
481 }
482
483 // normalizeMessage 归一化钉钉机器人消息;不满足门控时返回 nil。
484 func (a *adapter) normalizeMessage(raw robotMessage) *bot.InboundMessage {
485 messageID := strings.TrimSpace(raw.MsgID)
486 chatID := strings.TrimSpace(raw.ConversationID)
487 userID := strings.TrimSpace(raw.SenderStaffID)
488 userName := strings.TrimSpace(raw.SenderNick)
489 if userName == "" {
490 userName = userID
491 }
492 if messageID == "" || chatID == "" {
493 return nil
494 }
495 if a.markSeen(messageID) {
496 return nil
497 }
498 isGroup := strings.TrimSpace(raw.ConversationType) == "2"
499 text := ""
500 if raw.Text != nil {
501 text = strings.TrimSpace(raw.Text.Content)
502 }
503 // 群聊 @ 判断:以官方回调的结构化 isInAtList 为准(正文示例不保留
504 // 前导 @token);仅当回调未标记被 @ 时,才回退到文本前导 @ 解析。
505 if isGroup {
506 if a.cfg.RequireMention {
507 mentioned := raw.IsInAtList
508 if !mentioned {
509 mentioned, text = a.splitMention(text)
510 } else {
511 // 结构化标记已确认被 @,仅剥离可能残留的前导 @token。
512 text = a.stripGroupMention(text)
513 }
514 if !mentioned {
515 a.logger.Info("dingtalk message ignored", "reason", "missing_mention", "chat", logHash(chatID), "message", logHash(messageID))
516 return nil
517 }
518 } else {
519 text = a.stripGroupMention(text)
520 }
521 }
522 chatType := bot.ChatDM
523 if isGroup {
524 chatType = bot.ChatGroup
525 }
526 webhook := strings.TrimSpace(raw.SessionWebhook)
527 if webhook != "" && a.validDingtalkWebhook(webhook) {
528 // 记录会话 webhook,供后续回复查表使用;同时记录最近会话供测试发送。
529 // 仅记录钉钉官方域名的 webhook,恶意/伪造回调无法注入任意回复目标。
530 a.webhookMu.Lock()
531 a.webhooks[chatID] = webhook
532 a.lastChatID = chatID
533 a.webhookMu.Unlock()
534 } else if webhook != "" {
535 // 非白名单 webhook 不记录、不透传,防止伪造回调注入任意回复目标。
536 a.logger.Warn("dingtalk ignored non-dingtalk session webhook", "chat", logHash(chatID))
537 webhook = ""
538 }
539 // 记录 messageID→chatID,供 AddPendingReaction 贴/撤表情时还原会话。
540 a.msgChatsMu.Lock()
541 a.msgChats[messageID] = chatID
542 if len(a.msgChats) > 10000 {
543 a.msgChats = make(map[string]string)
544 a.msgChats[messageID] = chatID
545 }
546 a.msgChatsMu.Unlock()
547 return &bot.InboundMessage{
548 Platform: bot.PlatformDingtalk,
549 ChatType: chatType,
550 ChatID: chatID,
551 UserID: userID,
552 UserName: userName,
553 Text: text,
554 MessageID: messageID,
555 SessionWebhook: webhook,
556 }
557 }
558
559 // webhookFor 返回某会话最新的回复 webhook;无记录时返回空串。
560 func (a *adapter) webhookFor(chatID string) string {
561 a.webhookMu.Lock()
562 defer a.webhookMu.Unlock()
563 return a.webhooks[strings.TrimSpace(chatID)]
564 }
565
566 // chatForMessageID 返回消息所属会话;无记录时返回空串。
567 func (a *adapter) chatForMessageID(messageID string) string {
568 a.msgChatsMu.Lock()
569 defer a.msgChatsMu.Unlock()
570 return a.msgChats[strings.TrimSpace(messageID)]
571 }
572
573 // AddPendingReaction 在收到的消息上贴 🤔思考中 表情("已收到,处理中"),
574 // 返回的 cleanup 在回合结束后撤回表情。实现 gateway 的 pendingReactionAdapter
575 // 接口(DSH dsh-dingtalk-channel 的 addEmotion/recallEmotion)。
576 func (a *adapter) AddPendingReaction(ctx context.Context, messageID string) (func(), error) {
577 messageID = strings.TrimSpace(messageID)
578 chatID := a.chatForMessageID(messageID)
579 if chatID == "" {
580 return nil, fmt.Errorf("dingtalk emotion: unknown chat for message %s", logHash(messageID))
581 }
582 if err := a.setEmotion(ctx, chatID, messageID, "reply"); err != nil {
583 return nil, err
584 }
585 return func() {
586 // 撤回失败仅记录,不阻塞回合收尾。
587 recallCtx, cancel := context.WithTimeout(context.Background(), sendTimeout)
588 defer cancel()
589 if err := a.setEmotion(recallCtx, chatID, messageID, "recall"); err != nil {
590 a.logger.Warn("dingtalk recall emotion failed", "chat", logHash(chatID), "message", logHash(messageID), "err", err)
591 }
592 }, nil
593 }
594
595 // emotionBody 构造贴/撤表情的请求体。robotCode 即应用 AppKey(client id):
596 // 钉钉将机器人并入应用,client id 就是机器人 code(与 dsh transport.ts 一致)。
597 func emotionBody(robotCode, chatID, messageID, action string) map[string]any {
598 body := map[string]any{
599 "robotCode": robotCode,
600 "openMsgId": messageID,
601 "openConversationId": chatID,
602 "emotionType": 2,
603 "emotionName": thinkingEmotion,
604 }
605 if action == "reply" {
606 body["textEmotion"] = map[string]any{
607 "emotionId": "2659900",
608 "emotionName": thinkingEmotion,
609 "text": thinkingEmotion,
610 "backgroundId": "im_bg_1",
611 }
612 }
613 return body
614 }
615
616 // setEmotion 调钉钉表情接口:action 为 reply(贴)或 recall(撤)。
617 func (a *adapter) setEmotion(ctx context.Context, chatID, messageID, action string) error {
618 robotCode := a.clientID()
619 if robotCode == "" {
620 return fmt.Errorf("dingtalk emotion: client_id is not configured")
621 }
622 token, err := a.accessToken(ctx)
623 if err != nil {
624 return err
625 }
626 payload, err := json.Marshal(emotionBody(robotCode, chatID, messageID, action))
627 if err != nil {
628 return err
629 }
630 ctx, cancel := context.WithTimeout(ctx, sendTimeout)
631 defer cancel()
632 req, err := http.NewRequestWithContext(ctx, http.MethodPost,
633 emotionURL+"/"+action, bytes.NewReader(payload))
634 if err != nil {
635 return err
636 }
637 req.Header.Set("Content-Type", "application/json")
638 req.Header.Set("x-acs-dingtalk-access-token", token)
639 resp, err := a.httpClient.Do(req)
640 if err != nil {
641 return err
642 }
643 defer resp.Body.Close()
644 body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
645 if resp.StatusCode != http.StatusOK {
646 return fmt.Errorf("dingtalk emotion %s failed (%d): %s", action, resp.StatusCode, truncate(string(body), 300))
647 }
648 return nil
649 }
650
651 // stripGroupMention 剥离开头的 @xxx 词元。
652 func (a *adapter) stripGroupMention(text string) string {
653 mentionsBot, rest := a.splitMention(text)
654 if mentionsBot {
655 return rest
656 }
657 return text
658 }
659
660 // splitMention 检查文本是否以 @机器人 开头;是则返回 (true, 剥离后的文本)。
661 func (a *adapter) splitMention(text string) (bool, string) {
662 text = strings.TrimSpace(text)
663 if !strings.HasPrefix(text, "@") {
664 return false, text
665 }
666 rest := text[1:]
667 end := strings.IndexAny(rest, " \t\n")
668 name := rest
669 if end >= 0 {
670 name = rest[:end]
671 }
672 botName := strings.TrimSpace(a.cfg.BotName)
673 if botName == "" {
674 // 未配置昵称:任意 @ 开头都视为 @ 机器人(与飞书旧行为一致)。
675 if end < 0 {
676 return true, ""
677 }
678 return true, strings.TrimSpace(rest[end:])
679 }
680 if name != botName {
681 return false, text
682 }
683 if end < 0 {
684 return true, ""
685 }
686 return true, strings.TrimSpace(rest[end:])
687 }
688
689 // markSeen 消息去重;返回 true 表示已见过。
690 func (a *adapter) markSeen(messageID string) bool {
691 a.seenMu.Lock()
692 defer a.seenMu.Unlock()
693 if a.seen[messageID] {
694 return true
695 }
696 if len(a.seen) > 10000 {
697 a.seen = make(map[string]bool)
698 }
699 a.seen[messageID] = true
700 return false
701 }
702
703 // sendMessage 发送出站消息(文本或 markdown)。
704 // 钉钉无全局发送 API:回复必须 POST 到会话 webhook。webhook 来源:
705 // 1) msg.SessionWebhook(入站消息透传,含持久化恢复场景)
706 // 2) chatID→webhook 映射表(入站消息学习而来)
707 // 两者皆无时拒绝并给出可读错误。
708 // 注意:gateway 的 sendText 会把 ReplyToMsgID 填成入站消息 ID(非 URL),
709 // 这里完全不把 ReplyToMsgID 当 URL 使用——回复目标只来自钉钉官方回调携带
710 // 的 sessionWebhook。所有 webhook 必须通过 validDingtalkWebhook 校验
711 // (仅钉钉域名),且回复 POST 不携带 access token(官方文档:会话 webhook
712 // 无需额外认证),避免向任意 URL 泄漏 token 或形成 SSRF。
713 func (a *adapter) sendMessage(ctx context.Context, msg bot.OutboundMessage) (bot.SendResult, error) {
714 webhook := strings.TrimSpace(msg.SessionWebhook)
715 if webhook != "" && !a.validDingtalkWebhook(webhook) {
716 return bot.SendResult{}, fmt.Errorf("dingtalk send rejected: session webhook %q is not a dingtalk endpoint", truncate(webhook, 64))
717 }
718 if webhook == "" {
719 webhook = a.webhookFor(msg.ChatID)
720 }
721 if webhook == "" {
722 return bot.SendResult{}, fmt.Errorf("dingtalk send requires a session webhook: no webhook known for chat %s (bot must receive a message in this chat first)", logHash(msg.ChatID))
723 }
724 if !a.validDingtalkWebhook(webhook) {
725 return bot.SendResult{}, fmt.Errorf("dingtalk send rejected: webhook %q is not a dingtalk endpoint", truncate(webhook, 64))
726 }
727 // 一律以 markdown 类型发送:Reasonix bot 回复是 markdown 文本,钉钉
728 // text 类型按纯文本显示、不渲染语法(与飞书 buildMarkdownCard 一致)。
729 title := "Reasonix"
730 if msg.Card != nil && strings.TrimSpace(msg.Card.Header) != "" {
731 title = msg.Card.Header
732 }
733 payload := map[string]any{"msgtype": "markdown", "markdown": map[string]string{
734 "title": title,
735 "text": msg.Text,
736 }}
737 raw, err := json.Marshal(payload)
738 if err != nil {
739 return bot.SendResult{}, err
740 }
741 ctx, cancel := context.WithTimeout(ctx, sendTimeout)
742 defer cancel()
743 req, err := http.NewRequestWithContext(ctx, http.MethodPost, webhook, bytes.NewReader(raw))
744 if err != nil {
745 return bot.SendResult{}, err
746 }
747 req.Header.Set("Content-Type", "application/json")
748 resp, err := a.webhookClient().Do(req)
749 if err != nil {
750 return bot.SendResult{}, err
751 }
752 defer resp.Body.Close()
753 body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
754 if resp.StatusCode != http.StatusOK {
755 return bot.SendResult{}, fmt.Errorf("dingtalk send failed (%d): %s", resp.StatusCode, truncate(string(body), 300))
756 }
757 return bot.SendResult{MessageID: ""}, nil
758 }
759
760 // TestSend 向最近一个交互过的会话发送测试消息,验证凭据与发送链路。
761 // 钉钉无全局发送 API,必须先收到过该会话的消息(学到 webhook)才能回复。
762 func (a *adapter) TestSend(ctx context.Context, text string) (bot.SendResult, error) {
763 a.webhookMu.Lock()
764 chatID := a.lastChatID
765 a.webhookMu.Unlock()
766 if chatID == "" {
767 return bot.SendResult{}, fmt.Errorf("dingtalk test send requires a known chat: the bot must have received a message first")
768 }
769 return a.sendMessage(ctx, bot.OutboundMessage{ChatID: chatID, ChatType: bot.ChatDM, Text: text})
770 }
771
772 func truncate(s string, n int) string {
773 runes := []rune(s)
774 if len(runes) <= n {
775 return s
776 }
777 return string(runes[:n]) + "…"
778 }
779
780 // logHash 对 chat/消息 ID 做脱敏哈希,用于日志(不泄漏完整 ID)。
781 func logHash(id string) string {
782 if id == "" {
783 return ""
784 }
785 sum := sha256.Sum256([]byte(id))
786 return hex.EncodeToString(sum[:])[:12]
787 }
788
788 lines GO