| 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 |