返回 DeepSeek-Reasonix
dingtalk_test.go
根目录 / internal / bot / dingtalk / dingtalk_test.go
1 package dingtalk
2
3 import (
4 "context"
5 "encoding/json"
6 "io"
7 "log/slog"
8 "net/http"
9 "net/http/httptest"
10 "net/url"
11 "strings"
12 "testing"
13 "time"
14
15 "reasonix/internal/bot"
16 "reasonix/internal/config"
17 )
18
19 func newTestHTTPClient() *http.Client {
20 return &http.Client{}
21 }
22
23 // allowTestWebhook 把 httptest 桩 server 的 host 加入 webhook 白名单,
24 // 并仅为该 adapter 允许 HTTP,使发送测试可以打到桩地址。
25 func allowTestWebhook(t *testing.T, a *adapter, srv *httptest.Server) {
26 t.Helper()
27 u, err := url.Parse(srv.URL)
28 if err != nil {
29 t.Fatalf("parse httptest url %q: %v", srv.URL, err)
30 }
31 a.webhookHosts = append(a.webhookHosts, u.Hostname())
32 a.allowHTTPWebhook = true
33 }
34
35 func testAdapter(cfg config.DingtalkBotConfig) *adapter {
36 return &adapter{
37 cfg: cfg,
38 logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
39 seen: make(map[string]bool),
40 webhooks: make(map[string]string),
41 msgChats: make(map[string]string),
42 httpClient: newTestHTTPClient(),
43 webhookHosts: append([]string(nil), dingtalkWebhookHosts...),
44 }
45 }
46
47 type roundTripFunc func(*http.Request) (*http.Response, error)
48
49 func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
50 return f(req)
51 }
52
53 func TestNormalizeDirectMessage(t *testing.T) {
54 a := testAdapter(config.DingtalkBotConfig{})
55 msg := a.normalizeMessage(robotMessage{
56 SenderStaffID: "user-1",
57 SenderNick: "张三",
58 ConversationID: "cid-123",
59 ConversationType: "1",
60 MsgID: "msg-1",
61 MsgType: "text",
62 Text: &robotTextContent{Content: "你好"},
63 SessionWebhook: "https://webhook/1",
64 })
65 if msg == nil {
66 t.Fatal("direct message should be accepted")
67 }
68 if msg.Platform != bot.PlatformDingtalk {
69 t.Fatalf("platform = %q, want dingtalk", msg.Platform)
70 }
71 if msg.ChatType != bot.ChatDM {
72 t.Fatalf("chat type = %q, want dm", msg.ChatType)
73 }
74 if msg.ChatID != "cid-123" || msg.UserID != "user-1" || msg.Text != "你好" {
75 t.Fatalf("unexpected message fields: %+v", msg)
76 }
77 if msg.UserName != "张三" {
78 t.Fatalf("user name = %q, want 张三", msg.UserName)
79 }
80 }
81
82 func TestNormalizeGroupStripsMention(t *testing.T) {
83 a := testAdapter(config.DingtalkBotConfig{BotName: "我的助手"})
84 msg := a.normalizeMessage(robotMessage{
85 SenderStaffID: "user-1",
86 SenderNick: "李四",
87 ConversationID: "cid-grp",
88 ConversationType: "2",
89 MsgID: "msg-g1",
90 MsgType: "text",
91 Text: &robotTextContent{Content: "@我的助手 今天天气如何"},
92 SessionWebhook: "https://webhook/2",
93 })
94 if msg == nil {
95 t.Fatal("group message mentioning the bot should be accepted")
96 }
97 if msg.ChatType != bot.ChatGroup {
98 t.Fatalf("chat type = %q, want group", msg.ChatType)
99 }
100 if msg.Text != "今天天气如何" {
101 t.Fatalf("text = %q, want mention stripped", msg.Text)
102 }
103 }
104
105 func TestNormalizeGroupRequiresMention(t *testing.T) {
106 a := testAdapter(config.DingtalkBotConfig{RequireMention: true, BotName: "我的助手"})
107 // 未 @ 机器人 → 拒绝。
108 plain := a.normalizeMessage(robotMessage{
109 ConversationID: "cid-grp",
110 ConversationType: "2",
111 MsgID: "msg-g2",
112 Text: &robotTextContent{Content: "普通消息"},
113 })
114 if plain != nil {
115 t.Fatal("group message without @bot should be rejected when require_mention is set")
116 }
117 // 单个 @ 机器人 → 剥离后为空文本。
118 only := a.normalizeMessage(robotMessage{
119 ConversationID: "cid-grp",
120 ConversationType: "2",
121 MsgID: "msg-g3",
122 Text: &robotTextContent{Content: "@我的助手"},
123 })
124 if only == nil || only.Text != "" {
125 t.Fatalf("bare mention should pass gating with empty text, got %+v", only)
126 }
127 // 官方回调 isInAtList=true、正文无前导 @token → 按结构化字段放行。
128 structured := a.normalizeMessage(robotMessage{
129 ConversationID: "cid-grp",
130 ConversationType: "2",
131 MsgID: "msg-g4",
132 Text: &robotTextContent{Content: "今天天气如何"},
133 IsInAtList: true,
134 })
135 if structured == nil {
136 t.Fatal("group message with isInAtList=true should pass gating even without leading @token")
137 }
138 if structured.Text != "今天天气如何" {
139 t.Fatalf("text = %q, want original content (no mention prefix to strip)", structured.Text)
140 }
141 // isInAtList=false 且正文无前导 @ → 拒绝。
142 noMention := a.normalizeMessage(robotMessage{
143 ConversationID: "cid-grp",
144 ConversationType: "2",
145 MsgID: "msg-g5",
146 Text: &robotTextContent{Content: "今天天气如何"},
147 IsInAtList: false,
148 })
149 if noMention != nil {
150 t.Fatal("group message with isInAtList=false should be rejected when require_mention is set")
151 }
152 // isInAtList=true 且正文残留前导 @token → 剥离。
153 both := a.normalizeMessage(robotMessage{
154 ConversationID: "cid-grp",
155 ConversationType: "2",
156 MsgID: "msg-g6",
157 Text: &robotTextContent{Content: "@我的助手 今天天气如何"},
158 IsInAtList: true,
159 })
160 if both == nil || both.Text != "今天天气如何" {
161 t.Fatalf("mention + isInAtList should strip prefix, got %+v", both)
162 }
163 }
164
165 func TestNormalizeDeduplicatesByMsgID(t *testing.T) {
166 a := testAdapter(config.DingtalkBotConfig{})
167 first := a.normalizeMessage(robotMessage{
168 ConversationID: "cid-1", ConversationType: "1", MsgID: "dup-1",
169 Text: &robotTextContent{Content: "hi"},
170 })
171 if first == nil {
172 t.Fatal("first delivery should be accepted")
173 }
174 second := a.normalizeMessage(robotMessage{
175 ConversationID: "cid-1", ConversationType: "1", MsgID: "dup-1",
176 Text: &robotTextContent{Content: "hi"},
177 })
178 if second != nil {
179 t.Fatal("duplicate msgId should be dropped")
180 }
181 }
182
183 func TestNormalizeMissingIDsRejected(t *testing.T) {
184 a := testAdapter(config.DingtalkBotConfig{})
185 if msg := a.normalizeMessage(robotMessage{ConversationID: "cid", ConversationType: "1", MsgID: ""}); msg != nil {
186 t.Fatal("empty msgId should be rejected")
187 }
188 if msg := a.normalizeMessage(robotMessage{ConversationID: "", ConversationType: "1", MsgID: "m"}); msg != nil {
189 t.Fatal("empty chatId should be rejected")
190 }
191 }
192
193 func TestSendRequiresSessionWebhook(t *testing.T) {
194 a := testAdapter(config.DingtalkBotConfig{ClientID: "id", ClientSecret: "secret"})
195 _, err := a.sendMessage(context.Background(), bot.OutboundMessage{
196 ChatID: "cid-1",
197 Text: "hi",
198 })
199 if err == nil || !strings.Contains(err.Error(), "session webhook") {
200 t.Fatalf("send without webhook should fail with a clear error, got %v", err)
201 }
202 }
203
204 // TestNormalizeRecordsSessionWebhook: normalize 必须把钉钉官方域名的
205 // webhook 记入 chatID→webhook 映射表,并透传到 InboundMessage。
206 func TestNormalizeRecordsSessionWebhook(t *testing.T) {
207 a := testAdapter(config.DingtalkBotConfig{})
208 msg := a.normalizeMessage(robotMessage{
209 ConversationID: "cid-web",
210 ConversationType: "1",
211 MsgID: "msg-w1",
212 Text: &robotTextContent{Content: "hi"},
213 SessionWebhook: "https://api.dingtalk.com/v1.0/robot/oToMessages/send?sessionWebhook=abc",
214 })
215 if msg == nil {
216 t.Fatal("message should be accepted")
217 }
218 if msg.SessionWebhook != "https://api.dingtalk.com/v1.0/robot/oToMessages/send?sessionWebhook=abc" {
219 t.Fatalf("inbound session_webhook = %q, want learned value", msg.SessionWebhook)
220 }
221 if got := a.webhookFor("cid-web"); got != "https://api.dingtalk.com/v1.0/robot/oToMessages/send?sessionWebhook=abc" {
222 t.Fatalf("webhook map = %q, want learned value", got)
223 }
224 }
225
226 // TestNormalizeRejectsForeignWebhook: 非钉钉官方域名的 sessionWebhook 不得
227 // 记入映射表,也不得透传(防伪造回调注入任意回复目标)。
228 func TestNormalizeRejectsForeignWebhook(t *testing.T) {
229 a := testAdapter(config.DingtalkBotConfig{})
230 msg := a.normalizeMessage(robotMessage{
231 ConversationID: "cid-web",
232 ConversationType: "1",
233 MsgID: "msg-w2",
234 Text: &robotTextContent{Content: "hi"},
235 SessionWebhook: "http://169.254.169.254/latest/meta-data/",
236 })
237 if msg == nil {
238 t.Fatal("message should be accepted")
239 }
240 if msg.SessionWebhook != "" {
241 t.Fatalf("inbound session_webhook = %q, want empty for foreign host", msg.SessionWebhook)
242 }
243 if got := a.webhookFor("cid-web"); got != "" {
244 t.Fatalf("webhook map = %q, want empty for foreign host", got)
245 }
246 }
247
248 func TestValidDingtalkWebhookRejectsHTTPOfficialHost(t *testing.T) {
249 a := testAdapter(config.DingtalkBotConfig{})
250 if a.validDingtalkWebhook("http://api.dingtalk.com/v1.0/robot/send") {
251 t.Fatal("production webhook validation must require HTTPS")
252 }
253 }
254
255 // TestSendUsesLearnedWebhook: 入站学习到 webhook 后,sendMessage 应 POST 到
256 // 该 webhook 而非 ReplyToMsgID(gateway 会把 ReplyToMsgID 填成消息 ID),
257 // 且不携带 access token(会话 webhook 无需认证)。
258 func TestSendUsesLearnedWebhook(t *testing.T) {
259 var gotAuth, gotBody string
260 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
261 gotAuth = r.Header.Get("x-acs-dingtalk-access-token")
262 b, _ := io.ReadAll(r.Body)
263 gotBody = string(b)
264 w.WriteHeader(http.StatusOK)
265 }))
266 defer srv.Close()
267
268 a := testAdapter(config.DingtalkBotConfig{})
269 allowTestWebhook(t, a, srv)
270 a.httpClient = srv.Client()
271 // 入站消息学习 webhook。
272 if m := a.normalizeMessage(robotMessage{
273 ConversationID: "cid-learn", ConversationType: "1", MsgID: "m1",
274 Text: &robotTextContent{Content: "hi"}, SessionWebhook: srv.URL,
275 }); m == nil {
276 t.Fatal("inbound message should be accepted")
277 }
278 // 出站:ReplyToMsgID 是消息 ID(非 URL),必须忽略并查表。
279 if _, err := a.sendMessage(context.Background(), bot.OutboundMessage{
280 ChatID: "cid-learn",
281 ChatType: bot.ChatDM,
282 Text: "回复",
283 ReplyToMsgID: "om-12345", // 模拟 gateway 填入的消息 ID
284 }); err != nil {
285 t.Fatalf("send via learned webhook failed: %v", err)
286 }
287 if !strings.Contains(gotBody, "回复") {
288 t.Fatalf("webhook body = %q, want reply text", gotBody)
289 }
290 if gotAuth != "" {
291 t.Fatalf("webhook request must not carry access token, got %q", gotAuth)
292 }
293 }
294
295 func TestSendRejectsRedirectToForeignWebhook(t *testing.T) {
296 targetHit := false
297 target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
298 targetHit = true
299 w.WriteHeader(http.StatusOK)
300 }))
301 defer target.Close()
302 foreignTarget := strings.Replace(target.URL, "127.0.0.1", "localhost", 1)
303
304 source := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
305 w.Header().Set("Location", foreignTarget)
306 w.WriteHeader(http.StatusTemporaryRedirect)
307 }))
308 defer source.Close()
309
310 a := testAdapter(config.DingtalkBotConfig{})
311 allowTestWebhook(t, a, source)
312 a.httpClient = source.Client()
313 _, err := a.sendMessage(context.Background(), bot.OutboundMessage{
314 ChatID: "cid-redirect",
315 ChatType: bot.ChatDM,
316 Text: "hi",
317 SessionWebhook: source.URL,
318 })
319 if err == nil || !strings.Contains(err.Error(), "redirect to non-dingtalk endpoint") {
320 t.Fatalf("foreign redirect must be rejected, got %v", err)
321 }
322 if targetHit {
323 t.Fatal("redirect target must not receive the webhook request")
324 }
325 }
326
327 // TestSendRejectsForeignWebhook: 非钉钉官方域名的 webhook(SessionWebhook
328 // 或映射表)必须被拒绝,且不得发出任何请求——防止 SSRF 与 token 外泄。
329 func TestSendRejectsForeignWebhook(t *testing.T) {
330 hit := false
331 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
332 hit = true
333 w.WriteHeader(http.StatusOK)
334 }))
335 defer srv.Close()
336
337 a := testAdapter(config.DingtalkBotConfig{})
338 a.httpClient = srv.Client()
339 a.token = "test-token"
340 a.tokenAt = time.Now()
341 // SessionWebhook 指向非白名单主机(内网元数据地址)→ 拒绝,不发请求。
342 _, err := a.sendMessage(context.Background(), bot.OutboundMessage{
343 ChatID: "cid-x",
344 ChatType: bot.ChatDM,
345 Text: "hi",
346 SessionWebhook: "http://169.254.169.254/latest/meta-data/",
347 })
348 if err == nil || !strings.Contains(err.Error(), "not a dingtalk endpoint") {
349 t.Fatalf("foreign session webhook must be rejected, got %v", err)
350 }
351 // ReplyToMsgID 为 URL 时同样被拒绝(不再作为 webhook 来源)。
352 _, err = a.sendMessage(context.Background(), bot.OutboundMessage{
353 ChatID: "cid-x",
354 ChatType: bot.ChatDM,
355 Text: "hi",
356 ReplyToMsgID: srv.URL,
357 })
358 if err == nil {
359 t.Fatal("ReplyToMsgID URL must not be used as webhook")
360 }
361 // 映射表里的非白名单 webhook 也被拒绝。
362 a.webhooks["cid-x"] = "https://evil.example.com/hook"
363 _, err = a.sendMessage(context.Background(), bot.OutboundMessage{
364 ChatID: "cid-x",
365 ChatType: bot.ChatDM,
366 Text: "hi",
367 })
368 if err == nil || !strings.Contains(err.Error(), "not a dingtalk endpoint") {
369 t.Fatalf("foreign mapped webhook must be rejected, got %v", err)
370 }
371 if hit {
372 t.Fatal("no request may reach a foreign webhook host")
373 }
374 }
375
376 // TestSendPrefersSessionWebhookOverLearned: 入站消息透传的 SessionWebhook
377 // 优先于映射表(gateway 重启后持久化恢复场景,adapter 内存映射可能为空)。
378 func TestSendPrefersSessionWebhookOverLearned(t *testing.T) {
379 var gotPath string
380 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
381 gotPath = r.URL.Path
382 w.WriteHeader(http.StatusOK)
383 }))
384 defer srv.Close()
385
386 a := testAdapter(config.DingtalkBotConfig{})
387 allowTestWebhook(t, a, srv)
388 a.httpClient = srv.Client()
389 a.token = "test-token"
390 a.tokenAt = time.Now()
391 // 映射表里是旧 webhook,透传的是新 webhook,必须用新的。
392 a.webhooks["cid-x"] = "https://old.example.com/hook"
393 if _, err := a.sendMessage(context.Background(), bot.OutboundMessage{
394 ChatID: "cid-x",
395 ChatType: bot.ChatDM,
396 Text: "透传发送",
397 SessionWebhook: srv.URL,
398 }); err != nil {
399 t.Fatalf("send with session webhook failed: %v", err)
400 }
401 if !strings.HasPrefix(gotPath, "/") {
402 t.Fatalf("request path = %q, want httptest path", gotPath)
403 }
404 }
405
406 // TestSendPlainTextUsesMarkdown: 普通文本(无 Card)也必须以 markdown 类型
407 // 发送,否则钉钉按纯文本显示、不渲染 markdown 语法(与飞书一致)。
408 func TestSendPlainTextUsesMarkdown(t *testing.T) {
409 var gotBody string
410 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
411 b, _ := io.ReadAll(r.Body)
412 gotBody = string(b)
413 w.WriteHeader(http.StatusOK)
414 }))
415 defer srv.Close()
416
417 a := testAdapter(config.DingtalkBotConfig{})
418 allowTestWebhook(t, a, srv)
419 a.httpClient = srv.Client()
420 a.token = "test-token"
421 a.tokenAt = time.Now()
422 if _, err := a.sendMessage(context.Background(), bot.OutboundMessage{
423 ChatID: "cid-md",
424 ChatType: bot.ChatDM,
425 Text: "**加粗** 和 `code`",
426 SessionWebhook: srv.URL,
427 }); err != nil {
428 t.Fatalf("send failed: %v", err)
429 }
430 if !strings.Contains(gotBody, `"msgtype":"markdown"`) {
431 t.Fatalf("plain text send must use markdown msgtype, got %s", gotBody)
432 }
433 if !strings.Contains(gotBody, "**加粗** 和 `code`") {
434 t.Fatalf("markdown body should carry original text, got %s", gotBody)
435 }
436 }
437
438 // TestSendMarkdownCard: Card 存在时发送 markdown 消息。
439 func TestSendMarkdownCard(t *testing.T) {
440 var gotBody string
441 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
442 b, _ := io.ReadAll(r.Body)
443 gotBody = string(b)
444 w.WriteHeader(http.StatusOK)
445 }))
446 defer srv.Close()
447
448 a := testAdapter(config.DingtalkBotConfig{})
449 allowTestWebhook(t, a, srv)
450 a.httpClient = srv.Client()
451 a.token = "test-token"
452 a.tokenAt = time.Now()
453 _, err := a.sendMessage(context.Background(), bot.OutboundMessage{
454 ChatID: "cid-card",
455 ChatType: bot.ChatDM,
456 Text: "**bold** content",
457 SessionWebhook: srv.URL,
458 Card: &bot.InteractiveCard{Header: "标题"},
459 })
460 if err != nil {
461 t.Fatalf("send markdown card failed: %v", err)
462 }
463 if !strings.Contains(gotBody, `"msgtype":"markdown"`) || !strings.Contains(gotBody, "**bold**") {
464 t.Fatalf("webhook body = %q, want markdown payload", gotBody)
465 }
466 }
467
468 // TestDecodeRobotMessageNestedJSONData: Stream 回调的 data 是 JSON 编码的
469 // 字符串,decodeRobotMessage 必须先解码字符串再解析消息(与 dsh transport.ts
470 // 的 JSON.parse(res.data) 一致),同时兼容 data 直接是对象的情况。
471 func TestDecodeRobotMessageNestedJSONData(t *testing.T) {
472 inner, err := json.Marshal(robotMessage{
473 SenderStaffID: "user-nested",
474 SenderNick: "嵌套",
475 ConversationID: "cid-nested",
476 ConversationType: "1",
477 MsgID: "msg-nested",
478 MsgType: "text",
479 Text: &robotTextContent{Content: "双层"},
480 SessionWebhook: "https://webhook/nested",
481 })
482 if err != nil {
483 t.Fatal(err)
484 }
485 // 双层编码:data 是 JSON 字符串,其内容为 robotMessage 的 JSON。
486 nestedBytes, err := json.Marshal(string(inner))
487 if err != nil {
488 t.Fatal(err)
489 }
490 raw, ok := decodeRobotMessage(json.RawMessage(nestedBytes))
491 if !ok {
492 t.Fatal("nested string payload should decode")
493 }
494 if raw.MsgID != "msg-nested" || raw.ConversationID != "cid-nested" || raw.SessionWebhook != "https://webhook/nested" {
495 t.Fatalf("unexpected decoded message: %+v", raw)
496 }
497
498 // 直接对象(兼容路径)。
499 direct, ok := decodeRobotMessage(json.RawMessage(inner))
500 if !ok {
501 t.Fatal("direct object payload should decode")
502 }
503 if direct.MsgID != "msg-nested" {
504 t.Fatalf("direct payload msg id = %q", direct.MsgID)
505 }
506
507 // 非法载荷。
508 if _, ok := decodeRobotMessage(json.RawMessage(`"not-json`)); ok {
509 t.Fatal("garbage payload should be rejected")
510 }
511 }
512
513 func TestSplitMentionBotNameMismatch(t *testing.T) {
514 a := testAdapter(config.DingtalkBotConfig{BotName: "我的助手"})
515 mentionsBot, rest := a.splitMention("@别人 你好")
516 if mentionsBot {
517 t.Fatal("mention of another user should not count as @bot")
518 }
519 if rest != "@别人 你好" {
520 t.Fatalf("mismatched mention must keep the original text, got %q", rest)
521 }
522 }
523
524 func TestClientCredentialsFromEnv(t *testing.T) {
525 t.Setenv("DINGTALK_TEST_ID", "env-id")
526 t.Setenv("DINGTALK_TEST_SECRET", "env-secret")
527 a := testAdapter(config.DingtalkBotConfig{
528 ClientIDEnv: "DINGTALK_TEST_ID",
529 SecretEnv: "DINGTALK_TEST_SECRET",
530 })
531 if got := a.clientID(); got != "env-id" {
532 t.Fatalf("client id = %q, want env-id", got)
533 }
534 if got := a.clientSecret(); got != "env-secret" {
535 t.Fatalf("client secret = %q, want env-secret", got)
536 }
537 }
538
539 // TestStartRejectsMissingCredentials: Start 必须同步校验凭据并返回错误,而不是
540 // 标记 running 后由 runWithRetry 在后台静默失败(否则桌面端会显示绿色的
541 // "已连接" 状态,实际从未连上)。
542 func TestStartRejectsMissingCredentials(t *testing.T) {
543 a := testAdapter(config.DingtalkBotConfig{})
544 err := a.Start(context.Background())
545 if err == nil {
546 t.Fatal("Start with no credentials should fail")
547 }
548 if !strings.Contains(err.Error(), "client_id") {
549 t.Fatalf("Start error = %q, want client_id message", err)
550 }
551 }
552
553 func TestStartRejectsEmptySecret(t *testing.T) {
554 a := testAdapter(config.DingtalkBotConfig{ClientID: "app-key"})
555 err := a.Start(context.Background())
556 if err == nil {
557 t.Fatal("Start with no secret should fail")
558 }
559 if !strings.Contains(err.Error(), "client_secret") {
560 t.Fatalf("Start error = %q, want client_secret message", err)
561 }
562 }
563
564 func TestStartReturnsWebSocketHandshakeFailure(t *testing.T) {
565 var srv *httptest.Server
566 gatewayCalls := 0
567 srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
568 w.Header().Set("Content-Type", "application/json")
569 switch r.URL.Path {
570 case "/gettoken":
571 _, _ = io.WriteString(w, `{"access_token":"token","errcode":0}`)
572 case "/v1.0/gateway/connections/open":
573 gatewayCalls++
574 endpoint := "ws" + strings.TrimPrefix(srv.URL, "http") + "/ws"
575 _ = json.NewEncoder(w).Encode(gatewayEndpoint{Endpoint: endpoint, Ticket: "ticket-1"})
576 case "/ws":
577 http.Error(w, "handshake rejected", http.StatusBadGateway)
578 default:
579 http.NotFound(w, r)
580 }
581 }))
582 defer srv.Close()
583 target, err := url.Parse(srv.URL)
584 if err != nil {
585 t.Fatalf("parse server URL: %v", err)
586 }
587 a := testAdapter(config.DingtalkBotConfig{ClientID: "app-key", ClientSecret: "secret"})
588 a.httpClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
589 localReq := req.Clone(req.Context())
590 localReq.URL.Scheme = target.Scheme
591 localReq.URL.Host = target.Host
592 return http.DefaultTransport.RoundTrip(localReq)
593 })}
594
595 err = a.Start(context.Background())
596 if err == nil || !strings.Contains(err.Error(), "connection failed") {
597 t.Fatalf("Start must report WebSocket handshake failure, got %v", err)
598 }
599 if gatewayCalls != 1 {
600 t.Fatalf("gateway open calls = %d, want one initial ticket", gatewayCalls)
601 }
602 }
603
604 // TestTestSendWithoutKnownChat: 还没有任何交互过的会话时,测试发送返回
605 // 可读错误,而不是发起真实请求。
606 func TestTestSendWithoutKnownChat(t *testing.T) {
607 a := testAdapter(config.DingtalkBotConfig{})
608 if _, err := a.TestSend(context.Background(), "hi"); err == nil {
609 t.Fatal("TestSend without a known chat should fail")
610 } else if !strings.Contains(err.Error(), "requires a known chat") {
611 t.Fatalf("error = %q, want readable known-chat hint", err.Error())
612 }
613 }
614
615 // TestTestSendUsesLatestLearnedChat: 测试发送会发到最近交互过的会话
616 // (normalizeMessage 学到 webhook 后记录 lastChatID)。
617 func TestTestSendUsesLatestLearnedChat(t *testing.T) {
618 var gotBody string
619 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
620 b, _ := io.ReadAll(r.Body)
621 gotBody = string(b)
622 w.WriteHeader(http.StatusOK)
623 }))
624 defer srv.Close()
625
626 a := testAdapter(config.DingtalkBotConfig{})
627 allowTestWebhook(t, a, srv)
628 a.httpClient = srv.Client()
629 a.token = "test-token"
630 a.tokenAt = time.Now()
631 if m := a.normalizeMessage(robotMessage{
632 ConversationID: "cid-latest", ConversationType: "1", MsgID: "m1",
633 Text: &robotTextContent{Content: "hi"}, SessionWebhook: srv.URL,
634 }); m == nil {
635 t.Fatal("inbound message should be accepted")
636 }
637 if _, err := a.TestSend(context.Background(), "测试消息"); err != nil {
638 t.Fatalf("TestSend failed: %v", err)
639 }
640 if !strings.Contains(gotBody, "测试消息") {
641 t.Fatalf("webhook body = %q, want test text", gotBody)
642 }
643 }
644
645 // TestAddPendingReactionPinsAndRecallsEmotion: 收到消息后 AddPendingReaction
646 // 贴 🤔思考中 表情,cleanup 撤回;emotion 请求体携带 robotCode/chat/message。
647 func TestAddPendingReactionPinsAndRecallsEmotion(t *testing.T) {
648 var actions []string
649 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
650 b, _ := io.ReadAll(r.Body)
651 actions = append(actions, r.URL.Path+"|"+string(b))
652 w.WriteHeader(http.StatusOK)
653 }))
654 defer srv.Close()
655 oldURL := emotionURL
656 emotionURL = srv.URL + "/v1.0/robot/emotion"
657 t.Cleanup(func() { emotionURL = oldURL })
658
659 a := testAdapter(config.DingtalkBotConfig{ClientID: "ding-appkey", ClientSecret: "secret"})
660 a.httpClient = srv.Client()
661 a.token = "test-token"
662 a.tokenAt = time.Now()
663 // 入站消息学习 chat。
664 if m := a.normalizeMessage(robotMessage{
665 ConversationID: "cid-emotion", ConversationType: "1", MsgID: "msg-emotion-1",
666 Text: &robotTextContent{Content: "hi"}, SessionWebhook: "https://webhook/emotion",
667 }); m == nil {
668 t.Fatal("inbound message should be accepted")
669 }
670 cleanup, err := a.AddPendingReaction(context.Background(), "msg-emotion-1")
671 if err != nil {
672 t.Fatalf("AddPendingReaction failed: %v", err)
673 }
674 if cleanup == nil {
675 t.Fatal("cleanup must not be nil")
676 }
677 cleanup()
678
679 if len(actions) != 2 {
680 t.Fatalf("expected 2 emotion calls (reply+recall), got %d: %v", len(actions), actions)
681 }
682 if !strings.Contains(actions[0], "/reply") || !strings.Contains(actions[0], "🤔思考中") {
683 t.Fatalf("first call should be reply with thinking emotion, got %q", actions[0])
684 }
685 if !strings.Contains(actions[0], "ding-appkey") || !strings.Contains(actions[0], "cid-emotion") || !strings.Contains(actions[0], "msg-emotion-1") {
686 t.Fatalf("reply body missing robotCode/chat/message: %q", actions[0])
687 }
688 if !strings.Contains(actions[1], "/recall") {
689 t.Fatalf("second call should be recall, got %q", actions[1])
690 }
691 }
692
693 // TestAddPendingReactionUnknownMessage: 未记录过的 messageID 报可读错误。
694 func TestAddPendingReactionUnknownMessage(t *testing.T) {
695 a := testAdapter(config.DingtalkBotConfig{ClientID: "ding-appkey", ClientSecret: "secret"})
696 if _, err := a.AddPendingReaction(context.Background(), "unknown-msg"); err == nil {
697 t.Fatal("unknown message should fail")
698 } else if !strings.Contains(err.Error(), "unknown chat") {
699 t.Fatalf("error = %q, want unknown-chat hint", err.Error())
700 }
701 }
702
702 lines GO