1171 lines
33 KiB
Go
1171 lines
33 KiB
Go
package feishu
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/chenhg5/cc-connect/core"
|
|
lark "github.com/larksuite/oapi-sdk-go/v3"
|
|
larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
|
|
)
|
|
|
|
func TestOnMessageRecalledDispatchesCoreRecallMessage(t *testing.T) {
|
|
got := make(chan *core.Message, 1)
|
|
p := &Platform{
|
|
platformName: "feishu",
|
|
handler: func(_ core.Platform, msg *core.Message) {
|
|
got <- msg
|
|
},
|
|
}
|
|
messageID := "om_recalled"
|
|
chatID := "oc_chat"
|
|
recallTime := "1710000000000"
|
|
recallType := "user"
|
|
|
|
err := p.onMessageRecalled(context.Background(), &larkim.P2MessageRecalledV1{
|
|
Event: &larkim.P2MessageRecalledV1Data{
|
|
MessageId: &messageID,
|
|
ChatId: &chatID,
|
|
RecallTime: &recallTime,
|
|
RecallType: &recallType,
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("onMessageRecalled returned error: %v", err)
|
|
}
|
|
|
|
select {
|
|
case msg := <-got:
|
|
if msg.Platform != "feishu" {
|
|
t.Fatalf("Platform = %q, want feishu", msg.Platform)
|
|
}
|
|
if msg.MessageID != messageID {
|
|
t.Fatalf("MessageID = %q, want %q", msg.MessageID, messageID)
|
|
}
|
|
if !msg.Recalled {
|
|
t.Fatal("Recalled = false, want true")
|
|
}
|
|
if msg.Content != "" {
|
|
t.Fatalf("Content = %q, want empty recall payload", msg.Content)
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("timed out waiting for recall message")
|
|
}
|
|
}
|
|
|
|
func TestDispatchMessageDropsRecalledMessageBeforeHandler(t *testing.T) {
|
|
called := false
|
|
p := &Platform{
|
|
platformName: "feishu",
|
|
handler: func(_ core.Platform, _ *core.Message) {
|
|
called = true
|
|
},
|
|
}
|
|
p.markMessageRecalled("om_drop")
|
|
|
|
p.dispatchMessage(
|
|
context.Background(),
|
|
"text",
|
|
`{"text":"hello"}`,
|
|
nil,
|
|
"om_drop",
|
|
"feishu:ou_user:ou_user",
|
|
"",
|
|
"",
|
|
replyContext{messageID: "om_drop", sessionKey: "feishu:ou_user:ou_user"},
|
|
"",
|
|
)
|
|
|
|
if called {
|
|
t.Fatal("handler was called for a message already marked recalled")
|
|
}
|
|
}
|
|
|
|
func TestDispatchMessageIncludesQuotedImage(t *testing.T) {
|
|
const appID = "cli_quote_image"
|
|
const appSecret = "secret-quote-image"
|
|
const parentMessageID = "om_parent_image"
|
|
const imageKey = "img_parent"
|
|
|
|
imageData := []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}
|
|
|
|
tests := []struct {
|
|
name string
|
|
msgType string
|
|
content string
|
|
}{
|
|
{
|
|
name: "text reply",
|
|
msgType: "text",
|
|
content: `{"text":"这是什么图"}`,
|
|
},
|
|
{
|
|
name: "post reply",
|
|
msgType: "post",
|
|
content: `{"content":[[{"tag":"text","text":"这是什么图"}]]}`,
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := make(chan *core.Message, 1)
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch {
|
|
case r.URL.Path == "/open-apis/auth/v3/tenant_access_token/internal":
|
|
w.Header().Set("Content-Type", "application/json")
|
|
writeJSON(t, w, map[string]any{
|
|
"code": 0,
|
|
"msg": "success",
|
|
"expire": 7200,
|
|
"tenant_access_token": "tenant-token",
|
|
})
|
|
case r.URL.Path == "/open-apis/im/v1/messages/"+parentMessageID:
|
|
w.Header().Set("Content-Type", "application/json")
|
|
writeJSON(t, w, map[string]any{
|
|
"code": 0,
|
|
"msg": "success",
|
|
"data": map[string]any{
|
|
"items": []map[string]any{
|
|
{
|
|
"msg_type": "image",
|
|
"parent_id": "",
|
|
"sender": map[string]any{
|
|
"id": "",
|
|
"sender_type": "user",
|
|
},
|
|
"body": map[string]any{
|
|
"content": `{"image_key":"` + imageKey + `"}`,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
case r.URL.Path == "/open-apis/im/v1/messages/"+parentMessageID+"/resources/"+imageKey:
|
|
if r.URL.Query().Get("type") != "image" {
|
|
t.Fatalf("resource type = %q, want image", r.URL.Query().Get("type"))
|
|
}
|
|
w.Header().Set("Content-Type", "image/png")
|
|
if _, err := w.Write(imageData); err != nil {
|
|
t.Fatalf("write image: %v", err)
|
|
}
|
|
default:
|
|
t.Fatalf("unexpected path %s", r.URL.Path)
|
|
}
|
|
}))
|
|
defer srv.Close()
|
|
|
|
p := &Platform{
|
|
platformName: "feishu",
|
|
domain: srv.URL,
|
|
appID: appID,
|
|
appSecret: appSecret,
|
|
client: lark.NewClient(appID, appSecret,
|
|
lark.WithOpenBaseUrl(srv.URL),
|
|
lark.WithHttpClient(srv.Client()),
|
|
),
|
|
handler: func(_ core.Platform, msg *core.Message) {
|
|
got <- msg
|
|
},
|
|
}
|
|
|
|
p.dispatchMessage(
|
|
context.Background(),
|
|
tc.msgType,
|
|
tc.content,
|
|
nil,
|
|
"om_child",
|
|
"feishu:oc_chat:ou_user",
|
|
"",
|
|
"",
|
|
replyContext{messageID: "om_child", sessionKey: "feishu:oc_chat:ou_user"},
|
|
parentMessageID,
|
|
)
|
|
|
|
select {
|
|
case msg := <-got:
|
|
if msg.Content != "这是什么图" {
|
|
t.Fatalf("Content = %q, want question text", msg.Content)
|
|
}
|
|
if !strings.Contains(msg.ExtraContent, "[image]") {
|
|
t.Fatalf("ExtraContent = %q, want quoted image marker", msg.ExtraContent)
|
|
}
|
|
if len(msg.Images) != 1 {
|
|
t.Fatalf("len(Images) = %d, want 1", len(msg.Images))
|
|
}
|
|
if string(msg.Images[0].Data) != string(imageData) {
|
|
t.Fatal("quoted image data did not match downloaded resource")
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("timed out waiting for dispatched message")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDispatchMessageKeepsMentionOnlyQuotedText(t *testing.T) {
|
|
const appID = "cli_quote_text"
|
|
const appSecret = "secret-quote-text"
|
|
const parentMessageID = "om_parent_text"
|
|
|
|
got := make(chan *core.Message, 1)
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch {
|
|
case r.URL.Path == "/open-apis/auth/v3/tenant_access_token/internal":
|
|
w.Header().Set("Content-Type", "application/json")
|
|
writeJSON(t, w, map[string]any{
|
|
"code": 0,
|
|
"msg": "success",
|
|
"expire": 7200,
|
|
"tenant_access_token": "tenant-token",
|
|
})
|
|
case r.URL.Path == "/open-apis/im/v1/messages/"+parentMessageID:
|
|
w.Header().Set("Content-Type", "application/json")
|
|
writeJSON(t, w, map[string]any{
|
|
"code": 0,
|
|
"msg": "success",
|
|
"data": map[string]any{
|
|
"items": []map[string]any{
|
|
{
|
|
"msg_type": "text",
|
|
"parent_id": "",
|
|
"sender": map[string]any{
|
|
"id": "ou_parent",
|
|
"sender_type": "user",
|
|
},
|
|
"body": map[string]any{
|
|
"content": `{"text":"请总结这条消息"}`,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
case strings.HasPrefix(r.URL.Path, "/open-apis/contact/v3/users/"):
|
|
w.Header().Set("Content-Type", "application/json")
|
|
writeJSON(t, w, map[string]any{"code": 0, "msg": "success"})
|
|
case strings.HasPrefix(r.URL.Path, "/open-apis/im/v1/chats/"):
|
|
w.Header().Set("Content-Type", "application/json")
|
|
writeJSON(t, w, map[string]any{"code": 0, "msg": "success"})
|
|
default:
|
|
t.Fatalf("unexpected path %s", r.URL.Path)
|
|
}
|
|
}))
|
|
defer srv.Close()
|
|
|
|
p := &Platform{
|
|
platformName: "feishu",
|
|
domain: srv.URL,
|
|
appID: appID,
|
|
appSecret: appSecret,
|
|
botOpenID: "ou_bot",
|
|
client: lark.NewClient(appID, appSecret,
|
|
lark.WithOpenBaseUrl(srv.URL),
|
|
lark.WithHttpClient(srv.Client()),
|
|
),
|
|
handler: func(_ core.Platform, msg *core.Message) {
|
|
got <- msg
|
|
},
|
|
}
|
|
|
|
p.dispatchMessage(
|
|
context.Background(),
|
|
"text",
|
|
`{"text":"@bot"}`,
|
|
[]*larkim.MentionEvent{
|
|
{Key: strPtr("@bot"), Id: &larkim.UserId{OpenId: strPtr("ou_bot")}, Name: strPtr("Bot")},
|
|
},
|
|
"om_child",
|
|
"feishu:oc_chat:ou_user",
|
|
"ou_user",
|
|
"oc_chat",
|
|
replyContext{messageID: "om_child", sessionKey: "feishu:oc_chat:ou_user"},
|
|
parentMessageID,
|
|
)
|
|
|
|
select {
|
|
case msg := <-got:
|
|
if msg.Content != "" {
|
|
t.Fatalf("Content = %q, want empty user text after bot mention stripping", msg.Content)
|
|
}
|
|
if !strings.Contains(msg.ExtraContent, "请总结这条消息") {
|
|
t.Fatalf("ExtraContent = %q, want quoted text", msg.ExtraContent)
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("timed out waiting for mention-only quoted text message")
|
|
}
|
|
}
|
|
|
|
func TestIsMessageRecalledDetectsWithdrawnMessageFromGetAPI(t *testing.T) {
|
|
const appID = "cli_recall_probe"
|
|
const appSecret = "secret-recall-probe"
|
|
|
|
getCalls := 0
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
switch r.URL.Path {
|
|
case "/open-apis/auth/v3/tenant_access_token/internal":
|
|
writeJSON(t, w, map[string]any{
|
|
"code": 0,
|
|
"msg": "success",
|
|
"expire": 7200,
|
|
"tenant_access_token": "tenant-token",
|
|
})
|
|
case "/open-apis/im/v1/messages/om_withdrawn":
|
|
getCalls++
|
|
writeJSON(t, w, map[string]any{
|
|
"code": 230011,
|
|
"msg": "The message was withdrawn.",
|
|
})
|
|
default:
|
|
t.Fatalf("unexpected path %s", r.URL.Path)
|
|
}
|
|
}))
|
|
defer srv.Close()
|
|
|
|
p := &Platform{
|
|
platformName: "feishu",
|
|
domain: srv.URL,
|
|
appID: appID,
|
|
appSecret: appSecret,
|
|
client: lark.NewClient(appID, appSecret,
|
|
lark.WithOpenBaseUrl(srv.URL),
|
|
lark.WithHttpClient(srv.Client()),
|
|
),
|
|
replayClient: lark.NewClient(appID, appSecret,
|
|
lark.WithEnableTokenCache(false),
|
|
lark.WithOpenBaseUrl(srv.URL),
|
|
lark.WithHttpClient(srv.Client()),
|
|
),
|
|
}
|
|
|
|
recalled, err := p.IsMessageRecalled(context.Background(), replyContext{messageID: "om_withdrawn", chatID: "oc_chat"})
|
|
if err != nil {
|
|
t.Fatalf("IsMessageRecalled() error = %v", err)
|
|
}
|
|
if !recalled {
|
|
t.Fatal("IsMessageRecalled() = false, want true")
|
|
}
|
|
if getCalls != 1 {
|
|
t.Fatalf("getCalls = %d, want 1", getCalls)
|
|
}
|
|
if !p.isMessageRecalled("om_withdrawn") {
|
|
t.Fatal("withdrawn message id was not cached after detection")
|
|
}
|
|
}
|
|
|
|
func TestIsMessageRecalledDetectsDeletedMessageItem(t *testing.T) {
|
|
const appID = "cli_recall_probe"
|
|
const appSecret = "secret-recall-probe"
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
switch r.URL.Path {
|
|
case "/open-apis/auth/v3/tenant_access_token/internal":
|
|
writeJSON(t, w, map[string]any{
|
|
"code": 0,
|
|
"msg": "success",
|
|
"expire": 7200,
|
|
"tenant_access_token": "tenant-token",
|
|
})
|
|
case "/open-apis/im/v1/messages/om_deleted":
|
|
writeJSON(t, w, map[string]any{
|
|
"code": 0,
|
|
"msg": "success",
|
|
"data": map[string]any{
|
|
"items": []map[string]any{
|
|
{
|
|
"message_id": "om_deleted",
|
|
"deleted": true,
|
|
},
|
|
},
|
|
},
|
|
})
|
|
default:
|
|
t.Fatalf("unexpected path %s", r.URL.Path)
|
|
}
|
|
}))
|
|
defer srv.Close()
|
|
|
|
p := &Platform{
|
|
platformName: "feishu",
|
|
domain: srv.URL,
|
|
appID: appID,
|
|
appSecret: appSecret,
|
|
client: lark.NewClient(appID, appSecret,
|
|
lark.WithOpenBaseUrl(srv.URL),
|
|
lark.WithHttpClient(srv.Client()),
|
|
),
|
|
replayClient: lark.NewClient(appID, appSecret,
|
|
lark.WithEnableTokenCache(false),
|
|
lark.WithOpenBaseUrl(srv.URL),
|
|
lark.WithHttpClient(srv.Client()),
|
|
),
|
|
}
|
|
|
|
recalled, err := p.IsMessageRecalled(context.Background(), replyContext{messageID: "om_deleted", chatID: "oc_chat"})
|
|
if err != nil {
|
|
t.Fatalf("IsMessageRecalled() error = %v", err)
|
|
}
|
|
if !recalled {
|
|
t.Fatal("IsMessageRecalled() = false, want true for deleted message item")
|
|
}
|
|
if !p.isMessageRecalled("om_deleted") {
|
|
t.Fatal("deleted message id was not cached after detection")
|
|
}
|
|
}
|
|
|
|
func TestExtractPostParts_TextOnly(t *testing.T) {
|
|
p := &Platform{}
|
|
post := &postLang{
|
|
Title: "标题",
|
|
Content: [][]postElement{
|
|
{
|
|
{Tag: "text", Text: "第一行"},
|
|
{Tag: "text", Text: "接着"},
|
|
},
|
|
{
|
|
{Tag: "text", Text: "第二行"},
|
|
},
|
|
},
|
|
}
|
|
texts, images := p.extractPostParts("", post)
|
|
if len(texts) != 4 {
|
|
t.Fatalf("expected 4 text parts, got %d", len(texts))
|
|
}
|
|
if texts[0] != "标题" {
|
|
t.Errorf("expected title '标题', got %q", texts[0])
|
|
}
|
|
if texts[1] != "第一行" {
|
|
t.Errorf("expected '第一行', got %q", texts[1])
|
|
}
|
|
if len(images) != 0 {
|
|
t.Errorf("expected 0 images, got %d", len(images))
|
|
}
|
|
}
|
|
|
|
func TestExtractPostParts_WithLink(t *testing.T) {
|
|
p := &Platform{}
|
|
post := &postLang{
|
|
Content: [][]postElement{
|
|
{
|
|
{Tag: "text", Text: "点击 "},
|
|
{Tag: "a", Text: "这里", Href: "https://example.com"},
|
|
},
|
|
},
|
|
}
|
|
texts, _ := p.extractPostParts("", post)
|
|
if len(texts) != 2 {
|
|
t.Fatalf("expected 2 text parts, got %d", len(texts))
|
|
}
|
|
if texts[0] != "点击 " || texts[1] != "[这里](https://example.com)" {
|
|
t.Errorf("unexpected texts: %v", texts)
|
|
}
|
|
}
|
|
|
|
func TestExtractPostParts_EmptyContent(t *testing.T) {
|
|
p := &Platform{}
|
|
post := &postLang{}
|
|
texts, images := p.extractPostParts("", post)
|
|
if len(texts) != 0 || len(images) != 0 {
|
|
t.Errorf("expected empty results, got texts=%d images=%d", len(texts), len(images))
|
|
}
|
|
}
|
|
|
|
func TestExtractPostParts_NoTitle(t *testing.T) {
|
|
p := &Platform{}
|
|
post := &postLang{
|
|
Content: [][]postElement{
|
|
{
|
|
{Tag: "text", Text: "只有正文"},
|
|
},
|
|
},
|
|
}
|
|
texts, _ := p.extractPostParts("", post)
|
|
if len(texts) != 1 || texts[0] != "只有正文" {
|
|
t.Errorf("unexpected texts: %v", texts)
|
|
}
|
|
}
|
|
|
|
func TestExtractPostParts_AtMention(t *testing.T) {
|
|
p := &Platform{botOpenID: "ou_bot"}
|
|
post := &postLang{
|
|
Content: [][]postElement{
|
|
{
|
|
{Tag: "text", Text: "hi "},
|
|
{Tag: "at", UserId: "ou_alice", UserName: "Alice"},
|
|
{Tag: "text", Text: " and "},
|
|
{Tag: "at", UserId: "all"},
|
|
{Tag: "text", Text: " but not "},
|
|
{Tag: "at", UserId: "ou_bot", UserName: "Bot"},
|
|
},
|
|
},
|
|
}
|
|
texts, _ := p.extractPostParts("", post)
|
|
joined := strings.Join(texts, "")
|
|
want := "hi @Alice and @all but not "
|
|
if joined != want {
|
|
t.Errorf("want %q, got %q", want, joined)
|
|
}
|
|
}
|
|
|
|
func TestExtractPostParts_Markdown(t *testing.T) {
|
|
p := &Platform{}
|
|
post := &postLang{
|
|
Content: [][]postElement{
|
|
{{Tag: "markdown", Text: "**bold**"}},
|
|
},
|
|
}
|
|
texts, _ := p.extractPostParts("", post)
|
|
if len(texts) != 1 || texts[0] != "**bold**" {
|
|
t.Errorf("unexpected texts: %v", texts)
|
|
}
|
|
}
|
|
|
|
func TestParsePostContent_FlatFormat(t *testing.T) {
|
|
p := &Platform{}
|
|
raw := `{"title":"test","content":[[{"tag":"text","text":"hello"}]]}`
|
|
texts, _ := p.parsePostContent("", raw)
|
|
if len(texts) != 2 || texts[0] != "test" || texts[1] != "hello" {
|
|
t.Errorf("unexpected result: %v", texts)
|
|
}
|
|
}
|
|
|
|
func TestParsePostContent_LangKeyedFormat(t *testing.T) {
|
|
p := &Platform{}
|
|
raw := `{"zh_cn":{"title":"标题","content":[[{"tag":"text","text":"内容"}]]}}`
|
|
texts, _ := p.parsePostContent("", raw)
|
|
if len(texts) != 2 || texts[0] != "标题" || texts[1] != "内容" {
|
|
t.Errorf("unexpected result: %v", texts)
|
|
}
|
|
}
|
|
|
|
func TestParsePostContent_InvalidJSON(t *testing.T) {
|
|
p := &Platform{}
|
|
texts, images := p.parsePostContent("", "not json")
|
|
if texts != nil || images != nil {
|
|
t.Errorf("expected nil results for invalid json")
|
|
}
|
|
}
|
|
|
|
func TestParseInlineMarkdown_Link(t *testing.T) {
|
|
elements := parseInlineMarkdown("visit [Google](https://google.com) now")
|
|
found := false
|
|
for _, el := range elements {
|
|
if el["tag"] == "a" && el["text"] == "Google" && el["href"] == "https://google.com" {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("expected link element, got %v", elements)
|
|
}
|
|
}
|
|
|
|
func TestParseInlineMarkdown_Italic(t *testing.T) {
|
|
elements := parseInlineMarkdown("hello *world*")
|
|
found := false
|
|
for _, el := range elements {
|
|
if styles, ok := el["style"].([]string); ok {
|
|
for _, s := range styles {
|
|
if s == "italic" && el["text"] == "world" {
|
|
found = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("expected italic element, got %v", elements)
|
|
}
|
|
}
|
|
|
|
func TestParseInlineMarkdown_Strikethrough(t *testing.T) {
|
|
elements := parseInlineMarkdown("hello ~~world~~")
|
|
found := false
|
|
for _, el := range elements {
|
|
if styles, ok := el["style"].([]string); ok {
|
|
for _, s := range styles {
|
|
if s == "lineThrough" && el["text"] == "world" {
|
|
found = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("expected strikethrough element, got %v", elements)
|
|
}
|
|
}
|
|
|
|
func TestPreprocessFeishuMarkdown_NewlineBeforeCodeFence(t *testing.T) {
|
|
input := "some text```go\ncode\n```"
|
|
out := preprocessFeishuMarkdown(input)
|
|
if !strings.Contains(out, "text\n```go") {
|
|
t.Errorf("expected newline before code fence, got %q", out)
|
|
}
|
|
}
|
|
|
|
func TestPreprocessFeishuMarkdown_AlreadyNewline(t *testing.T) {
|
|
input := "text\n```go\ncode\n```"
|
|
out := preprocessFeishuMarkdown(input)
|
|
if out != input {
|
|
t.Errorf("should not change content that already has newlines, got %q", out)
|
|
}
|
|
}
|
|
|
|
func TestPreprocessFeishuMarkdown_PreservesTablesAndHeadings(t *testing.T) {
|
|
input := "## Title\n| A | B |\n|---|---|\n> quote"
|
|
out := preprocessFeishuMarkdown(input)
|
|
if !strings.Contains(out, "## Title") {
|
|
t.Errorf("heading should be preserved, got %q", out)
|
|
}
|
|
if !strings.Contains(out, "| A | B |") {
|
|
t.Errorf("table should be preserved, got %q", out)
|
|
}
|
|
if !strings.Contains(out, "> quote") {
|
|
t.Errorf("blockquote should be preserved, got %q", out)
|
|
}
|
|
}
|
|
|
|
func TestHasComplexMarkdown(t *testing.T) {
|
|
if !hasComplexMarkdown("text\n```go\ncode\n```") {
|
|
t.Error("should detect code blocks")
|
|
}
|
|
if !hasComplexMarkdown("| A | B |\n|---|---|") {
|
|
t.Error("should detect tables")
|
|
}
|
|
if hasComplexMarkdown("**bold** and *italic*") {
|
|
t.Error("should not detect simple markdown")
|
|
}
|
|
}
|
|
|
|
func TestCountMarkdownTables(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
want int
|
|
}{
|
|
{"no tables", "hello world", 0},
|
|
{"one table", "| A | B |\n|---|---|\n| 1 | 2 |", 1},
|
|
{"two tables separated by text", "| A |\n|---|\n\nsome text\n\n| B |\n|---|", 2},
|
|
{"consecutive tables no gap", "| A |\n|---|\n| 1 |\n| B |\n|---|", 1},
|
|
{"six tables", "| A |\n|---|\n\nx\n\n| B |\n|---|\n\nx\n\n| C |\n|---|\n\nx\n\n| D |\n|---|\n\nx\n\n| E |\n|---|\n\nx\n\n| F |\n|---|", 6},
|
|
{"table inside code block is still counted", "```\n| A |\n```\n\n| B |\n|---|", 2},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := countMarkdownTables(tt.input)
|
|
if got != tt.want {
|
|
t.Errorf("countMarkdownTables() = %d, want %d", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBuildReplyContent_FallbackWhenManyTables(t *testing.T) {
|
|
// Build content with 6 tables (exceeds the 5-table card limit).
|
|
var sb strings.Builder
|
|
for i := 0; i < 6; i++ {
|
|
if i > 0 {
|
|
sb.WriteString("\n\nsome text\n\n")
|
|
}
|
|
sb.WriteString("| H |\n|---|\n| V |")
|
|
}
|
|
content := sb.String()
|
|
|
|
msgType, _ := buildReplyContent(content)
|
|
if msgType == larkim.MsgTypeInteractive {
|
|
t.Errorf("expected non-card message type for >5 tables, got interactive")
|
|
}
|
|
|
|
// With exactly 5 tables, card should still be used.
|
|
sb.Reset()
|
|
for i := 0; i < 5; i++ {
|
|
if i > 0 {
|
|
sb.WriteString("\n\nsome text\n\n")
|
|
}
|
|
sb.WriteString("| H |\n|---|\n| V |")
|
|
}
|
|
content5 := sb.String()
|
|
|
|
msgType5, _ := buildReplyContent(content5)
|
|
if msgType5 != larkim.MsgTypeInteractive {
|
|
t.Errorf("expected interactive card for 5 tables, got %s", msgType5)
|
|
}
|
|
}
|
|
|
|
func TestParseInlineMarkdown_BoldAndCode(t *testing.T) {
|
|
elements := parseInlineMarkdown("**bold** and `code`")
|
|
hasBold, hasCode := false, false
|
|
for _, el := range elements {
|
|
if styles, ok := el["style"].([]string); ok {
|
|
for _, s := range styles {
|
|
if s == "bold" && el["text"] == "bold" {
|
|
hasBold = true
|
|
}
|
|
if s == "code" && el["text"] == "code" {
|
|
hasCode = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if !hasBold || !hasCode {
|
|
t.Errorf("expected bold and code, got %v", elements)
|
|
}
|
|
}
|
|
|
|
func TestExtractPostPlainText_FlatFormat(t *testing.T) {
|
|
content := `{"title":"公告","content":[[{"tag":"text","text":"第一段"}],[{"tag":"text","text":"第二段"}]]}`
|
|
got := extractPostPlainText(content)
|
|
if got != "公告\n第一段\n第二段" {
|
|
t.Errorf("expected '公告\\n第一段\\n第二段', got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestExtractPostPlainText_LocaleWrapped(t *testing.T) {
|
|
content := `{"zh_cn":{"title":"标题","content":[[{"tag":"text","text":"内容"}]]}}`
|
|
got := extractPostPlainText(content)
|
|
if got != "标题\n内容" {
|
|
t.Errorf("expected '标题\\n内容', got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestExtractPostPlainText_NoTitle(t *testing.T) {
|
|
content := `{"content":[[{"tag":"text","text":"仅内容"}]]}`
|
|
got := extractPostPlainText(content)
|
|
if got != "仅内容" {
|
|
t.Errorf("expected '仅内容', got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestExtractPostPlainText_Empty(t *testing.T) {
|
|
got := extractPostPlainText(`{}`)
|
|
if got != "" {
|
|
t.Errorf("expected empty string, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestExtractPostPlainText_LinkText(t *testing.T) {
|
|
content := `{"content":[[{"tag":"text","text":"hello "},{"tag":"a","text":"link","href":"http://x.com"}]]}`
|
|
got := extractPostPlainText(content)
|
|
if got != "hello [link](http://x.com)" {
|
|
t.Errorf("expected 'hello [link](http://x.com)', got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestExtractPostPlainText_AtMention(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
content string
|
|
want string
|
|
}{
|
|
{"named", `{"content":[[{"tag":"text","text":"hi "},{"tag":"at","user_id":"ou_x","user_name":"Alice"}]]}`, "hi @Alice"},
|
|
{"all", `{"content":[[{"tag":"at","user_id":"all"}]]}`, "@all"},
|
|
{"fallback", `{"content":[[{"tag":"at","user_id":"ou_x"}]]}`, "@user"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if got := extractPostPlainText(tc.content); got != tc.want {
|
|
t.Errorf("want %q, got %q", tc.want, got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestExtractPostPlainText_Markdown(t *testing.T) {
|
|
content := `{"content":[[{"tag":"markdown","text":"**bold** and *italic*"}]]}`
|
|
got := extractPostPlainText(content)
|
|
if got != "**bold** and *italic*" {
|
|
t.Errorf("expected markdown passthrough, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestExtractPostPlainText_CodeBlock(t *testing.T) {
|
|
content := `{"content":[[{"tag":"text","text":"see:"},{"tag":"code_block","language":"go","text":"fmt.Println()"}]]}`
|
|
got := extractPostPlainText(content)
|
|
// Same paragraph: inline elements are concatenated (no extra newline before the fence).
|
|
want := "see:```go\nfmt.Println()\n```"
|
|
if got != want {
|
|
t.Errorf("expected %q, got %q", want, got)
|
|
}
|
|
}
|
|
|
|
func strPtr(s string) *string { return &s }
|
|
|
|
func TestStripMentions(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
text string
|
|
mentions []*larkim.MentionEvent
|
|
botOpenID string
|
|
expected string
|
|
}{
|
|
{
|
|
name: "no mentions",
|
|
text: "hello",
|
|
mentions: nil,
|
|
botOpenID: "",
|
|
expected: "hello",
|
|
},
|
|
{
|
|
name: "bot mention removed",
|
|
text: "@_user_1 /help",
|
|
mentions: []*larkim.MentionEvent{
|
|
{Key: strPtr("@_user_1"), Id: &larkim.UserId{OpenId: strPtr("bot123")}, Name: strPtr("Bot")},
|
|
},
|
|
botOpenID: "bot123",
|
|
expected: "/help",
|
|
},
|
|
{
|
|
name: "non-bot mention replaced with name",
|
|
text: "assign to @_user_2",
|
|
mentions: []*larkim.MentionEvent{
|
|
{Key: strPtr("@_user_2"), Id: &larkim.UserId{OpenId: strPtr("user456")}, Name: strPtr("张三")},
|
|
},
|
|
botOpenID: "bot123",
|
|
expected: "assign to @张三",
|
|
},
|
|
{
|
|
name: "bot removed and other preserved",
|
|
text: "@_user_1 assign to @_user_2",
|
|
mentions: []*larkim.MentionEvent{
|
|
{Key: strPtr("@_user_1"), Id: &larkim.UserId{OpenId: strPtr("bot123")}, Name: strPtr("Bot")},
|
|
{Key: strPtr("@_user_2"), Id: &larkim.UserId{OpenId: strPtr("user456")}, Name: strPtr("张三")},
|
|
},
|
|
botOpenID: "bot123",
|
|
expected: "assign to @张三",
|
|
},
|
|
{
|
|
name: "mention with nil key skipped",
|
|
text: "@_user_1 hello",
|
|
mentions: []*larkim.MentionEvent{
|
|
{Key: nil, Id: &larkim.UserId{OpenId: strPtr("bot123")}},
|
|
},
|
|
botOpenID: "bot123",
|
|
expected: "@_user_1 hello",
|
|
},
|
|
{
|
|
name: "mention with no name fallback removed",
|
|
text: "text @_user_3",
|
|
mentions: []*larkim.MentionEvent{
|
|
{Key: strPtr("@_user_3"), Id: &larkim.UserId{OpenId: strPtr("user789")}, Name: nil},
|
|
},
|
|
botOpenID: "bot123",
|
|
expected: "text",
|
|
},
|
|
{
|
|
name: "empty botOpenID all non-named removed",
|
|
text: "@_user_1 hello",
|
|
mentions: []*larkim.MentionEvent{
|
|
{Key: strPtr("@_user_1"), Id: &larkim.UserId{OpenId: strPtr("someone")}},
|
|
},
|
|
botOpenID: "",
|
|
expected: "hello",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := stripMentions(tt.text, tt.mentions, tt.botOpenID)
|
|
if got != tt.expected {
|
|
t.Errorf("stripMentions() = %q, want %q", got, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestResolveBotSenderName(t *testing.T) {
|
|
p := &Platform{peerBots: map[string]string{
|
|
"cli_known": "Jeeves",
|
|
"cli_other": "Ivy",
|
|
}}
|
|
tests := []struct {
|
|
name string
|
|
appID string
|
|
want string
|
|
}{
|
|
{"empty app id falls back to Bot", "", "Bot"},
|
|
{"known app id resolves to alias", "cli_known", "Jeeves"},
|
|
{"another known app id", "cli_other", "Ivy"},
|
|
{"unknown app id surfaces id", "cli_unknown", "Bot[cli_unknown]"},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := p.resolveBotSenderName(tt.appID)
|
|
if got != tt.want {
|
|
t.Errorf("resolveBotSenderName(%q) = %q, want %q", tt.appID, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestResolveBotSenderName_NilMap(t *testing.T) {
|
|
p := &Platform{}
|
|
if got := p.resolveBotSenderName("cli_any"); got != "Bot[cli_any]" {
|
|
t.Errorf("nil peerBots: got %q, want %q", got, "Bot[cli_any]")
|
|
}
|
|
if got := p.resolveBotSenderName(""); got != "Bot" {
|
|
t.Errorf("nil peerBots + empty id: got %q, want %q", got, "Bot")
|
|
}
|
|
}
|
|
|
|
func TestIsAttachmentMsgType(t *testing.T) {
|
|
tests := []struct {
|
|
msgType string
|
|
want bool
|
|
}{
|
|
{"image", true},
|
|
{"file", true},
|
|
{"audio", true},
|
|
{"media", true},
|
|
{"text", false},
|
|
{"post", false},
|
|
{"sticker", false},
|
|
{"merge_forward", false},
|
|
{"", false},
|
|
}
|
|
for _, tt := range tests {
|
|
if got := isAttachmentMsgType(tt.msgType); got != tt.want {
|
|
t.Errorf("isAttachmentMsgType(%q) = %v, want %v", tt.msgType, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestMarkAndIsActiveThreadSession(t *testing.T) {
|
|
const threadKey = "feishu:oc_chat:root:om_root"
|
|
const directKey = "feishu:oc_chat:ou_user"
|
|
|
|
t.Run("thread isolation disabled is no-op", func(t *testing.T) {
|
|
p := &Platform{threadIsolation: false}
|
|
p.markThreadSessionActive(threadKey)
|
|
if p.isActiveThreadSession(threadKey) {
|
|
t.Fatal("expected no-op when thread_isolation is off")
|
|
}
|
|
})
|
|
|
|
t.Run("non-thread sessionKey is ignored", func(t *testing.T) {
|
|
p := &Platform{threadIsolation: true}
|
|
p.markThreadSessionActive(directKey)
|
|
if p.isActiveThreadSession(directKey) {
|
|
t.Fatal("expected non-thread sessionKey to be ignored")
|
|
}
|
|
})
|
|
|
|
t.Run("thread sessionKey is recorded", func(t *testing.T) {
|
|
p := &Platform{threadIsolation: true}
|
|
if p.isActiveThreadSession(threadKey) {
|
|
t.Fatal("thread should not be active before mark")
|
|
}
|
|
p.markThreadSessionActive(threadKey)
|
|
if !p.isActiveThreadSession(threadKey) {
|
|
t.Fatal("thread should be active after mark")
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestOnMessageThreadIsolationAdmitsAttachmentWithoutMention covers the fix
|
|
// for the case where a user @mentions the bot in a thread, then drops follow-up
|
|
// images into the same thread without re-mentioning. Pre-fix, those images
|
|
// were silently dropped by the group @bot filter; post-fix they should pass
|
|
// through and be dispatched.
|
|
func TestOnMessageThreadIsolationAdmitsAttachmentWithoutMention(t *testing.T) {
|
|
const appID = "cli_thread_admit"
|
|
const appSecret = "secret-thread-admit"
|
|
const botOpenID = "ou_bot"
|
|
const userOpenID = "ou_user"
|
|
const chatID = "oc_chat"
|
|
const rootMsgID = "om_root"
|
|
const imageKey = "img_in_thread"
|
|
|
|
imageBytes := []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch {
|
|
case r.URL.Path == "/open-apis/auth/v3/tenant_access_token/internal":
|
|
w.Header().Set("Content-Type", "application/json")
|
|
writeJSON(t, w, map[string]any{
|
|
"code": 0,
|
|
"msg": "success",
|
|
"expire": 7200,
|
|
"tenant_access_token": "tenant-token",
|
|
})
|
|
case strings.HasSuffix(r.URL.Path, "/resources/"+imageKey):
|
|
w.Header().Set("Content-Type", "image/png")
|
|
if _, err := w.Write(imageBytes); err != nil {
|
|
t.Fatalf("write image: %v", err)
|
|
}
|
|
default:
|
|
// Unknown calls (e.g. user/chat info lookups) return empty success
|
|
// so dispatch can continue past optional metadata fetches.
|
|
w.Header().Set("Content-Type", "application/json")
|
|
writeJSON(t, w, map[string]any{"code": 0, "msg": "success", "data": map[string]any{}})
|
|
}
|
|
}))
|
|
defer srv.Close()
|
|
|
|
received := make(chan *core.Message, 8)
|
|
p := &Platform{
|
|
platformName: "feishu",
|
|
domain: srv.URL,
|
|
appID: appID,
|
|
appSecret: appSecret,
|
|
botOpenID: botOpenID,
|
|
threadIsolation: true,
|
|
dedup: &core.MessageDedup{},
|
|
client: lark.NewClient(appID, appSecret,
|
|
lark.WithOpenBaseUrl(srv.URL),
|
|
lark.WithHttpClient(srv.Client()),
|
|
),
|
|
handler: func(_ core.Platform, msg *core.Message) {
|
|
received <- msg
|
|
},
|
|
}
|
|
|
|
chatType := "group"
|
|
senderType := "user"
|
|
now := time.Now().UnixMilli()
|
|
createTime := func() *string {
|
|
s := strconv.FormatInt(now, 10)
|
|
now++
|
|
return &s
|
|
}
|
|
|
|
buildEvent := func(msgID, msgType, content string, mentions []*larkim.MentionEvent, rootID string) *larkim.P2MessageReceiveV1 {
|
|
ev := &larkim.P2MessageReceiveV1{
|
|
Event: &larkim.P2MessageReceiveV1Data{
|
|
Sender: &larkim.EventSender{
|
|
SenderId: &larkim.UserId{OpenId: stringPtr(userOpenID)},
|
|
SenderType: &senderType,
|
|
},
|
|
Message: &larkim.EventMessage{
|
|
MessageId: stringPtr(msgID),
|
|
ChatId: stringPtr(chatID),
|
|
ChatType: &chatType,
|
|
MessageType: stringPtr(msgType),
|
|
Content: stringPtr(content),
|
|
CreateTime: createTime(),
|
|
Mentions: mentions,
|
|
},
|
|
},
|
|
}
|
|
if rootID != "" {
|
|
ev.Event.Message.RootId = stringPtr(rootID)
|
|
}
|
|
return ev
|
|
}
|
|
|
|
botMention := []*larkim.MentionEvent{
|
|
{
|
|
Key: stringPtr("@_user_1"),
|
|
Id: &larkim.UserId{OpenId: stringPtr(botOpenID)},
|
|
Name: stringPtr("claude code"),
|
|
},
|
|
}
|
|
|
|
// Step 1: opening message @mentions the bot — establishes the thread.
|
|
if err := p.onMessage(context.Background(), buildEvent(rootMsgID, "text", `{"text":"@_user_1 看看这个"}`, botMention, "")); err != nil {
|
|
t.Fatalf("onMessage(root) error = %v", err)
|
|
}
|
|
threadKey := "feishu:" + chatID + ":root:" + rootMsgID
|
|
if !p.isActiveThreadSession(threadKey) {
|
|
t.Fatalf("thread %q should be marked active after @bot text", threadKey)
|
|
}
|
|
select {
|
|
case <-received:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("timed out waiting for the opening @bot text to dispatch")
|
|
}
|
|
|
|
// Step 2: follow-up image in the same thread, no @mention — should pass through.
|
|
imgContent := `{"image_key":"` + imageKey + `"}`
|
|
if err := p.onMessage(context.Background(), buildEvent("om_thread_img", "image", imgContent, nil, rootMsgID)); err != nil {
|
|
t.Fatalf("onMessage(image in thread) error = %v", err)
|
|
}
|
|
select {
|
|
case msg := <-received:
|
|
if msg.MessageID != "om_thread_img" {
|
|
t.Fatalf("expected dispatched image om_thread_img, got %q", msg.MessageID)
|
|
}
|
|
if len(msg.Images) != 1 {
|
|
t.Fatalf("expected dispatched image to carry 1 attachment, got %d", len(msg.Images))
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("expected image in active thread to be dispatched without @mention")
|
|
}
|
|
|
|
// Step 3: image in a *different* thread without @mention — should still be dropped.
|
|
if err := p.onMessage(context.Background(), buildEvent("om_other_img", "image", imgContent, nil, "om_other_root")); err != nil {
|
|
t.Fatalf("onMessage(image in unrelated thread) error = %v", err)
|
|
}
|
|
select {
|
|
case msg := <-received:
|
|
t.Fatalf("image in unrelated thread should be dropped, but handler received %q", msg.MessageID)
|
|
case <-time.After(300 * time.Millisecond):
|
|
// expected: nothing dispatched
|
|
}
|
|
|
|
// Step 4: text without @mention in the active thread — should be dropped
|
|
// (only attachments are admitted; otherwise unrelated thread chatter would
|
|
// flood the agent).
|
|
if err := p.onMessage(context.Background(), buildEvent("om_thread_text", "text", `{"text":"刚才那张图能看到吗"}`, nil, rootMsgID)); err != nil {
|
|
t.Fatalf("onMessage(text in thread without mention) error = %v", err)
|
|
}
|
|
select {
|
|
case msg := <-received:
|
|
t.Fatalf("text in active thread without @mention should be dropped, got %q", msg.MessageID)
|
|
case <-time.After(300 * time.Millisecond):
|
|
// expected
|
|
}
|
|
}
|
|
|
|
func extractBasePlatform(p core.Platform) *Platform {
|
|
if fp, ok := p.(*Platform); ok {
|
|
return fp
|
|
}
|
|
if ip, ok := p.(*interactivePlatform); ok {
|
|
return ip.Platform
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func TestNewPlatform_RequireMentionFalseAliasesGroupReplyAll(t *testing.T) {
|
|
// Regression test for #1141: users set require_mention = false but feishu
|
|
// reads group_reply_all. The two options must be equivalent so that
|
|
// group messages without @mention are NOT silently dropped.
|
|
p, err := newPlatform("feishu", lark.FeishuBaseUrl, map[string]any{
|
|
"app_id": "cli_test",
|
|
"app_secret": "secret",
|
|
"require_mention": false,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("newPlatform error: %v", err)
|
|
}
|
|
fp := extractBasePlatform(p)
|
|
if fp == nil {
|
|
t.Fatal("expected *Platform or *interactivePlatform")
|
|
}
|
|
if !fp.groupReplyAll {
|
|
t.Error("require_mention=false should set groupReplyAll=true, but it is false")
|
|
}
|
|
}
|
|
|
|
func TestNewPlatform_RequireMentionTrueDoesNotForceGroupReplyAll(t *testing.T) {
|
|
// require_mention = true (the default) must NOT set groupReplyAll.
|
|
p, err := newPlatform("feishu", lark.FeishuBaseUrl, map[string]any{
|
|
"app_id": "cli_test",
|
|
"app_secret": "secret",
|
|
"require_mention": true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("newPlatform error: %v", err)
|
|
}
|
|
fp := extractBasePlatform(p)
|
|
if fp == nil {
|
|
t.Fatal("expected *Platform or *interactivePlatform")
|
|
}
|
|
if fp.groupReplyAll {
|
|
t.Error("require_mention=true should leave groupReplyAll=false, but it is true")
|
|
}
|
|
}
|