package feishu import ( "bytes" "context" "encoding/json" "log/slog" "strconv" "strings" "sync" "sync/atomic" "testing" "time" lark "github.com/larksuite/oapi-sdk-go/v3" "github.com/chenhg5/cc-connect/core" callback "github.com/larksuite/oapi-sdk-go/v3/event/dispatcher/callback" larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1" ) func TestNew_DefaultsToInteractivePlatform(t *testing.T) { p, err := New(map[string]any{"app_id": "cli_xxx", "app_secret": "secret"}) if err != nil { t.Fatalf("New() error = %v", err) } if _, ok := p.(core.CardSender); !ok { t.Fatal("expected default Feishu platform to implement core.CardSender") } } func TestNew_CanDisableInteractiveCards(t *testing.T) { p, err := New(map[string]any{"app_id": "cli_xxx", "app_secret": "secret", "enable_feishu_card": false}) if err != nil { t.Fatalf("New() error = %v", err) } if _, ok := p.(core.CardSender); ok { t.Fatal("expected disabled Feishu platform to fall back to plain text") } } func TestNew_DisabledInteractiveCardsDoesNotStartPreviewCard(t *testing.T) { pAny, err := New(map[string]any{"app_id": "cli_xxx", "app_secret": "secret", "enable_feishu_card": false}) if err != nil { t.Fatalf("New() error = %v", err) } p, ok := pAny.(*Platform) if !ok { t.Fatalf("platform type = %T, want *Platform", pAny) } _, err = p.SendPreviewStart(context.Background(), replyContext{messageID: "om_x", chatID: "oc_x"}, "hello") if err == nil { t.Fatal("SendPreviewStart() error = nil, want not supported when cards are disabled") } if err != core.ErrNotSupported { t.Fatalf("SendPreviewStart() error = %v, want %v", err, core.ErrNotSupported) } } func TestNew_ProgressStyleDefaultLegacy(t *testing.T) { p, err := New(map[string]any{"app_id": "cli_xxx", "app_secret": "secret"}) if err != nil { t.Fatalf("New() error = %v", err) } sp, ok := p.(core.ProgressStyleProvider) if !ok { t.Fatalf("platform type %T does not implement ProgressStyleProvider", p) } if got := sp.ProgressStyle(); got != "legacy" { t.Fatalf("ProgressStyle() = %q, want legacy", got) } } func TestNew_ProgressStyleSupportsCompactAndCard(t *testing.T) { tests := []string{"compact", "card"} for _, style := range tests { t.Run(style, func(t *testing.T) { p, err := New(map[string]any{ "app_id": "cli_xxx", "app_secret": "secret", "progress_style": style, }) if err != nil { t.Fatalf("New() error = %v", err) } sp, ok := p.(core.ProgressStyleProvider) if !ok { t.Fatalf("platform type %T does not implement ProgressStyleProvider", p) } if got := sp.ProgressStyle(); got != style { t.Fatalf("ProgressStyle() = %q, want %q", got, style) } payloadCap, ok := p.(core.ProgressCardPayloadSupport) if !ok { t.Fatalf("platform type %T does not implement ProgressCardPayloadSupport", p) } if !payloadCap.SupportsProgressCardPayload() { t.Fatal("SupportsProgressCardPayload() = false, want true") } }) } } func TestNew_ProgressStyleRejectsInvalidValue(t *testing.T) { _, err := New(map[string]any{ "app_id": "cli_xxx", "app_secret": "secret", "progress_style": "invalid-style", }) if err == nil { t.Fatal("expected error for invalid progress_style") } if !strings.Contains(err.Error(), "invalid progress_style") { t.Fatalf("error = %q, want invalid progress_style", err.Error()) } } func TestInteractivePlatform_OnMessagePassesCardSenderToHandler(t *testing.T) { platformAny, err := New(map[string]any{"app_id": "cli_xxx", "app_secret": "secret", "enable_feishu_card": true}) if err != nil { t.Fatalf("New() error = %v", err) } ip, ok := platformAny.(*interactivePlatform) if !ok { t.Fatalf("platform type = %T, want *interactivePlatform", platformAny) } messageID := "om_test_message" chatID := "oc_test_chat" openID := "ou_test_user" msgType := "text" chatType := "p2p" senderType := "user" content := `{"text":"/help"}` createText := strconv.FormatInt(time.Now().UnixMilli(), 10) var ( wg sync.WaitGroup receivedPlat core.Platform receivedMsg *core.Message ) wg.Add(1) ip.handler = func(p core.Platform, msg *core.Message) { defer wg.Done() receivedPlat = p receivedMsg = msg } event := &larkim.P2MessageReceiveV1{ Event: &larkim.P2MessageReceiveV1Data{ Sender: &larkim.EventSender{ SenderId: &larkim.UserId{OpenId: &openID}, SenderType: &senderType, }, Message: &larkim.EventMessage{ MessageId: &messageID, ChatId: &chatID, ChatType: &chatType, MessageType: &msgType, Content: &content, CreateTime: &createText, }, }, } if err := ip.onMessage(context.Background(), event); err != nil { t.Fatalf("onMessage() error = %v", err) } wg.Wait() if receivedMsg == nil { t.Fatal("expected handler to receive a message") } if receivedMsg.Content != "/help" { t.Fatalf("message content = %q, want /help", receivedMsg.Content) } if _, ok := receivedPlat.(core.CardSender); !ok { t.Fatalf("handler platform type = %T, want core.CardSender", receivedPlat) } } func TestInteractivePlatform_CardActionPassesCardSenderToHandler(t *testing.T) { platformAny, err := New(map[string]any{"app_id": "cli_xxx", "app_secret": "secret", "enable_feishu_card": true}) if err != nil { t.Fatalf("New() error = %v", err) } ip, ok := platformAny.(*interactivePlatform) if !ok { t.Fatalf("platform type = %T, want *interactivePlatform", platformAny) } openID := "ou_test_user" chatID := "oc_test_chat" messageID := "om_test_message" action := "cmd:/help" var ( msgCh = make(chan *core.Message, 1) platCh = make(chan core.Platform, 1) ) ip.handler = func(p core.Platform, msg *core.Message) { platCh <- p msgCh <- msg } _, err = ip.onCardAction(&callback.CardActionTriggerEvent{ Event: &callback.CardActionTriggerRequest{ Operator: &callback.Operator{OpenID: openID}, Action: &callback.CallBackAction{Value: map[string]any{"action": action}}, Context: &callback.Context{OpenChatID: chatID, OpenMessageID: messageID}, }, }) if err != nil { t.Fatalf("onCardAction() error = %v", err) } select { case receivedPlat := <-platCh: if _, ok := receivedPlat.(core.CardSender); !ok { t.Fatalf("handler platform type = %T, want core.CardSender", receivedPlat) } case <-time.After(2 * time.Second): t.Fatal("expected card action handler invocation") } select { case receivedMsg := <-msgCh: if receivedMsg.Content != "/help" { t.Fatalf("message content = %q, want /help", receivedMsg.Content) } case <-time.After(2 * time.Second): t.Fatal("expected card action message") } } func TestInteractivePlatform_CardActionActWithoutCardResponseDoesNotWarn(t *testing.T) { platformAny, err := New(map[string]any{"app_id": "cli_xxx", "app_secret": "secret", "enable_feishu_card": true}) if err != nil { t.Fatalf("New() error = %v", err) } ip, ok := platformAny.(*interactivePlatform) if !ok { t.Fatalf("platform type = %T, want *interactivePlatform", platformAny) } ip.cardNavHandler = func(action string, sessionKey string) *core.Card { return nil } var buf bytes.Buffer orig := slog.Default() slog.SetDefault(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))) t.Cleanup(func() { slog.SetDefault(orig) }) resp, err := ip.onCardAction(&callback.CardActionTriggerEvent{ Event: &callback.CardActionTriggerRequest{ Operator: &callback.Operator{OpenID: "ou_test_user"}, Action: &callback.CallBackAction{Value: map[string]any{"action": "act:/delete-mode toggle session-1"}}, Context: &callback.Context{OpenChatID: "oc_test_chat", OpenMessageID: "om_test_message"}, }, }) if err != nil { t.Fatalf("onCardAction() error = %v", err) } if resp == nil || resp.Toast == nil { t.Fatalf("expected toast response for silent toggle, got %#v", resp) } if resp.Card != nil { t.Fatalf("expected no card update on toggle, got %#v", resp.Card) } logs := buf.String() if strings.Contains(logs, "level=WARN") && strings.Contains(logs, "card nav returned nil, ignoring") { t.Fatalf("unexpected warning logs: %s", logs) } } func TestInteractivePlatform_CardActionFormSubmitPassesSelectedIDs(t *testing.T) { platformAny, err := New(map[string]any{"app_id": "cli_xxx", "app_secret": "secret", "enable_feishu_card": true}) if err != nil { t.Fatalf("New() error = %v", err) } ip, ok := platformAny.(*interactivePlatform) if !ok { t.Fatalf("platform type = %T, want *interactivePlatform", platformAny) } actionCh := make(chan string, 1) ip.cardNavHandler = func(action string, sessionKey string) *core.Card { actionCh <- action return core.NewCard().Markdown("ok").Build() } _, err = ip.onCardAction(&callback.CardActionTriggerEvent{ Event: &callback.CardActionTriggerRequest{ Operator: &callback.Operator{OpenID: "ou_test_user"}, Action: &callback.CallBackAction{ Value: map[string]any{"action": "act:/delete-mode form-submit"}, FormValue: map[string]any{ deleteModeCheckerName("session-2"): true, deleteModeCheckerName("session-1"): true, deleteModeCheckerName("session-3"): false, }, }, Context: &callback.Context{OpenChatID: "oc_test_chat", OpenMessageID: "om_test_message"}, }, }) if err != nil { t.Fatalf("onCardAction() error = %v", err) } select { case got := <-actionCh: want := "act:/delete-mode form-submit session-1,session-2" if got != want { t.Fatalf("action = %q, want %q", got, want) } case <-time.After(2 * time.Second): t.Fatal("expected card nav handler invocation") } } func TestInteractivePlatform_CardActionFormSubmitUsesActionNameFallback(t *testing.T) { platformAny, err := New(map[string]any{"app_id": "cli_xxx", "app_secret": "secret", "enable_feishu_card": true}) if err != nil { t.Fatalf("New() error = %v", err) } ip, ok := platformAny.(*interactivePlatform) if !ok { t.Fatalf("platform type = %T, want *interactivePlatform", platformAny) } actionCh := make(chan string, 1) ip.cardNavHandler = func(action string, sessionKey string) *core.Card { actionCh <- action return core.NewCard().Markdown("ok").Build() } _, err = ip.onCardAction(&callback.CardActionTriggerEvent{ Event: &callback.CardActionTriggerRequest{ Operator: &callback.Operator{OpenID: "ou_test_user"}, Action: &callback.CallBackAction{ Name: "delete_mode_submit", FormValue: map[string]any{ deleteModeCheckerName("session-2"): true, deleteModeCheckerName("session-1"): true, }, }, Context: &callback.Context{OpenChatID: "oc_test_chat", OpenMessageID: "om_test_message"}, }, }) if err != nil { t.Fatalf("onCardAction() error = %v", err) } select { case got := <-actionCh: want := "act:/delete-mode form-submit session-1,session-2" if got != want { t.Fatalf("action = %q, want %q", got, want) } case <-time.After(2 * time.Second): t.Fatal("expected card nav handler invocation") } } func TestInteractivePlatform_CardActionFormCancelUsesActionNameFallback(t *testing.T) { platformAny, err := New(map[string]any{"app_id": "cli_xxx", "app_secret": "secret", "enable_feishu_card": true}) if err != nil { t.Fatalf("New() error = %v", err) } ip, ok := platformAny.(*interactivePlatform) if !ok { t.Fatalf("platform type = %T, want *interactivePlatform", platformAny) } actionCh := make(chan string, 1) ip.cardNavHandler = func(action string, sessionKey string) *core.Card { actionCh <- action return core.NewCard().Markdown("ok").Build() } _, err = ip.onCardAction(&callback.CardActionTriggerEvent{ Event: &callback.CardActionTriggerRequest{ Operator: &callback.Operator{OpenID: "ou_test_user"}, Action: &callback.CallBackAction{ Name: "delete_mode_cancel", }, Context: &callback.Context{OpenChatID: "oc_test_chat", OpenMessageID: "om_test_message"}, }, }) if err != nil { t.Fatalf("onCardAction() error = %v", err) } select { case got := <-actionCh: want := "act:/delete-mode cancel" if got != want { t.Fatalf("action = %q, want %q", got, want) } case <-time.After(2 * time.Second): t.Fatal("expected card nav handler invocation") } } func TestInteractivePlatform_CardActionUsesCallbackSessionKey(t *testing.T) { platformAny, err := New(map[string]any{"app_id": "cli_xxx", "app_secret": "secret", "enable_feishu_card": true, "thread_isolation": true}) if err != nil { t.Fatalf("New() error = %v", err) } ip := platformAny.(*interactivePlatform) wantSessionKey := "feishu:oc_test_chat:root:om_root_thread" msgCh := make(chan *core.Message, 1) ip.handler = func(_ core.Platform, msg *core.Message) { msgCh <- msg } _, err = ip.onCardAction(&callback.CardActionTriggerEvent{ Event: &callback.CardActionTriggerRequest{ Operator: &callback.Operator{OpenID: "ou_test_user"}, Action: &callback.CallBackAction{Value: map[string]any{ "action": "cmd:/help", "session_key": wantSessionKey, }}, Context: &callback.Context{ OpenChatID: "oc_test_chat", OpenMessageID: "om_any_card_message", }, }, }) if err != nil { t.Fatalf("onCardAction() error = %v", err) } select { case msg := <-msgCh: if msg.SessionKey != wantSessionKey { t.Fatalf("SessionKey = %q, want %q", msg.SessionKey, wantSessionKey) } case <-time.After(2 * time.Second): t.Fatal("expected card action message") } } func TestInteractivePlatform_ModelCardActionReturnsCardUpdate(t *testing.T) { platformAny, err := New(map[string]any{"app_id": "cli_xxx", "app_secret": "secret", "enable_feishu_card": true}) if err != nil { t.Fatalf("New() error = %v", err) } ip, ok := platformAny.(*interactivePlatform) if !ok { t.Fatalf("platform type = %T, want *interactivePlatform", platformAny) } var gotAction, gotSessionKey string ip.cardNavHandler = func(action string, sessionKey string) *core.Card { gotAction = action gotSessionKey = sessionKey return core.NewCard().Markdown("switching").Build() } resp, err := ip.onCardAction(&callback.CardActionTriggerEvent{ Event: &callback.CardActionTriggerRequest{ Operator: &callback.Operator{OpenID: "ou_test_user"}, Action: &callback.CallBackAction{Value: map[string]any{"action": "act:/model switch 1"}}, Context: &callback.Context{OpenChatID: "oc_test_chat", OpenMessageID: "om_test_message"}, }, }) if err != nil { t.Fatalf("onCardAction() error = %v", err) } if resp == nil || resp.Card == nil { t.Fatalf("expected card response, got %#v", resp) } if gotAction != "act:/model switch 1" { t.Fatalf("action = %q, want act:/model switch 1", gotAction) } if gotSessionKey == "" { t.Fatal("expected non-empty session key") } ip.cardActionMsgMu.Lock() tracked := ip.cardActionMsgIDs[gotSessionKey] ip.cardActionMsgMu.Unlock() if tracked != "om_test_message" { t.Fatalf("tracked message id = %q, want om_test_message", tracked) } } func TestNewLark_PlatformNameAndDomain(t *testing.T) { p, err := newPlatform("lark", lark.LarkBaseUrl, map[string]any{ "app_id": "cli_xxx", "app_secret": "secret", }) if err != nil { t.Fatalf("newPlatform(lark) error = %v", err) } if p.Name() != "lark" { t.Fatalf("Name() = %q, want lark", p.Name()) } ip, ok := p.(*interactivePlatform) if !ok { t.Fatalf("type = %T, want *interactivePlatform", p) } if ip.domain != lark.LarkBaseUrl { t.Fatalf("domain = %q, want %q", ip.domain, lark.LarkBaseUrl) } } func TestPlatformShouldUseWebhookMode(t *testing.T) { tests := []struct { name string platform string encryptKey string want bool }{ {name: "lark defaults to websocket", platform: "lark", want: false}, {name: "lark webhook when encrypt key set", platform: "lark", encryptKey: "enc-key", want: true}, {name: "feishu defaults to websocket", platform: "feishu", want: false}, {name: "feishu webhook when encrypt key set", platform: "feishu", encryptKey: "enc-key", want: true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { p := &Platform{platformName: tt.platform, encryptKey: tt.encryptKey} if got := p.shouldUseWebhookMode(); got != tt.want { t.Fatalf("shouldUseWebhookMode() = %v, want %v", got, tt.want) } }) } } func TestNewFeishu_PlatformNameAndDomain(t *testing.T) { p, err := New(map[string]any{ "app_id": "cli_xxx", "app_secret": "secret", }) if err != nil { t.Fatalf("New() error = %v", err) } if p.Name() != "feishu" { t.Fatalf("Name() = %q, want feishu", p.Name()) } } func TestNewFeishu_CustomDomainOverride(t *testing.T) { customDomain := "https://open.example.invalid" p, err := New(map[string]any{ "app_id": "cli_xxx", "app_secret": "secret", "domain": customDomain, }) if err != nil { t.Fatalf("New() error = %v", err) } ip, ok := p.(*interactivePlatform) if !ok { t.Fatalf("type = %T, want *interactivePlatform", p) } if ip.domain != customDomain { t.Fatalf("domain = %q, want %q", ip.domain, customDomain) } } func TestNewFeishu_InvalidCustomDomain(t *testing.T) { _, err := New(map[string]any{ "app_id": "cli_xxx", "app_secret": "secret", "domain": "://bad", }) if err == nil { t.Fatal("expected invalid domain error") } } func TestLark_SessionKeyPrefix(t *testing.T) { p, err := newPlatform("lark", lark.LarkBaseUrl, map[string]any{ "app_id": "cli_xxx", "app_secret": "secret", "enable_feishu_card": true, }) if err != nil { t.Fatalf("newPlatform(lark) error = %v", err) } ip := p.(*interactivePlatform) messageID := "om_test" chatID := "oc_test" openID := "ou_test" msgType := "text" chatType := "p2p" senderType := "user" content := `{"text":"hello"}` createText := strconv.FormatInt(time.Now().UnixMilli(), 10) var receivedMsg *core.Message var wg sync.WaitGroup wg.Add(1) ip.handler = func(_ core.Platform, msg *core.Message) { defer wg.Done() receivedMsg = msg } _ = ip.onMessage(context.Background(), &larkim.P2MessageReceiveV1{ Event: &larkim.P2MessageReceiveV1Data{ Sender: &larkim.EventSender{ SenderId: &larkim.UserId{OpenId: &openID}, SenderType: &senderType, }, Message: &larkim.EventMessage{ MessageId: &messageID, ChatId: &chatID, ChatType: &chatType, MessageType: &msgType, Content: &content, CreateTime: &createText, }, }, }) wg.Wait() if receivedMsg == nil { t.Fatal("handler not called") } if !strings.HasPrefix(receivedMsg.SessionKey, "lark:") { t.Fatalf("SessionKey = %q, want lark: prefix", receivedMsg.SessionKey) } if receivedMsg.Platform != "lark" { t.Fatalf("Platform = %q, want lark", receivedMsg.Platform) } } func TestLark_ThreadIsolationUsesRootSessionKey(t *testing.T) { p, err := newPlatform("lark", lark.LarkBaseUrl, map[string]any{ "app_id": "cli_xxx", "app_secret": "secret", "enable_feishu_card": true, "thread_isolation": true, }) if err != nil { t.Fatalf("newPlatform(lark) error = %v", err) } ip := p.(*interactivePlatform) messageID := "om_reply" rootID := "om_root" chatID := "oc_test" openID := "ou_test" msgType := "text" chatType := "group" senderType := "user" content := `{"text":"@bot hello"}` createText := strconv.FormatInt(time.Now().UnixMilli(), 10) var receivedMsg *core.Message var wg sync.WaitGroup wg.Add(1) ip.botOpenID = "ou_bot" ip.handler = func(_ core.Platform, msg *core.Message) { defer wg.Done() receivedMsg = msg } _ = ip.onMessage(context.Background(), &larkim.P2MessageReceiveV1{ Event: &larkim.P2MessageReceiveV1Data{ Sender: &larkim.EventSender{ SenderId: &larkim.UserId{OpenId: &openID}, SenderType: &senderType, }, Message: &larkim.EventMessage{ MessageId: &messageID, RootId: &rootID, ChatId: &chatID, ChatType: &chatType, MessageType: &msgType, Content: &content, CreateTime: &createText, Mentions: []*larkim.MentionEvent{ { Key: stringPtr("@bot"), Id: &larkim.UserId{OpenId: stringPtr("ou_bot")}, }, }, }, }, }) wg.Wait() if receivedMsg == nil { t.Fatal("handler not called") } if receivedMsg.SessionKey != "lark:oc_test:root:om_root" { t.Fatalf("SessionKey = %q, want lark:oc_test:root:om_root", receivedMsg.SessionKey) } } func TestLark_GroupReplyAllWithThreadIsolationUsesRootSessionKeyWithoutMention(t *testing.T) { p, err := newPlatform("lark", lark.LarkBaseUrl, map[string]any{ "app_id": "cli_xxx", "app_secret": "secret", "enable_feishu_card": true, "group_reply_all": true, "thread_isolation": true, }) if err != nil { t.Fatalf("newPlatform(lark) error = %v", err) } ip := p.(*interactivePlatform) messageID := "om_root" chatID := "oc_test" openID := "ou_test" msgType := "text" chatType := "group" senderType := "user" content := `{"text":"hello from group root"}` createText := strconv.FormatInt(time.Now().UnixMilli(), 10) msgCh := make(chan *core.Message, 1) ip.handler = func(_ core.Platform, msg *core.Message) { msgCh <- msg } if err := ip.onMessage(context.Background(), &larkim.P2MessageReceiveV1{ Event: &larkim.P2MessageReceiveV1Data{ Sender: &larkim.EventSender{ SenderId: &larkim.UserId{OpenId: &openID}, SenderType: &senderType, }, Message: &larkim.EventMessage{ MessageId: &messageID, ChatId: &chatID, ChatType: &chatType, MessageType: &msgType, Content: &content, CreateTime: &createText, }, }, }); err != nil { t.Fatalf("onMessage() error = %v", err) } select { case receivedMsg := <-msgCh: if receivedMsg.SessionKey != "lark:oc_test:root:om_root" { t.Fatalf("SessionKey = %q, want lark:oc_test:root:om_root", receivedMsg.SessionKey) } rc, ok := receivedMsg.ReplyCtx.(replyContext) if !ok { t.Fatalf("ReplyCtx type = %T, want replyContext", receivedMsg.ReplyCtx) } if rc.sessionKey != "lark:oc_test:root:om_root" { t.Fatalf("replyContext.sessionKey = %q, want lark:oc_test:root:om_root", rc.sessionKey) } if rc.messageID != "om_root" { t.Fatalf("replyContext.messageID = %q, want om_root", rc.messageID) } case <-time.After(2 * time.Second): t.Fatal("expected group root message to be handled without mention") } } func TestBuildReplyMessageReqBody_SetsReplyInThreadFlag(t *testing.T) { tests := []struct { name string platform *Platform replyCtx replyContext wantThreading bool }{ { name: "thread isolation enabled", platform: &Platform{threadIsolation: true}, replyCtx: replyContext{messageID: "om_reply", sessionKey: "feishu:oc_chat:root:om_root"}, wantThreading: true, }, { name: "thread isolation does not affect p2p session", platform: &Platform{threadIsolation: true}, replyCtx: replyContext{messageID: "om_reply", sessionKey: "feishu:oc_chat:ou_user"}, wantThreading: false, }, { name: "plain reply remains non-threaded", platform: &Platform{}, replyCtx: replyContext{messageID: "om_reply"}, wantThreading: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { body := tt.platform.buildReplyMessageReqBody(tt.replyCtx, larkim.MsgTypeText, `{"text":"hello"}`) if body == nil { t.Fatal("Body = nil, want populated reply body") } if body.ReplyInThread == nil { if tt.wantThreading { t.Fatal("ReplyInThread = nil, want true") } return } if got := *body.ReplyInThread; got != tt.wantThreading { t.Fatalf("ReplyInThread = %v, want %v", got, tt.wantThreading) } }) } } func TestLark_ReconstructReplyCtx(t *testing.T) { p, err := newPlatform("lark", lark.LarkBaseUrl, map[string]any{ "app_id": "cli_xxx", "app_secret": "secret", "enable_feishu_card": false, }) if err != nil { t.Fatalf("newPlatform(lark) error = %v", err) } base := p.(*Platform) rctx, err := base.ReconstructReplyCtx("lark:oc_chat123:ou_user456") if err != nil { t.Fatalf("ReconstructReplyCtx() error = %v", err) } rc := rctx.(replyContext) if rc.chatID != "oc_chat123" { t.Fatalf("chatID = %q, want oc_chat123", rc.chatID) } rctx, err = base.ReconstructReplyCtx("lark:oc_chat123:root:om_root456") if err != nil { t.Fatalf("ReconstructReplyCtx(thread) error = %v", err) } rc = rctx.(replyContext) if rc.chatID != "oc_chat123" { t.Fatalf("thread chatID = %q, want oc_chat123", rc.chatID) } if rc.messageID != "om_root456" { t.Fatalf("thread messageID = %q, want om_root456", rc.messageID) } _, err = base.ReconstructReplyCtx("feishu:oc_chat:ou_user") if err == nil { t.Fatal("expected error for feishu-prefixed key on lark platform") } } func TestUserIDFromEventFallsBackToUserID(t *testing.T) { userID := "uid_user123" if got := userIDFromEvent(&larkim.UserId{UserId: &userID}); got != userID { t.Fatalf("userIDFromEvent() = %q, want %q", got, userID) } } func TestResolveUserNameSkipsInvalidLookupID(t *testing.T) { p := &Platform{} for _, id := range []string{"", "feishu:oc_chat:ou_user", "ou user"} { if got := p.resolveUserName(id); got != id { t.Fatalf("resolveUserName(%q) = %q, want unchanged", id, got) } } } func stringPtr(s string) *string { return &s } func TestSanitizeMarkdownURLs(t *testing.T) { tests := []struct { name string input string want string }{ { name: "http link kept", input: "see [docs](http://example.com)", want: "see [docs](http://example.com)", }, { name: "https link kept", input: "see [docs](https://example.com/path)", want: "see [docs](https://example.com/path)", }, { name: "file scheme removed", input: "open [file](file:///tmp/foo.txt)", want: "open file (file:///tmp/foo.txt)", }, { name: "data scheme removed", input: "img [pic](data:image/png;base64,abc)", want: "img pic (data:image/png;base64,abc)", }, { name: "mixed links", input: "[ok](https://x.com) and [bad](file:///etc/passwd)", want: "[ok](https://x.com) and bad (file:///etc/passwd)", }, { name: "no links unchanged", input: "plain text without links", want: "plain text without links", }, { name: "ftp scheme removed", input: "[dl](ftp://files.example.com/f.zip)", want: "dl (ftp://files.example.com/f.zip)", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := sanitizeMarkdownURLs(tt.input) if got != tt.want { t.Errorf("sanitizeMarkdownURLs(%q) = %q, want %q", tt.input, got, tt.want) } }) } } func TestLark_ErrorMessagePrefix(t *testing.T) { _, err := newPlatform("lark", lark.LarkBaseUrl, map[string]any{}) if err == nil { t.Fatal("expected error for missing credentials") } if !strings.HasPrefix(err.Error(), "lark:") { t.Fatalf("error = %q, want lark: prefix", err.Error()) } } func TestBuildPreviewCardJSON_ProgressPayloadUsesStructuredCard(t *testing.T) { payload := core.BuildProgressCardPayloadV2([]core.ProgressCardEntry{ {Kind: core.ProgressEntryThinking, Text: "planning"}, {Kind: core.ProgressEntryToolUse, Tool: "Bash", Text: "pwd"}, }, false, "Codex", core.LangEnglish, core.ProgressCardStateRunning) if payload == "" { t.Fatal("BuildProgressCardPayload returned empty payload") } cardJSON := buildPreviewCardJSON(payload) if strings.Contains(cardJSON, core.ProgressCardPayloadPrefix) { t.Fatalf("card JSON should not leak payload prefix, got %q", cardJSON) } if !strings.Contains(cardJSON, "Codex · Running") { t.Fatalf("card JSON should contain progress title, got %q", cardJSON) } if strings.Contains(cardJSON, "\"tag\":\"note\"") { t.Fatalf("card JSON should not use deprecated note tag, got %q", cardJSON) } if !strings.Contains(cardJSON, "\"text_color\":\"grey\"") { t.Fatalf("card JSON should render thinking with grey style, got %q", cardJSON) } if !strings.Contains(cardJSON, "\\u003ctext_tag color='blue'\\u003eTool") { t.Fatalf("card JSON should include tool label, got %q", cardJSON) } var card map[string]any if err := json.Unmarshal([]byte(cardJSON), &card); err != nil { t.Fatalf("card JSON is invalid: %v", err) } header, ok := card["header"].(map[string]any) if !ok || header == nil { t.Fatalf("expected header in card json, got %#v", card["header"]) } } func TestBuildRichCard_RendersThinkingAndToolResultRows(t *testing.T) { code := 0 success := true cardJSON := buildRichCard(core.CardStatusWorking, "", []core.ToolStep{ {Kind: core.ToolStepKindThinking, Name: "Thinking", Summary: "Inspecting event routing"}, { Kind: core.ToolStepKindTool, Name: "Bash", Summary: "echo hi", Result: "hi", Status: "completed", ExitCode: &code, Success: &success, Done: true, }, }, "done", true, time.Second) for _, want := range []string{"Inspecting event routing", "echo hi", "completed", "exit: 0", "hi"} { if !strings.Contains(cardJSON, want) { t.Fatalf("rich card should contain %q, got %q", want, cardJSON) } } if strings.Contains(cardJSON, core.ProgressCardPayloadPrefix) { t.Fatalf("rich card should not contain progress payload prefix, got %q", cardJSON) } } func TestBuildPreviewCardJSON_NormalTextFallback(t *testing.T) { cardJSON := buildPreviewCardJSON("plain progress text") if strings.Contains(cardJSON, "cc-connect · 进度") { t.Fatalf("normal text should use default card template, got %q", cardJSON) } if !strings.Contains(cardJSON, "\"tag\":\"markdown\"") { t.Fatalf("default preview card should contain markdown element, got %q", cardJSON) } } func TestFormatProgressToolInput_TodoWrite(t *testing.T) { tests := []struct { name string input string wantContains []string notWantContains []string }{ { name: "valid todos with all statuses", input: `{"todos": [ {"content": "Task 1", "status": "completed", "activeForm": "Completing task 1"}, {"content": "Task 2", "status": "in_progress", "activeForm": "Working on task 2"}, {"content": "Task 3", "status": "pending", "activeForm": "Planning task 3"} ]}`, wantContains: []string{"✅", "🔄", "⏳", "Task 1", "Task 2", "Task 3", "Completing task 1", "Working on task 2"}, notWantContains: []string{"```"}, }, { name: "todos without activeForm", input: `{"todos": [{"content": "Simple task", "status": "pending"}]}`, wantContains: []string{"⏳", "Simple task"}, notWantContains: []string{"(", ")"}, }, { name: "invalid JSON falls back to default", input: `not valid json`, wantContains: []string{"```text"}, }, { name: "empty todos array", input: `{"todos": []}`, wantContains: []string{"```text"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := formatProgressToolInput("TodoWrite", tt.input) for _, want := range tt.wantContains { if !strings.Contains(result, want) { t.Errorf("result should contain %q, got %q", want, result) } } for _, notWant := range tt.notWantContains { if strings.Contains(result, notWant) { t.Errorf("result should not contain %q, got %q", notWant, result) } } }) } } func TestFormatProgressToolInput_OtherTools(t *testing.T) { // Non-TodoWrite tools should use default formatting result := formatProgressToolInput("Bash", "ls -la") if !strings.Contains(result, "```bash") { t.Errorf("Bash tool should use bash code block, got %q", result) } // TodoWrite with invalid JSON should fall back to text block result = formatProgressToolInput("TodoWrite", "not json") if !strings.Contains(result, "```text") { t.Errorf("TodoWrite with invalid JSON should fall back to text block, got %q", result) } } func TestAllowChat_FiltersGroupMessages(t *testing.T) { tests := []struct { name string allowChat string chatID string chatType string wantPass bool }{ {"empty allow_chat permits all groups", "", "oc_abc", "group", true}, {"wildcard permits all groups", "*", "oc_abc", "group", true}, {"matching chat_id passes", "oc_abc", "oc_abc", "group", true}, {"non-matching chat_id blocked", "oc_abc", "oc_xyz", "group", false}, {"multiple chat_ids, match second", "oc_abc,oc_xyz", "oc_xyz", "group", true}, {"multiple chat_ids, no match", "oc_abc,oc_def", "oc_xyz", "group", false}, {"private chat bypasses allow_chat filter", "oc_abc", "oc_xyz", "p2p", true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { p, err := newPlatform("feishu", lark.FeishuBaseUrl, map[string]any{ "app_id": "cli_xxx", "app_secret": "secret", "enable_feishu_card": true, "group_reply_all": true, "allow_chat": tt.allowChat, }) if err != nil { t.Fatalf("newPlatform() error = %v", err) } ip := p.(*interactivePlatform) messageID := "om_test_" + tt.name openID := "ou_test" msgType := "text" senderType := "user" content := `{"text":"hello"}` createTime := strconv.FormatInt(time.Now().UnixMilli(), 10) msgCh := make(chan *core.Message, 1) ip.handler = func(_ core.Platform, msg *core.Message) { msgCh <- msg } if err := ip.onMessage(context.Background(), &larkim.P2MessageReceiveV1{ Event: &larkim.P2MessageReceiveV1Data{ Sender: &larkim.EventSender{ SenderId: &larkim.UserId{OpenId: &openID}, SenderType: &senderType, }, Message: &larkim.EventMessage{ MessageId: &messageID, ChatId: &tt.chatID, ChatType: &tt.chatType, MessageType: &msgType, Content: &content, CreateTime: &createTime, }, }, }); err != nil { t.Fatalf("onMessage() error = %v", err) } select { case <-msgCh: if !tt.wantPass { t.Fatal("expected message to be blocked by allow_chat, but it was delivered") } case <-time.After(2 * time.Second): if tt.wantPass { t.Fatal("expected message to pass allow_chat filter, but it was blocked") } } }) } } // --- Mention resolution tests --- func TestResolveMentions_ReplacesKnownMember(t *testing.T) { p := &Platform{platformName: "feishu", resolveMentions: true} p.chatMemberCache.Store("oc_chat", &chatMemberEntry{ members: map[string]string{"张三": "ou_zhangsan", "李四": "ou_lisi"}, fetchedAt: time.Now(), }) input := "巡检完成,@张三 @李四 请查看" result := p.resolveMentionsInContent(context.Background(), "oc_chat", input) if !strings.Contains(result, `张三`) { t.Fatalf("expected 张三 to be resolved, got %q", result) } if !strings.Contains(result, `李四`) { t.Fatalf("expected 李四 to be resolved, got %q", result) } } func TestResolveMentions_UnknownMemberKeptAsIs(t *testing.T) { p := &Platform{platformName: "feishu", resolveMentions: true} p.chatMemberCache.Store("oc_chat", &chatMemberEntry{ members: map[string]string{"张三": "ou_zhangsan"}, fetchedAt: time.Now(), }) input := "@不存在的人 请查看" result := p.resolveMentionsInContent(context.Background(), "oc_chat", input) if strings.Contains(result, "") { t.Fatalf("card format should use , got %q", result) } } func TestResolveMentions_DisabledByConfig(t *testing.T) { p := &Platform{platformName: "feishu", resolveMentions: false} p.chatMemberCache.Store("oc_chat", &chatMemberEntry{ members: map[string]string{"张三": "ou_zhangsan"}, fetchedAt: time.Now(), }) input := "@张三 请查看" result := p.resolveMentionsInContent(context.Background(), "oc_chat", input) if result != input { t.Fatalf("resolve_mentions=false should not replace, got %q", result) } } func TestResolveMentions_NoAtSign(t *testing.T) { p := &Platform{platformName: "feishu", resolveMentions: true} input := "普通消息没有at" result := p.resolveMentionsInContent(context.Background(), "oc_chat", input) if result != input { t.Fatalf("no @ should return unchanged, got %q", result) } } func TestResolveMentions_DuplicateNameSkipped(t *testing.T) { p := &Platform{platformName: "feishu", resolveMentions: true} p.chatMemberCache.Store("oc_chat", &chatMemberEntry{ members: map[string]string{"张三": "", "李四": "ou_lisi"}, fetchedAt: time.Now(), }) input := "请 @张三 和 @李四 看看" result := p.resolveMentionsInContent(context.Background(), "oc_chat", input) if !strings.Contains(result, "@张三") { t.Fatal("ambiguous name should be kept as-is") } if strings.Contains(result, "@李四") { t.Fatal("unique name should be resolved") } } func TestResolveMentions_SpecialCharsEscaped(t *testing.T) { p := &Platform{platformName: "feishu", resolveMentions: true} p.chatMemberCache.Store("oc_chat", &chatMemberEntry{ members: map[string]string{`A<"B">`: "ou_special"}, fetchedAt: time.Now(), }) input := `@A<"B"> 你好` result := p.resolveMentionsInContent(context.Background(), "oc_chat", input) if strings.Contains(result, `<"B">`) { t.Fatalf("special chars should be escaped, got %q", result) } if !strings.Contains(result, "A<") { t.Fatalf("expected HTML-escaped name, got %q", result) } } type mockRefreshPlatform struct { *Platform refreshCalled atomic.Int32 refreshDone chan struct{} refreshCard func(ctx context.Context, sessionKey string, card *core.Card) error } func newMockRefreshPlatform(p *Platform) *mockRefreshPlatform { return &mockRefreshPlatform{Platform: p, refreshDone: make(chan struct{})} } func (m *mockRefreshPlatform) RefreshCard(ctx context.Context, sessionKey string, card *core.Card) error { m.refreshCalled.Add(1) close(m.refreshDone) if m.refreshCard != nil { return m.refreshCard(ctx, sessionKey, card) } return nil } func TestCardAction_NavFast_ReturnsCard(t *testing.T) { platformAny, err := New(map[string]any{"app_id": "cli_xxx", "app_secret": "secret", "enable_feishu_card": true}) if err != nil { t.Fatalf("New() error = %v", err) } ip := platformAny.(*interactivePlatform) ip.cardNavHandler = func(action string, sessionKey string) *core.Card { return core.NewCard().Markdown("list content").Build() } start := time.Now() resp, err := ip.onCardAction(&callback.CardActionTriggerEvent{ Event: &callback.CardActionTriggerRequest{ Operator: &callback.Operator{OpenID: "ou_test_user"}, Action: &callback.CallBackAction{Value: map[string]any{"action": "nav:/list"}}, Context: &callback.Context{OpenChatID: "oc_test_chat", OpenMessageID: "om_test_message"}, }, }) elapsed := time.Since(start) if err != nil { t.Fatalf("onCardAction() error = %v", err) } if resp == nil || resp.Card == nil { t.Fatalf("expected card response, got %#v", resp) } if elapsed >= cardNavTimeout { t.Fatalf("fast nav should return within cardNavTimeout, took %v", elapsed) } if resp.Toast != nil { t.Fatalf("expected no toast for fast response, got %q", resp.Toast.Content) } } func TestCardAction_NavSlow_ReturnsToastThenRefreshes(t *testing.T) { platformAny, err := New(map[string]any{"app_id": "cli_xxx", "app_secret": "secret", "enable_feishu_card": true}) if err != nil { t.Fatalf("New() error = %v", err) } ip := platformAny.(*interactivePlatform) mock := newMockRefreshPlatform(ip.Platform) ip.Platform.self = mock handlerDone := make(chan struct{}) ip.cardNavHandler = func(action string, sessionKey string) *core.Card { time.Sleep(cardNavTimeout + 200*time.Millisecond) close(handlerDone) return core.NewCard().Markdown("async list content").Build() } start := time.Now() resp, err := ip.onCardAction(&callback.CardActionTriggerEvent{ Event: &callback.CardActionTriggerRequest{ Operator: &callback.Operator{OpenID: "ou_test_user"}, Action: &callback.CallBackAction{Value: map[string]any{"action": "nav:/list"}}, Context: &callback.Context{OpenChatID: "oc_test_chat", OpenMessageID: "om_test_message"}, }, }) elapsed := time.Since(start) if err != nil { t.Fatalf("onCardAction() error = %v", err) } if resp == nil || resp.Toast == nil { t.Fatalf("expected toast response, got %#v", resp) } if elapsed >= 3*time.Second { t.Fatalf("should return within feishu timeout, took %v", elapsed) } if resp.Card != nil { t.Fatalf("expected no card for timeout response, got non-nil card") } select { case <-mock.refreshDone: case <-time.After(5 * time.Second): t.Fatal("RefreshCard should have been called") } if got := mock.refreshCalled.Load(); got != 1 { t.Fatalf("RefreshCard called %d times, want 1", got) } } func TestCardAction_NavSlow_NilCard_NoRefresh(t *testing.T) { platformAny, err := New(map[string]any{"app_id": "cli_xxx", "app_secret": "secret", "enable_feishu_card": true}) if err != nil { t.Fatalf("New() error = %v", err) } ip := platformAny.(*interactivePlatform) mock := newMockRefreshPlatform(ip.Platform) ip.Platform.self = mock ip.cardNavHandler = func(action string, sessionKey string) *core.Card { time.Sleep(cardNavTimeout + 200*time.Millisecond) return nil } resp, err := ip.onCardAction(&callback.CardActionTriggerEvent{ Event: &callback.CardActionTriggerRequest{ Operator: &callback.Operator{OpenID: "ou_test_user"}, Action: &callback.CallBackAction{Value: map[string]any{"action": "nav:/list"}}, Context: &callback.Context{OpenChatID: "oc_test_chat", OpenMessageID: "om_test_message"}, }, }) if err != nil { t.Fatalf("onCardAction() error = %v", err) } if resp == nil || resp.Toast == nil { t.Fatalf("expected toast response, got %#v", resp) } time.Sleep(cardNavTimeout + 500*time.Millisecond) if got := mock.refreshCalled.Load(); got != 0 { t.Fatalf("RefreshCard should not be called for nil card, called %d times", got) } }