返回 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 := 0; i < 100; i++ {
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 func TestHandleMessageTreatsTopicGroupAsGroup(t *testing.T) {
229 a := &adapter{
230 cfg: config.FeishuBotConfig{RequireMention: true},
231 logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
232 msgCh: make(chan bot.InboundMessage, 1),
233 }
234 a.handleMessage(context.Background(), feishuMsgEvent{
235 MessageID: "msg-topic",
236 ChatID: "chat-topic",
237 ChatType: "topic_group",
238 MsgType: "text",
239 Content: `{"text":"hello"}`,
240 Sender: feishuSender{SenderID: struct {
241 UserID string `json:"user_id"`
242 OpenID string `json:"open_id"`
243 UnionID string `json:"union_id"`
244 }{OpenID: "open-user"}},
245 Mentions: []feishuMention{{Key: "@_user_1"}},
246 })
247
248 msg := <-a.msgCh
249 if msg.ChatType != bot.ChatGroup {
250 t.Fatalf("chat type = %q, want group", msg.ChatType)
251 }
252 if msg.ChatID != "chat-topic" || msg.UserID != "open-user" {
253 t.Fatalf("message = %+v, want topic group routing", msg)
254 }
255 }
256
257 func TestHandleMessageRequiresMentionInTopicGroup(t *testing.T) {
258 a := &adapter{
259 cfg: config.FeishuBotConfig{RequireMention: true},
260 logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
261 msgCh: make(chan bot.InboundMessage, 1),
262 }
263 a.handleMessage(context.Background(), feishuMsgEvent{
264 MessageID: "msg-topic",
265 ChatID: "chat-topic",
266 ChatType: "topic_group",
267 MsgType: "text",
268 Content: `{"text":"hello"}`,
269 Sender: feishuSender{SenderID: struct {
270 UserID string `json:"user_id"`
271 OpenID string `json:"open_id"`
272 UnionID string `json:"union_id"`
273 }{OpenID: "open-user"}},
274 })
275
276 select {
277 case msg := <-a.msgCh:
278 t.Fatalf("message without mention was queued: %+v", msg)
279 default:
280 }
281 }
282
283 func TestWebSocketDispatcherHandlesCardActionTrigger(t *testing.T) {
284 a := &adapter{
285 logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
286 msgCh: make(chan bot.InboundMessage, 1),
287 }
288 raw := []byte(`{
289 "schema": "2.0",
290 "header": {
291 "event_id": "evt-card-1",
292 "event_type": "card.action.trigger",
293 "token": ""
294 },
295 "event": {
296 "operator": {
297 "operator_id": {
298 "open_id": "open-user",
299 "union_id": "union-user"
300 }
301 },
302 "context": {
303 "open_message_id": "msg-card-1",
304 "open_chat_id": "chat-card-1"
305 },
306 "action": {
307 "value": {
308 "command": "/approve approval-2",
309 "chat_type": "dm",
310 "user_id": "allowed-user"
311 }
312 }
313 }
314 }`)
315
316 resp, err := a.newEventDispatcher().Do(context.Background(), raw)
317 if err != nil {
318 t.Fatalf("dispatcher.Do returned error: %v", err)
319 }
320 toast, ok := resp.(*callback.CardActionTriggerResponse)
321 if !ok {
322 t.Fatalf("response = %T, want *callback.CardActionTriggerResponse", resp)
323 }
324 if toast.Toast == nil || toast.Toast.Type != "success" {
325 t.Fatalf("toast = %#v, want success toast", toast.Toast)
326 }
327
328 msg := <-a.msgCh
329 if msg.Text != "/approve approval-2" {
330 t.Fatalf("text = %q, want /approve approval-2", msg.Text)
331 }
332 if msg.ChatID != "chat-card-1" {
333 t.Fatalf("chat id = %q, want chat-card-1", msg.ChatID)
334 }
335 if msg.UserID != "allowed-user" {
336 t.Fatalf("user id = %q, want allowed-user", msg.UserID)
337 }
338
339 _, err = a.newEventDispatcher().Do(context.Background(), raw)
340 if err != nil {
341 t.Fatalf("duplicate dispatcher.Do returned error: %v", err)
342 }
343 select {
344 case duplicate := <-a.msgCh:
345 t.Fatalf("duplicate card action enqueued message: %#v", duplicate)
346 default:
347 }
348 }
349
350 // pngHeader 是合法 PNG 签名,足够 http.DetectContentType 识别为 image/png。
351 var pngHeader = []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n', 0, 0, 0, 0}
352
353 func newTestAdapter(fetch func(ctx context.Context, messageID, key, typ string) ([]byte, string, error)) *adapter {
354 return &adapter{
355 cfg: config.FeishuBotConfig{},
356 logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
357 msgCh: make(chan bot.InboundMessage, 1),
358 fetchResource: fetch,
359 }
360 }
361
362 func testSender(openID string) feishuSender {
363 return feishuSender{SenderID: struct {
364 UserID string `json:"user_id"`
365 OpenID string `json:"open_id"`
366 UnionID string `json:"union_id"`
367 }{OpenID: openID}}
368 }
369
370 func TestHandleMessageDefersImageDownload(t *testing.T) {
371 fetchCalls := 0
372 a := newTestAdapter(func(ctx context.Context, messageID, key, typ string) ([]byte, string, error) {
373 fetchCalls++
374 if messageID != "msg-img" || key != "img-key-1" || typ != "image" {
375 t.Fatalf("fetch args = %s/%s/%s, want msg-img/img-key-1/image", messageID, key, typ)
376 }
377 return pngHeader, "", nil
378 })
379 a.handleMessage(context.Background(), feishuMsgEvent{
380 MessageID: "msg-img",
381 ChatID: "chat-1",
382 ChatType: "p2p",
383 MsgType: "image",
384 Content: `{"image_key":"img-key-1"}`,
385 Sender: testSender("open-user"),
386 })
387
388 msg := <-a.msgCh
389 if len(msg.Media) != 1 {
390 t.Fatalf("media items = %d, want 1", len(msg.Media))
391 }
392 if fetchCalls != 0 {
393 t.Fatalf("resource fetched %d times before gateway admission, want 0", fetchCalls)
394 }
395 data, _, err := msg.Media[0].Load(context.Background())
396 if err != nil || !strings.HasPrefix(string(data), string(pngHeader)) {
397 t.Fatalf("deferred load = %x, %v; want png bytes", data, err)
398 }
399 if fetchCalls != 1 {
400 t.Fatalf("resource fetched %d times after load, want 1", fetchCalls)
401 }
402 }
403
404 func TestHandleMessageFileDownloadFailureKeepsDeferredPlaceholder(t *testing.T) {
405 fetchCalls := 0
406 a := newTestAdapter(func(ctx context.Context, messageID, key, typ string) ([]byte, string, error) {
407 fetchCalls++
408 return nil, "", fmt.Errorf("boom")
409 })
410 a.handleMessage(context.Background(), feishuMsgEvent{
411 MessageID: "msg-file",
412 ChatID: "chat-1",
413 ChatType: "p2p",
414 MsgType: "file",
415 Content: `{"file_key":"file-key-1","file_name":"report.pdf"}`,
416 Sender: testSender("open-user"),
417 })
418
419 msg := <-a.msgCh
420 if len(msg.Media) != 1 || fetchCalls != 0 {
421 t.Fatalf("media items/fetches = %d/%d, want one deferred item and no pre-admission fetch", len(msg.Media), fetchCalls)
422 }
423 if _, _, err := msg.Media[0].Load(context.Background()); err == nil {
424 t.Fatal("deferred load should report the injected failure")
425 }
426 if !strings.Contains(msg.Media[0].FailureText, "report.pdf") {
427 t.Fatalf("fallback = %q, want download-failure placeholder naming the file", msg.Media[0].FailureText)
428 }
429 }
430
431 func TestHandleMessageParsesPostContent(t *testing.T) {
432 fetchCalls := 0
433 a := newTestAdapter(func(ctx context.Context, messageID, key, typ string) ([]byte, string, error) {
434 fetchCalls++
435 if key != "post-img-1" || typ != "image" {
436 t.Fatalf("fetch args = %s/%s, want post-img-1/image", key, typ)
437 }
438 return pngHeader, "", nil
439 })
440 a.handleMessage(context.Background(), feishuMsgEvent{
441 MessageID: "msg-post",
442 ChatID: "chat-1",
443 ChatType: "p2p",
444 MsgType: "post",
445 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"}]]}`,
446 Sender: testSender("open-user"),
447 })
448
449 msg := <-a.msgCh
450 for _, want := range []string{"周报", "进展见", "文档 (https://example.com/doc)", "@张三"} {
451 if !strings.Contains(msg.Text, want) {
452 t.Fatalf("text = %q, want it to contain %q", msg.Text, want)
453 }
454 }
455 if len(msg.Media) != 1 {
456 t.Fatalf("media items = %d, want one deferred embedded image", len(msg.Media))
457 }
458 if fetchCalls != 0 {
459 t.Fatalf("post image fetched %d times before gateway admission, want 0", fetchCalls)
460 }
461 }
462
463 func TestHandleMessageUnsupportedTypeIgnored(t *testing.T) {
464 a := newTestAdapter(nil)
465 a.handleMessage(context.Background(), feishuMsgEvent{
466 MessageID: "msg-audio",
467 ChatID: "chat-1",
468 ChatType: "p2p",
469 MsgType: "audio",
470 Content: `{"file_key":"audio-key"}`,
471 Sender: testSender("open-user"),
472 })
473
474 select {
475 case msg := <-a.msgCh:
476 t.Fatalf("unsupported message type was queued: %+v", msg)
477 default:
478 }
479 }
480
481 func TestReplaceMentionPlaceholdersStripsBotAndNamesOthers(t *testing.T) {
482 a := newTestAdapter(nil)
483 a.botID = "ou-bot"
484 got := a.replaceMentionPlaceholders("@_user_1 帮 @_user_2 看看这个", []mentionRef{
485 {Key: "@_user_1", OpenID: "ou-bot", Name: "Reasonix"},
486 {Key: "@_user_2", OpenID: "ou-zhang", Name: "张三"},
487 })
488 if got != "帮 @张三 看看这个" {
489 t.Fatalf("text = %q, want bot mention stripped and peer mention named", got)
490 }
491 }
492
493 func TestMentionGatingRequiresBotWhenIdentityKnown(t *testing.T) {
494 a := newTestAdapter(nil)
495 a.cfg.RequireMention = true
496 a.botID = "ou-bot"
497 a.handleMessage(context.Background(), feishuMsgEvent{
498 MessageID: "msg-other",
499 ChatID: "chat-group",
500 ChatType: "group",
501 MsgType: "text",
502 Content: `{"text":"@_user_1 在吗"}`,
503 Sender: testSender("open-user"),
504 Mentions: []feishuMention{{Key: "@_user_1", Name: "张三", ID: struct {
505 OpenID string `json:"open_id"`
506 }{OpenID: "ou-zhang"}}},
507 })
508
509 select {
510 case msg := <-a.msgCh:
511 t.Fatalf("message mentioning someone else was queued: %+v", msg)
512 default:
513 }
514 }
515
516 func TestBuildMarkdownCard(t *testing.T) {
517 content, err := buildMarkdownCard("hello [docs](https://example.com)")
518 if err != nil {
519 t.Fatalf("buildMarkdownCard: %v", err)
520 }
521 var payload struct {
522 Schema string `json:"schema"`
523 Config struct {
524 UpdateMulti bool `json:"update_multi"`
525 } `json:"config"`
526 Body struct {
527 Elements []struct {
528 Tag string `json:"tag"`
529 Content string `json:"content"`
530 } `json:"elements"`
531 } `json:"body"`
532 }
533 if err := json.Unmarshal([]byte(content), &payload); err != nil {
534 t.Fatalf("card content should be valid json: %v", err)
535 }
536 if payload.Schema != "2.0" {
537 t.Fatalf("schema = %q, want 2.0", payload.Schema)
538 }
539 // update_multi must be set or Im.Message.Patch (streaming) is rejected.
540 if !payload.Config.UpdateMulti {
541 t.Fatal("card config.update_multi = false, want true so the card is patchable")
542 }
543 if len(payload.Body.Elements) != 1 || payload.Body.Elements[0].Tag != "markdown" {
544 t.Fatalf("elements = %#v, want one markdown element", payload.Body.Elements)
545 }
546 if payload.Body.Elements[0].Content != "hello [docs](https://example.com)" {
547 t.Fatalf("content = %q, want original markdown", payload.Body.Elements[0].Content)
548 }
549 }
550
551 func TestReplyFallbackOnlyForRecalledMessage(t *testing.T) {
552 if isReplyFallbackError(fmt.Errorf("i/o timeout")) {
553 t.Fatal("ambiguous transport errors must not fall back to Create")
554 }
555 if isReplyFallbackError(&feishuAPIError{op: "reply", code: 230013, msg: "no availability"}) {
556 t.Fatal("permission errors must not fall back to Create")
557 }
558 if !isReplyFallbackError(&feishuAPIError{op: "reply", code: feishuReplyRecalledCode, msg: "recalled"}) {
559 t.Fatal("a recalled target should fall back to Create")
560 }
561 }
562
562 lines GO