562 lines
16 KiB
Go
562 lines
16 KiB
Go
package feishu
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"reasonix/internal/bot"
|
|
"reasonix/internal/config"
|
|
|
|
"github.com/larksuite/oapi-sdk-go/v3/event/dispatcher/callback"
|
|
)
|
|
|
|
func TestStartReturnsMissingWebSocketSecret(t *testing.T) {
|
|
t.Setenv("FEISHU_TEST_SECRET", "")
|
|
a := New(config.FeishuBotConfig{
|
|
AppID: "cli-test",
|
|
AppSecretEnv: "FEISHU_TEST_SECRET",
|
|
Mode: "websocket",
|
|
}, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
|
|
|
err := a.Start(context.Background())
|
|
if err == nil || !strings.Contains(err.Error(), "FEISHU_TEST_SECRET") {
|
|
t.Fatalf("Start error = %v, want missing secret env", err)
|
|
}
|
|
}
|
|
|
|
func TestVerificationTokenValidRequiresConfiguredToken(t *testing.T) {
|
|
a := &adapter{cfg: config.FeishuBotConfig{VerificationToken: "expected"}}
|
|
|
|
if a.verificationTokenValid("") {
|
|
t.Fatal("missing token should be rejected when verification token is configured")
|
|
}
|
|
if a.verificationTokenValid("wrong") {
|
|
t.Fatal("wrong token should be rejected")
|
|
}
|
|
if !a.verificationTokenValid("expected") {
|
|
t.Fatal("matching token should be accepted")
|
|
}
|
|
|
|
a.cfg.VerificationToken = ""
|
|
if a.verificationTokenValid("") {
|
|
t.Fatal("unconfigured verification token should deny all callers")
|
|
}
|
|
}
|
|
|
|
func TestMarkSeenConcurrent(t *testing.T) {
|
|
a := &adapter{seen: make(map[string]bool)}
|
|
var wg sync.WaitGroup
|
|
|
|
for i := 0; i < 100; i++ {
|
|
wg.Add(1)
|
|
go func(i int) {
|
|
defer wg.Done()
|
|
_ = a.markSeen(fmt.Sprintf("evt-%d", i%5))
|
|
}(i)
|
|
}
|
|
wg.Wait()
|
|
|
|
if got := len(a.seen); got != 5 {
|
|
t.Fatalf("seen size = %d, want 5", got)
|
|
}
|
|
if a.markSeen("evt-1") != true {
|
|
t.Fatal("second markSeen call should report duplicate")
|
|
}
|
|
if a.markSeen("") {
|
|
t.Fatal("empty event id should not be treated as duplicate")
|
|
}
|
|
}
|
|
|
|
func TestHandleCardActionUsesChatType(t *testing.T) {
|
|
a := &adapter{
|
|
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
|
msgCh: make(chan bot.InboundMessage, 1),
|
|
}
|
|
raw := []byte(`{
|
|
"event": {
|
|
"operator": {
|
|
"operator_id": {"open_id": "open-user"}
|
|
},
|
|
"context": {
|
|
"open_message_id": "msg-1",
|
|
"open_chat_id": "chat-1"
|
|
},
|
|
"action": {
|
|
"value": {
|
|
"command": "/approve approval-1",
|
|
"chat_type": "dm"
|
|
}
|
|
}
|
|
}
|
|
}`)
|
|
|
|
if !a.handleCardAction(raw) {
|
|
t.Fatal("handleCardAction returned false")
|
|
}
|
|
|
|
msg := <-a.msgCh
|
|
if msg.ChatType != bot.ChatDM {
|
|
t.Fatalf("chat type = %q, want %q", msg.ChatType, bot.ChatDM)
|
|
}
|
|
if msg.Text != "/approve approval-1" {
|
|
t.Fatalf("text = %q, want /approve approval-1", msg.Text)
|
|
}
|
|
}
|
|
|
|
func TestHandleCardActionEnqueuesAskAnswerCommand(t *testing.T) {
|
|
a := &adapter{
|
|
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
|
msgCh: make(chan bot.InboundMessage, 1),
|
|
}
|
|
raw := []byte(`{
|
|
"event": {
|
|
"operator": {
|
|
"operator_id": {"open_id": "open-user"}
|
|
},
|
|
"context": {
|
|
"open_message_id": "msg-ask",
|
|
"open_chat_id": "chat-ask"
|
|
},
|
|
"action": {
|
|
"value": {
|
|
"command": "/answer ask-1 2",
|
|
"chat_type": "dm",
|
|
"user_id": "allowed-user"
|
|
}
|
|
}
|
|
}
|
|
}`)
|
|
|
|
if !a.handleCardAction(raw) {
|
|
t.Fatal("handleCardAction returned false")
|
|
}
|
|
|
|
msg := <-a.msgCh
|
|
if msg.Text != "/answer ask-1 2" {
|
|
t.Fatalf("text = %q, want /answer ask-1 2", msg.Text)
|
|
}
|
|
if msg.UserID != "allowed-user" {
|
|
t.Fatalf("user id = %q, want allowed-user", msg.UserID)
|
|
}
|
|
if msg.OperatorID != "open-user" {
|
|
t.Fatalf("operator id = %q, want open-user (the actual clicker, not the card requester)", msg.OperatorID)
|
|
}
|
|
if msg.ChatID != "chat-ask" || msg.MessageID != "msg-ask" {
|
|
t.Fatalf("message routing = chat %q msg %q, want chat-ask/msg-ask", msg.ChatID, msg.MessageID)
|
|
}
|
|
}
|
|
|
|
func TestHandleCardActionAcceptsDirectOperatorID(t *testing.T) {
|
|
a := &adapter{
|
|
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
|
msgCh: make(chan bot.InboundMessage, 1),
|
|
}
|
|
raw := []byte(`{
|
|
"event": {
|
|
"operator": {
|
|
"open_id": "open-user-direct"
|
|
},
|
|
"context": {
|
|
"open_message_id": "msg-1",
|
|
"open_chat_id": "chat-1"
|
|
},
|
|
"action": {
|
|
"value": {
|
|
"command": "/approve approval-1",
|
|
"chat_type": "dm"
|
|
}
|
|
}
|
|
}
|
|
}`)
|
|
|
|
if !a.handleCardAction(raw) {
|
|
t.Fatal("handleCardAction returned false")
|
|
}
|
|
|
|
msg := <-a.msgCh
|
|
if msg.UserID != "open-user-direct" {
|
|
t.Fatalf("user id = %q, want open-user-direct", msg.UserID)
|
|
}
|
|
if msg.OperatorID != "open-user-direct" {
|
|
t.Fatalf("operator id = %q, want open-user-direct", msg.OperatorID)
|
|
}
|
|
}
|
|
|
|
func TestHandleCardActionDoesNotTrustCardRequesterAsOperator(t *testing.T) {
|
|
a := &adapter{
|
|
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
|
msgCh: make(chan bot.InboundMessage, 1),
|
|
}
|
|
raw := []byte(`{
|
|
"event": {
|
|
"operator": {
|
|
"operator_id": {"open_id": "clicker"}
|
|
},
|
|
"context": {
|
|
"open_message_id": "msg-1",
|
|
"open_chat_id": "chat-1"
|
|
},
|
|
"action": {
|
|
"value": {
|
|
"command": "/approve approval-1",
|
|
"chat_type": "group",
|
|
"user_id": "requester"
|
|
}
|
|
}
|
|
}
|
|
}`)
|
|
|
|
if !a.handleCardAction(raw) {
|
|
t.Fatal("handleCardAction returned false")
|
|
}
|
|
|
|
msg := <-a.msgCh
|
|
if msg.UserID != "requester" {
|
|
t.Fatalf("user id = %q, want requester (routing follows the card value)", msg.UserID)
|
|
}
|
|
if msg.OperatorID != "clicker" {
|
|
t.Fatalf("operator id = %q, want clicker (gate follows the real button presser)", msg.OperatorID)
|
|
}
|
|
}
|
|
|
|
func TestHandleMessageTreatsTopicGroupAsGroup(t *testing.T) {
|
|
a := &adapter{
|
|
cfg: config.FeishuBotConfig{RequireMention: true},
|
|
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
|
msgCh: make(chan bot.InboundMessage, 1),
|
|
}
|
|
a.handleMessage(context.Background(), feishuMsgEvent{
|
|
MessageID: "msg-topic",
|
|
ChatID: "chat-topic",
|
|
ChatType: "topic_group",
|
|
MsgType: "text",
|
|
Content: `{"text":"hello"}`,
|
|
Sender: feishuSender{SenderID: struct {
|
|
UserID string `json:"user_id"`
|
|
OpenID string `json:"open_id"`
|
|
UnionID string `json:"union_id"`
|
|
}{OpenID: "open-user"}},
|
|
Mentions: []feishuMention{{Key: "@_user_1"}},
|
|
})
|
|
|
|
msg := <-a.msgCh
|
|
if msg.ChatType != bot.ChatGroup {
|
|
t.Fatalf("chat type = %q, want group", msg.ChatType)
|
|
}
|
|
if msg.ChatID != "chat-topic" || msg.UserID != "open-user" {
|
|
t.Fatalf("message = %+v, want topic group routing", msg)
|
|
}
|
|
}
|
|
|
|
func TestHandleMessageRequiresMentionInTopicGroup(t *testing.T) {
|
|
a := &adapter{
|
|
cfg: config.FeishuBotConfig{RequireMention: true},
|
|
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
|
msgCh: make(chan bot.InboundMessage, 1),
|
|
}
|
|
a.handleMessage(context.Background(), feishuMsgEvent{
|
|
MessageID: "msg-topic",
|
|
ChatID: "chat-topic",
|
|
ChatType: "topic_group",
|
|
MsgType: "text",
|
|
Content: `{"text":"hello"}`,
|
|
Sender: feishuSender{SenderID: struct {
|
|
UserID string `json:"user_id"`
|
|
OpenID string `json:"open_id"`
|
|
UnionID string `json:"union_id"`
|
|
}{OpenID: "open-user"}},
|
|
})
|
|
|
|
select {
|
|
case msg := <-a.msgCh:
|
|
t.Fatalf("message without mention was queued: %+v", msg)
|
|
default:
|
|
}
|
|
}
|
|
|
|
func TestWebSocketDispatcherHandlesCardActionTrigger(t *testing.T) {
|
|
a := &adapter{
|
|
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
|
msgCh: make(chan bot.InboundMessage, 1),
|
|
}
|
|
raw := []byte(`{
|
|
"schema": "2.0",
|
|
"header": {
|
|
"event_id": "evt-card-1",
|
|
"event_type": "card.action.trigger",
|
|
"token": ""
|
|
},
|
|
"event": {
|
|
"operator": {
|
|
"operator_id": {
|
|
"open_id": "open-user",
|
|
"union_id": "union-user"
|
|
}
|
|
},
|
|
"context": {
|
|
"open_message_id": "msg-card-1",
|
|
"open_chat_id": "chat-card-1"
|
|
},
|
|
"action": {
|
|
"value": {
|
|
"command": "/approve approval-2",
|
|
"chat_type": "dm",
|
|
"user_id": "allowed-user"
|
|
}
|
|
}
|
|
}
|
|
}`)
|
|
|
|
resp, err := a.newEventDispatcher().Do(context.Background(), raw)
|
|
if err != nil {
|
|
t.Fatalf("dispatcher.Do returned error: %v", err)
|
|
}
|
|
toast, ok := resp.(*callback.CardActionTriggerResponse)
|
|
if !ok {
|
|
t.Fatalf("response = %T, want *callback.CardActionTriggerResponse", resp)
|
|
}
|
|
if toast.Toast == nil || toast.Toast.Type != "success" {
|
|
t.Fatalf("toast = %#v, want success toast", toast.Toast)
|
|
}
|
|
|
|
msg := <-a.msgCh
|
|
if msg.Text != "/approve approval-2" {
|
|
t.Fatalf("text = %q, want /approve approval-2", msg.Text)
|
|
}
|
|
if msg.ChatID != "chat-card-1" {
|
|
t.Fatalf("chat id = %q, want chat-card-1", msg.ChatID)
|
|
}
|
|
if msg.UserID != "allowed-user" {
|
|
t.Fatalf("user id = %q, want allowed-user", msg.UserID)
|
|
}
|
|
|
|
_, err = a.newEventDispatcher().Do(context.Background(), raw)
|
|
if err != nil {
|
|
t.Fatalf("duplicate dispatcher.Do returned error: %v", err)
|
|
}
|
|
select {
|
|
case duplicate := <-a.msgCh:
|
|
t.Fatalf("duplicate card action enqueued message: %#v", duplicate)
|
|
default:
|
|
}
|
|
}
|
|
|
|
// pngHeader 是合法 PNG 签名,足够 http.DetectContentType 识别为 image/png。
|
|
var pngHeader = []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n', 0, 0, 0, 0}
|
|
|
|
func newTestAdapter(fetch func(ctx context.Context, messageID, key, typ string) ([]byte, string, error)) *adapter {
|
|
return &adapter{
|
|
cfg: config.FeishuBotConfig{},
|
|
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
|
msgCh: make(chan bot.InboundMessage, 1),
|
|
fetchResource: fetch,
|
|
}
|
|
}
|
|
|
|
func testSender(openID string) feishuSender {
|
|
return feishuSender{SenderID: struct {
|
|
UserID string `json:"user_id"`
|
|
OpenID string `json:"open_id"`
|
|
UnionID string `json:"union_id"`
|
|
}{OpenID: openID}}
|
|
}
|
|
|
|
func TestHandleMessageDefersImageDownload(t *testing.T) {
|
|
fetchCalls := 0
|
|
a := newTestAdapter(func(ctx context.Context, messageID, key, typ string) ([]byte, string, error) {
|
|
fetchCalls++
|
|
if messageID != "msg-img" || key != "img-key-1" || typ != "image" {
|
|
t.Fatalf("fetch args = %s/%s/%s, want msg-img/img-key-1/image", messageID, key, typ)
|
|
}
|
|
return pngHeader, "", nil
|
|
})
|
|
a.handleMessage(context.Background(), feishuMsgEvent{
|
|
MessageID: "msg-img",
|
|
ChatID: "chat-1",
|
|
ChatType: "p2p",
|
|
MsgType: "image",
|
|
Content: `{"image_key":"img-key-1"}`,
|
|
Sender: testSender("open-user"),
|
|
})
|
|
|
|
msg := <-a.msgCh
|
|
if len(msg.Media) != 1 {
|
|
t.Fatalf("media items = %d, want 1", len(msg.Media))
|
|
}
|
|
if fetchCalls != 0 {
|
|
t.Fatalf("resource fetched %d times before gateway admission, want 0", fetchCalls)
|
|
}
|
|
data, _, err := msg.Media[0].Load(context.Background())
|
|
if err != nil || !strings.HasPrefix(string(data), string(pngHeader)) {
|
|
t.Fatalf("deferred load = %x, %v; want png bytes", data, err)
|
|
}
|
|
if fetchCalls != 1 {
|
|
t.Fatalf("resource fetched %d times after load, want 1", fetchCalls)
|
|
}
|
|
}
|
|
|
|
func TestHandleMessageFileDownloadFailureKeepsDeferredPlaceholder(t *testing.T) {
|
|
fetchCalls := 0
|
|
a := newTestAdapter(func(ctx context.Context, messageID, key, typ string) ([]byte, string, error) {
|
|
fetchCalls++
|
|
return nil, "", fmt.Errorf("boom")
|
|
})
|
|
a.handleMessage(context.Background(), feishuMsgEvent{
|
|
MessageID: "msg-file",
|
|
ChatID: "chat-1",
|
|
ChatType: "p2p",
|
|
MsgType: "file",
|
|
Content: `{"file_key":"file-key-1","file_name":"report.pdf"}`,
|
|
Sender: testSender("open-user"),
|
|
})
|
|
|
|
msg := <-a.msgCh
|
|
if len(msg.Media) != 1 || fetchCalls != 0 {
|
|
t.Fatalf("media items/fetches = %d/%d, want one deferred item and no pre-admission fetch", len(msg.Media), fetchCalls)
|
|
}
|
|
if _, _, err := msg.Media[0].Load(context.Background()); err == nil {
|
|
t.Fatal("deferred load should report the injected failure")
|
|
}
|
|
if !strings.Contains(msg.Media[0].FailureText, "report.pdf") {
|
|
t.Fatalf("fallback = %q, want download-failure placeholder naming the file", msg.Media[0].FailureText)
|
|
}
|
|
}
|
|
|
|
func TestHandleMessageParsesPostContent(t *testing.T) {
|
|
fetchCalls := 0
|
|
a := newTestAdapter(func(ctx context.Context, messageID, key, typ string) ([]byte, string, error) {
|
|
fetchCalls++
|
|
if key != "post-img-1" || typ != "image" {
|
|
t.Fatalf("fetch args = %s/%s, want post-img-1/image", key, typ)
|
|
}
|
|
return pngHeader, "", nil
|
|
})
|
|
a.handleMessage(context.Background(), feishuMsgEvent{
|
|
MessageID: "msg-post",
|
|
ChatID: "chat-1",
|
|
ChatType: "p2p",
|
|
MsgType: "post",
|
|
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"}]]}`,
|
|
Sender: testSender("open-user"),
|
|
})
|
|
|
|
msg := <-a.msgCh
|
|
for _, want := range []string{"周报", "进展见", "文档 (https://example.com/doc)", "@张三"} {
|
|
if !strings.Contains(msg.Text, want) {
|
|
t.Fatalf("text = %q, want it to contain %q", msg.Text, want)
|
|
}
|
|
}
|
|
if len(msg.Media) != 1 {
|
|
t.Fatalf("media items = %d, want one deferred embedded image", len(msg.Media))
|
|
}
|
|
if fetchCalls != 0 {
|
|
t.Fatalf("post image fetched %d times before gateway admission, want 0", fetchCalls)
|
|
}
|
|
}
|
|
|
|
func TestHandleMessageUnsupportedTypeIgnored(t *testing.T) {
|
|
a := newTestAdapter(nil)
|
|
a.handleMessage(context.Background(), feishuMsgEvent{
|
|
MessageID: "msg-audio",
|
|
ChatID: "chat-1",
|
|
ChatType: "p2p",
|
|
MsgType: "audio",
|
|
Content: `{"file_key":"audio-key"}`,
|
|
Sender: testSender("open-user"),
|
|
})
|
|
|
|
select {
|
|
case msg := <-a.msgCh:
|
|
t.Fatalf("unsupported message type was queued: %+v", msg)
|
|
default:
|
|
}
|
|
}
|
|
|
|
func TestReplaceMentionPlaceholdersStripsBotAndNamesOthers(t *testing.T) {
|
|
a := newTestAdapter(nil)
|
|
a.botID = "ou-bot"
|
|
got := a.replaceMentionPlaceholders("@_user_1 帮 @_user_2 看看这个", []mentionRef{
|
|
{Key: "@_user_1", OpenID: "ou-bot", Name: "Reasonix"},
|
|
{Key: "@_user_2", OpenID: "ou-zhang", Name: "张三"},
|
|
})
|
|
if got != "帮 @张三 看看这个" {
|
|
t.Fatalf("text = %q, want bot mention stripped and peer mention named", got)
|
|
}
|
|
}
|
|
|
|
func TestMentionGatingRequiresBotWhenIdentityKnown(t *testing.T) {
|
|
a := newTestAdapter(nil)
|
|
a.cfg.RequireMention = true
|
|
a.botID = "ou-bot"
|
|
a.handleMessage(context.Background(), feishuMsgEvent{
|
|
MessageID: "msg-other",
|
|
ChatID: "chat-group",
|
|
ChatType: "group",
|
|
MsgType: "text",
|
|
Content: `{"text":"@_user_1 在吗"}`,
|
|
Sender: testSender("open-user"),
|
|
Mentions: []feishuMention{{Key: "@_user_1", Name: "张三", ID: struct {
|
|
OpenID string `json:"open_id"`
|
|
}{OpenID: "ou-zhang"}}},
|
|
})
|
|
|
|
select {
|
|
case msg := <-a.msgCh:
|
|
t.Fatalf("message mentioning someone else was queued: %+v", msg)
|
|
default:
|
|
}
|
|
}
|
|
|
|
func TestBuildMarkdownCard(t *testing.T) {
|
|
content, err := buildMarkdownCard("hello [docs](https://example.com)")
|
|
if err != nil {
|
|
t.Fatalf("buildMarkdownCard: %v", err)
|
|
}
|
|
var payload struct {
|
|
Schema string `json:"schema"`
|
|
Config struct {
|
|
UpdateMulti bool `json:"update_multi"`
|
|
} `json:"config"`
|
|
Body struct {
|
|
Elements []struct {
|
|
Tag string `json:"tag"`
|
|
Content string `json:"content"`
|
|
} `json:"elements"`
|
|
} `json:"body"`
|
|
}
|
|
if err := json.Unmarshal([]byte(content), &payload); err != nil {
|
|
t.Fatalf("card content should be valid json: %v", err)
|
|
}
|
|
if payload.Schema != "2.0" {
|
|
t.Fatalf("schema = %q, want 2.0", payload.Schema)
|
|
}
|
|
// update_multi must be set or Im.Message.Patch (streaming) is rejected.
|
|
if !payload.Config.UpdateMulti {
|
|
t.Fatal("card config.update_multi = false, want true so the card is patchable")
|
|
}
|
|
if len(payload.Body.Elements) != 1 || payload.Body.Elements[0].Tag != "markdown" {
|
|
t.Fatalf("elements = %#v, want one markdown element", payload.Body.Elements)
|
|
}
|
|
if payload.Body.Elements[0].Content != "hello [docs](https://example.com)" {
|
|
t.Fatalf("content = %q, want original markdown", payload.Body.Elements[0].Content)
|
|
}
|
|
}
|
|
|
|
func TestReplyFallbackOnlyForRecalledMessage(t *testing.T) {
|
|
if isReplyFallbackError(fmt.Errorf("i/o timeout")) {
|
|
t.Fatal("ambiguous transport errors must not fall back to Create")
|
|
}
|
|
if isReplyFallbackError(&feishuAPIError{op: "reply", code: 230013, msg: "no availability"}) {
|
|
t.Fatal("permission errors must not fall back to Create")
|
|
}
|
|
if !isReplyFallbackError(&feishuAPIError{op: "reply", code: feishuReplyRecalledCode, msg: "recalled"}) {
|
|
t.Fatal("a recalled target should fall back to Create")
|
|
}
|
|
}
|