1380 lines
42 KiB
Go
1380 lines
42 KiB
Go
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, `<at user_id="ou_zhangsan">张三</at>`) {
|
|
t.Fatalf("expected 张三 to be resolved, got %q", result)
|
|
}
|
|
if !strings.Contains(result, `<at user_id="ou_lisi">李四</at>`) {
|
|
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, "<at") {
|
|
t.Fatalf("unknown member should not be replaced, got %q", result)
|
|
}
|
|
}
|
|
|
|
func TestResolveMentions_LongestMatchFirst(t *testing.T) {
|
|
p := &Platform{platformName: "feishu", resolveMentions: true}
|
|
p.chatMemberCache.Store("oc_chat", &chatMemberEntry{
|
|
members: map[string]string{"张三": "ou_zhangsan", "张三丰": "ou_zhangsanfeng"},
|
|
fetchedAt: time.Now(),
|
|
})
|
|
input := "@张三丰请查看"
|
|
result := p.resolveMentionsInContent(context.Background(), "oc_chat", input)
|
|
if !strings.Contains(result, "ou_zhangsanfeng") {
|
|
t.Fatalf("should match 张三丰 (longest), got %q", result)
|
|
}
|
|
}
|
|
|
|
func TestResolveMentions_CardFormat(t *testing.T) {
|
|
p := &Platform{platformName: "feishu", resolveMentions: true}
|
|
p.chatMemberCache.Store("oc_chat", &chatMemberEntry{
|
|
members: map[string]string{"张三": "ou_zhangsan"},
|
|
fetchedAt: time.Now(),
|
|
})
|
|
// Content with complex markdown triggers card format
|
|
input := "# 巡检报告\n\n@张三 请查看\n\n```\nstatus: ok\n```"
|
|
result := p.resolveMentionsInContent(context.Background(), "oc_chat", input)
|
|
if !strings.Contains(result, "<at id=ou_zhangsan></at>") {
|
|
t.Fatalf("card format should use <at id=...>, 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)
|
|
}
|
|
}
|