返回 DeepSeek-Reasonix
feishu_test.go
根目录 / internal / bot / feishu / feishu_test.go
1 package feishu
2
3 import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "io"
8 "log/slog"
9 "strings"
10 "sync"
11 "testing"
12
13 "reasonix/internal/bot"
14 "reasonix/internal/config"
15
16 "github.com/larksuite/oapi-sdk-go/v3/event/dispatcher/callback"
17 )
18
19 func TestStartReturnsMissingWebSocketSecret(t *testing.T) {
20 t.Setenv("FEISHU_TEST_SECRET", "")
21 a := New(config.FeishuBotConfig{
22 AppID: "cli-test",
23 AppSecretEnv: "FEISHU_TEST_SECRET",
24 Mode: "websocket",
25 }, slog.New(slog.NewTextHandler(io.Discard, nil)))
26
27 err := a.Start(context.Background())
28 if err == nil || !strings.Contains(err.Error(), "FEISHU_TEST_SECRET") {
29 t.Fatalf("Start error = %v, want missing secret env", err)
30 }
31 }
32
33 func TestVerificationTokenValidRequiresConfiguredToken(t *testing.T) {
34 a := &adapter{cfg: config.FeishuBotConfig{VerificationToken: "expected"}}
35
36 if a.verificationTokenValid("") {
37 t.Fatal("missing token should be rejected when verification token is configured")
38 }
39 if a.verificationTokenValid("wrong") {
40 t.Fatal("wrong token should be rejected")
41 }
42 if !a.verificationTokenValid("expected") {
43 t.Fatal("matching token should be accepted")
44 }
45
46 a.cfg.VerificationToken = ""
47 if a.verificationTokenValid("") {
48 t.Fatal("unconfigured verification token should deny all callers")
49 }
50 }
51
52 func TestMarkSeenConcurrent(t *testing.T) {
53 a := &adapter{seen: make(map[string]bool)}
54 var wg sync.WaitGroup
55
56 for i := range 100 {
57 wg.Add(1)
58 go func(i int) {
59 defer wg.Done()
60 _ = a.markSeen(fmt.Sprintf("evt-%d", i%5))
61 }(i)
62 }
63 wg.Wait()
64
65 if got := len(a.seen); got != 5 {
66 t.Fatalf("seen size = %d, want 5", got)
67 }
68 if a.markSeen("evt-1") != true {
69 t.Fatal("second markSeen call should report duplicate")
70 }
71 if a.markSeen("") {
72 t.Fatal("empty event id should not be treated as duplicate")
73 }
74 }
75
76 func TestHandleCardActionUsesChatType(t *testing.T) {
77 a := &adapter{
78 logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
79 msgCh: make(chan bot.InboundMessage, 1),
80 }
81 raw := []byte(`{
82 "event": {
83 "operator": {
84 "operator_id": {"open_id": "open-user"}
85 },
86 "context": {
87 "open_message_id": "msg-1",
88 "open_chat_id": "chat-1"
89 },
90 "action": {
91 "value": {
92 "command": "/approve approval-1",
93 "chat_type": "dm"
94 }
95 }
96 }
97 }`)
98
99 if !a.handleCardAction(raw) {
100 t.Fatal("handleCardAction returned false")
101 }
102
103 msg := <-a.msgCh
104 if msg.ChatType != bot.ChatDM {
105 t.Fatalf("chat type = %q, want %q", msg.ChatType, bot.ChatDM)
106 }
107 if msg.Text != "/approve approval-1" {
108 t.Fatalf("text = %q, want /approve approval-1", msg.Text)
109 }
110 }
111
112 func TestHandleCardActionEnqueuesAskAnswerCommand(t *testing.T) {
113 a := &adapter{
114 logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
115 msgCh: make(chan bot.InboundMessage, 1),
116 }
117 raw := []byte(`{
118 "event": {
119 "operator": {
120 "operator_id": {"open_id": "open-user"}
121 },
122 "context": {
123 "open_message_id": "msg-ask",
124 "open_chat_id": "chat-ask"
125 },
126 "action": {
127 "value": {
128 "command": "/answer ask-1 2",
129 "chat_type": "dm",
130 "user_id": "allowed-user"
131 }
132 }
133 }
134 }`)
135
136 if !a.handleCardAction(raw) {
137 t.Fatal("handleCardAction returned false")
138 }
139
140 msg := <-a.msgCh
141 if msg.Text != "/answer ask-1 2" {
142 t.Fatalf("text = %q, want /answer ask-1 2", msg.Text)
143 }
144 if msg.UserID != "allowed-user" {
145 t.Fatalf("user id = %q, want allowed-user", msg.UserID)
146 }
147 if msg.OperatorID != "open-user" {
148 t.Fatalf("operator id = %q, want open-user (the actual clicker, not the card requester)", msg.OperatorID)
149 }
150 if msg.ChatID != "chat-ask" || msg.MessageID != "msg-ask" {
151 t.Fatalf("message routing = chat %q msg %q, want chat-ask/msg-ask", msg.ChatID, msg.MessageID)
152 }
153 }
154
155 func TestHandleCardActionAcceptsDirectOperatorID(t *testing.T) {
156 a := &adapter{
157 logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
158 msgCh: make(chan bot.InboundMessage, 1),
159 }
160 raw := []byte(`{
161 "event": {
162 "operator": {
163 "open_id": "open-user-direct"
164 },
165 "context": {
166 "open_message_id": "msg-1",
167 "open_chat_id": "chat-1"
168 },
169 "action": {
170 "value": {
171 "command": "/approve approval-1",
172 "chat_type": "dm"
173 }
174 }
175 }
176 }`)
177
178 if !a.handleCardAction(raw) {
179 t.Fatal("handleCardAction returned false")
180 }
181
182 msg := <-a.msgCh
183 if msg.UserID != "open-user-direct" {
184 t.Fatalf("user id = %q, want open-user-direct", msg.UserID)
185 }
186 if msg.OperatorID != "open-user-direct" {
187 t.Fatalf("operator id = %q, want open-user-direct", msg.OperatorID)
188 }
189 }
190
191 func TestHandleCardActionDoesNotTrustCardRequesterAsOperator(t *testing.T) {
192 a := &adapter{
193 logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
194 msgCh: make(chan bot.InboundMessage, 1),
195 }
196 raw := []byte(`{
197 "event": {
198 "operator": {
199 "operator_id": {"open_id": "clicker"}
200 },
201 "context": {
202 "open_message_id": "msg-1",
203 "open_chat_id": "chat-1"
204 },
205 "action": {
206 "value": {
207 "command": "/approve approval-1",
208 "chat_type": "group",
209 "user_id": "requester"
210 }
211 }
212 }
213 }`)
214
215 if !a.handleCardAction(raw) {
216 t.Fatal("handleCardAction returned false")
217 }
218
219 msg := <-a.msgCh
220 if msg.UserID != "requester" {
221 t.Fatalf("user id = %q, want requester (routing follows the card value)", msg.UserID)
222 }
223 if msg.OperatorID != "clicker" {
224 t.Fatalf("operator id = %q, want clicker (gate follows the real button presser)", msg.OperatorID)
225 }
226 }
227
228 // Allowlists record open_id and handleSDKMessage resolves open_id first, so a
229 // card callback carrying both ids must resolve the same way.
230 func TestHandleCardActionPrefersOpenIDOverUnionID(t *testing.T) {
231 a := &adapter{
232 logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
233 msgCh: make(chan bot.InboundMessage, 1),
234 }
235 raw := []byte(`{
236 "event": {
237 "operator": {
238 "operator_id": {
239 "union_id": "on_union-user",
240 "open_id": "ou_open-user"
241 }
242 },
243 "context": {
244 "open_message_id": "msg-1",
245 "open_chat_id": "chat-1"
246 },
247 "action": {
248 "value": {
249 "command": "/approve approval-1",
250 "chat_type": "dm"
251 }
252 }
253 }
254 }`)
255
256 if !a.handleCardAction(raw) {
257 t.Fatal("handleCardAction returned false")
258 }
259
260 msg := <-a.msgCh
261 if msg.OperatorID != "ou_open-user" {
262 t.Fatalf("operator id = %q, want ou_open-user (open_id must win over union_id)", msg.OperatorID)
263 }
264 if msg.UserID != "ou_open-user" {
265 t.Fatalf("user id = %q, want ou_open-user", msg.UserID)
266 }
267 }
268
269 func TestHandleMessageTreatsTopicGroupAsGroup(t *testing.T) {
270 a := &adapter{
271 cfg: config.FeishuBotConfig{RequireMention: true},
272 logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
273 msgCh: make(chan bot.InboundMessage, 1),
274 }
275 a.handleMessage(context.Background(), feishuMsgEvent{
276 MessageID: "msg-topic",
277 ChatID: "chat-topic",
278 ChatType: "topic_group",
279 MsgType: "text",
280 Content: `{"text":"hello"}`,
281 Sender: feishuSender{SenderID: struct {
282 UserID string `json:"user_id"`
283 OpenID string `json:"open_id"`
284 UnionID string `json:"union_id"`
285 }{OpenID: "open-user"}},
286 Mentions: []feishuMention{{Key: "@_user_1"}},
287 })
288
289 msg := <-a.msgCh
290 if msg.ChatType != bot.ChatGroup {
291 t.Fatalf("chat type = %q, want group", msg.ChatType)
292 }
293 if msg.ChatID != "chat-topic" || msg.UserID != "open-user" {
294 t.Fatalf("message = %+v, want topic group routing", msg)
295 }
296 }
297
298 func TestHandleMessageRequiresMentionInTopicGroup(t *testing.T) {
299 a := &adapter{
300 cfg: config.FeishuBotConfig{RequireMention: true},
301 logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
302 msgCh: make(chan bot.InboundMessage, 1),
303 }
304 a.handleMessage(context.Background(), feishuMsgEvent{
305 MessageID: "msg-topic",
306 ChatID: "chat-topic",
307 ChatType: "topic_group",
308 MsgType: "text",
309 Content: `{"text":"hello"}`,
310 Sender: feishuSender{SenderID: struct {
311 UserID string `json:"user_id"`
312 OpenID string `json:"open_id"`
313 UnionID string `json:"union_id"`
314 }{OpenID: "open-user"}},
315 })
316
317 select {
318 case msg := <-a.msgCh:
319 t.Fatalf("message without mention was queued: %+v", msg)
320 default:
321 }
322 }
323
324 func TestWebSocketDispatcherHandlesCardActionTrigger(t *testing.T) {
325 a := &adapter{
326 logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
327 msgCh: make(chan bot.InboundMessage, 1),
328 }
329 raw := []byte(`{
330 "schema": "2.0",
331 "header": {
332 "event_id": "evt-card-1",
333 "event_type": "card.action.trigger",
334 "token": ""
335 },
336 "event": {
337 "operator": {
338 "operator_id": {
339 "open_id": "open-user",
340 "union_id": "union-user"
341 }
342 },
343 "context": {
344 "open_message_id": "msg-card-1",
345 "open_chat_id": "chat-card-1"
346 },
347 "action": {
348 "value": {
349 "command": "/approve approval-2",
350 "chat_type": "dm",
351 "user_id": "allowed-user"
352 }
353 }
354 }
355 }`)
356
357 resp, err := a.newEventDispatcher().Do(context.Background(), raw)
358 if err != nil {
359 t.Fatalf("dispatcher.Do returned error: %v", err)
360 }
361 toast, ok := resp.(*callback.CardActionTriggerResponse)
362 if !ok {
363 t.Fatalf("response = %T, want *callback.CardActionTriggerResponse", resp)
364 }
365 if toast.Toast == nil || toast.Toast.Type != "success" {
366 t.Fatalf("toast = %#v, want success toast", toast.Toast)
367 }
368
369 msg := <-a.msgCh
370 if msg.Text != "/approve approval-2" {
371 t.Fatalf("text = %q, want /approve approval-2", msg.Text)
372 }
373 if msg.ChatID != "chat-card-1" {
374 t.Fatalf("chat id = %q, want chat-card-1", msg.ChatID)
375 }
376 if msg.UserID != "allowed-user" {
377 t.Fatalf("user id = %q, want allowed-user", msg.UserID)
378 }
379
380 _, err = a.newEventDispatcher().Do(context.Background(), raw)
381 if err != nil {
382 t.Fatalf("duplicate dispatcher.Do returned error: %v", err)
383 }
384 select {
385 case duplicate := <-a.msgCh:
386 t.Fatalf("duplicate card action enqueued message: %#v", duplicate)
387 default:
388 }
389 }
390
391 // pngHeader 是合法 PNG 签名,足够 http.DetectContentType 识别为 image/png。
392 var pngHeader = []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n', 0, 0, 0, 0}
393
394 func newTestAdapter(fetch func(ctx context.Context, messageID, key, typ string) ([]byte, string, error)) *adapter {
395 return &adapter{
396 cfg: config.FeishuBotConfig{},
397 logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
398 msgCh: make(chan bot.InboundMessage, 1),
399 fetchResource: fetch,
400 }
401 }
402
403 func testSender(openID string) feishuSender {
404 return feishuSender{SenderID: struct {
405 UserID string `json:"user_id"`
406 OpenID string `json:"open_id"`
407 UnionID string `json:"union_id"`
408 }{OpenID: openID}}
409 }
410
411 func TestHandleMessageDefersImageDownload(t *testing.T) {
412 fetchCalls := 0
413 a := newTestAdapter(func(ctx context.Context, messageID, key, typ string) ([]byte, string, error) {
414 fetchCalls++
415 if messageID != "msg-img" || key != "img-key-1" || typ != "image" {
416 t.Fatalf("fetch args = %s/%s/%s, want msg-img/img-key-1/image", messageID, key, typ)
417 }
418 return pngHeader, "", nil
419 })
420 a.handleMessage(context.Background(), feishuMsgEvent{
421 MessageID: "msg-img",
422 ChatID: "chat-1",
423 ChatType: "p2p",
424 MsgType: "image",
425 Content: `{"image_key":"img-key-1"}`,
426 Sender: testSender("open-user"),
427 })
428
429 msg := <-a.msgCh
430 if len(msg.Media) != 1 {
431 t.Fatalf("media items = %d, want 1", len(msg.Media))
432 }
433 if fetchCalls != 0 {
434 t.Fatalf("resource fetched %d times before gateway admission, want 0", fetchCalls)
435 }
436 data, _, err := msg.Media[0].Load(context.Background())
437 if err != nil || !strings.HasPrefix(string(data), string(pngHeader)) {
438 t.Fatalf("deferred load = %x, %v; want png bytes", data, err)
439 }
440 if fetchCalls != 1 {
441 t.Fatalf("resource fetched %d times after load, want 1", fetchCalls)
442 }
443 }
444
445 func TestHandleMessageFileDownloadFailureKeepsDeferredPlaceholder(t *testing.T) {
446 fetchCalls := 0
447 a := newTestAdapter(func(ctx context.Context, messageID, key, typ string) ([]byte, string, error) {
448 fetchCalls++
449 return nil, "", fmt.Errorf("boom")
450 })
451 a.handleMessage(context.Background(), feishuMsgEvent{
452 MessageID: "msg-file",
453 ChatID: "chat-1",
454 ChatType: "p2p",
455 MsgType: "file",
456 Content: `{"file_key":"file-key-1","file_name":"report.pdf"}`,
457 Sender: testSender("open-user"),
458 })
459
460 msg := <-a.msgCh
461 if len(msg.Media) != 1 || fetchCalls != 0 {
462 t.Fatalf("media items/fetches = %d/%d, want one deferred item and no pre-admission fetch", len(msg.Media), fetchCalls)
463 }
464 if _, _, err := msg.Media[0].Load(context.Background()); err == nil {
465 t.Fatal("deferred load should report the injected failure")
466 }
467 if !strings.Contains(msg.Media[0].FailureText, "report.pdf") {
468 t.Fatalf("fallback = %q, want download-failure placeholder naming the file", msg.Media[0].FailureText)
469 }
470 }
471
472 func TestHandleMessageParsesPostContent(t *testing.T) {
473 fetchCalls := 0
474 a := newTestAdapter(func(ctx context.Context, messageID, key, typ string) ([]byte, string, error) {
475 fetchCalls++
476 if key != "post-img-1" || typ != "image" {
477 t.Fatalf("fetch args = %s/%s, want post-img-1/image", key, typ)
478 }
479 return pngHeader, "", nil
480 })
481 a.handleMessage(context.Background(), feishuMsgEvent{
482 MessageID: "msg-post",
483 ChatID: "chat-1",
484 ChatType: "p2p",
485 MsgType: "post",
486 Content: `{"title":"周报","content":[[{"tag":"text","text":"进展见 "},{"tag":"a","text":"文档","href":"https://example.com/doc"},{"tag":"at","user_name":"张三"}],[{"tag":"img","image_key":"post-img-1"}]]}`,
487 Sender: testSender("open-user"),
488 })
489
490 msg := <-a.msgCh
491 for _, want := range []string{"周报", "进展见", "文档 (https://example.com/doc)", "@张三"} {
492 if !strings.Contains(msg.Text, want) {
493 t.Fatalf("text = %q, want it to contain %q", msg.Text, want)
494 }
495 }
496 if len(msg.Media) != 1 {
497 t.Fatalf("media items = %d, want one deferred embedded image", len(msg.Media))
498 }
499 if fetchCalls != 0 {
500 t.Fatalf("post image fetched %d times before gateway admission, want 0", fetchCalls)
501 }
502 }
503
504 func TestHandleMessageUnsupportedTypeIgnored(t *testing.T) {
505 a := newTestAdapter(nil)
506 a.handleMessage(context.Background(), feishuMsgEvent{
507 MessageID: "msg-audio",
508 ChatID: "chat-1",
509 ChatType: "p2p",
510 MsgType: "audio",
511 Content: `{"file_key":"audio-key"}`,
512 Sender: testSender("open-user"),
513 })
514
515 select {
516 case msg := <-a.msgCh:
517 t.Fatalf("unsupported message type was queued: %+v", msg)
518 default:
519 }
520 }
521
522 func TestReplaceMentionPlaceholdersStripsBotAndNamesOthers(t *testing.T) {
523 a := newTestAdapter(nil)
524 a.botID = "ou-bot"
525 got := a.replaceMentionPlaceholders("@_user_1 帮 @_user_2 看看这个", []mentionRef{
526 {Key: "@_user_1", OpenID: "ou-bot", Name: "Reasonix"},
527 {Key: "@_user_2", OpenID: "ou-zhang", Name: "张三"},
528 })
529 if got != "帮 @张三 看看这个" {
530 t.Fatalf("text = %q, want bot mention stripped and peer mention named", got)
531 }
532 }
533
534 func TestMentionGatingRequiresBotWhenIdentityKnown(t *testing.T) {
535 a := newTestAdapter(nil)
536 a.cfg.RequireMention = true
537 a.botID = "ou-bot"
538 a.handleMessage(context.Background(), feishuMsgEvent{
539 MessageID: "msg-other",
540 ChatID: "chat-group",
541 ChatType: "group",
542 MsgType: "text",
543 Content: `{"text":"@_user_1 在吗"}`,
544 Sender: testSender("open-user"),
545 Mentions: []feishuMention{{Key: "@_user_1", Name: "张三", ID: struct {
546 OpenID string `json:"open_id"`
547 }{OpenID: "ou-zhang"}}},
548 })
549
550 select {
551 case msg := <-a.msgCh:
552 t.Fatalf("message mentioning someone else was queued: %+v", msg)
553 default:
554 }
555 }
556
557 func TestBuildMarkdownCard(t *testing.T) {
558 content, err := buildMarkdownCard("hello [docs](https://example.com)")
559 if err != nil {
560 t.Fatalf("buildMarkdownCard: %v", err)
561 }
562 var payload struct {
563 Schema string `json:"schema"`
564 Config struct {
565 UpdateMulti bool `json:"update_multi"`
566 } `json:"config"`
567 Body struct {
568 Elements []struct {
569 Tag string `json:"tag"`
570 Content string `json:"content"`
571 } `json:"elements"`
572 } `json:"body"`
573 }
574 if err := json.Unmarshal([]byte(content), &payload); err != nil {
575 t.Fatalf("card content should be valid json: %v", err)
576 }
577 if payload.Schema != "2.0" {
578 t.Fatalf("schema = %q, want 2.0", payload.Schema)
579 }
580 // update_multi must be set or Im.Message.Patch (streaming) is rejected.
581 if !payload.Config.UpdateMulti {
582 t.Fatal("card config.update_multi = false, want true so the card is patchable")
583 }
584 if len(payload.Body.Elements) != 1 || payload.Body.Elements[0].Tag != "markdown" {
585 t.Fatalf("elements = %#v, want one markdown element", payload.Body.Elements)
586 }
587 if payload.Body.Elements[0].Content != "hello [docs](https://example.com)" {
588 t.Fatalf("content = %q, want original markdown", payload.Body.Elements[0].Content)
589 }
590 }
591
592 func TestReplyFallbackOnlyForRecalledMessage(t *testing.T) {
593 if isReplyFallbackError(fmt.Errorf("i/o timeout")) {
594 t.Fatal("ambiguous transport errors must not fall back to Create")
595 }
596 if isReplyFallbackError(&feishuAPIError{op: "reply", code: 230013, msg: "no availability"}) {
597 t.Fatal("permission errors must not fall back to Create")
598 }
599 if !isReplyFallbackError(&feishuAPIError{op: "reply", code: feishuReplyRecalledCode, msg: "recalled"}) {
600 t.Fatal("a recalled target should fall back to Create")
601 }
602 }
603
603 lines GO