Files
2026-06-02 23:14:41 +08:00

1386 lines
37 KiB
Go

package qqbot
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/chenhg5/cc-connect/core"
)
func TestPlatform_Name(t *testing.T) {
p := &Platform{}
if got := p.Name(); got != "qqbot" {
t.Errorf("Name() = %q, want %q", got, "qqbot")
}
}
func TestNew_MissingAppID(t *testing.T) {
_, err := New(map[string]any{
"app_secret": "test-secret",
})
if err == nil {
t.Error("expected error for missing app_id, got nil")
}
}
func TestNew_MissingAppSecret(t *testing.T) {
_, err := New(map[string]any{
"app_id": "test-app-id",
})
if err == nil {
t.Error("expected error for missing app_secret, got nil")
}
}
func TestNew_MissingBoth(t *testing.T) {
_, err := New(map[string]any{})
if err == nil {
t.Error("expected error for missing credentials, got nil")
}
}
func TestNew_WithValidCredentials(t *testing.T) {
p, err := New(map[string]any{
"app_id": "test-app-id",
"app_secret": "test-secret",
})
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if p == nil {
t.Fatal("expected platform, got nil")
}
if p.Name() != "qqbot" {
t.Errorf("Name() = %q, want %q", p.Name(), "qqbot")
}
}
func TestNew_Sandbox(t *testing.T) {
p, err := New(map[string]any{
"app_id": "test-app-id",
"app_secret": "test-secret",
"sandbox": true,
})
if err != nil {
t.Errorf("unexpected error: %v", err)
}
platform := p.(*Platform)
if !platform.sandbox {
t.Error("sandbox = false, want true")
}
}
func TestNew_DefaultIntents(t *testing.T) {
p, err := New(map[string]any{
"app_id": "test-app-id",
"app_secret": "test-secret",
})
if err != nil {
t.Errorf("unexpected error: %v", err)
}
platform := p.(*Platform)
want := (1 << 25) | (1 << 26) // GROUP_AT_MESSAGE_CREATE | INTERACTION_CREATE
if platform.intents != want {
t.Errorf("intents = %d, want %d", platform.intents, want)
}
}
func TestNew_CustomIntents(t *testing.T) {
p, err := New(map[string]any{
"app_id": "test-app-id",
"app_secret": "test-secret",
"intents": 1 << 20,
})
if err != nil {
t.Errorf("unexpected error: %v", err)
}
platform := p.(*Platform)
if platform.intents != 1<<20 {
t.Errorf("intents = %d, want %d", platform.intents, 1<<20)
}
}
func TestNew_IntentsAsFloat(t *testing.T) {
p, err := New(map[string]any{
"app_id": "test-app-id",
"app_secret": "test-secret",
"intents": float64(1 << 18),
})
if err != nil {
t.Errorf("unexpected error: %v", err)
}
platform := p.(*Platform)
if platform.intents != 1<<18 {
t.Errorf("intents = %d, want %d", platform.intents, 1<<18)
}
}
func TestNew_WithAllowFrom(t *testing.T) {
p, err := New(map[string]any{
"app_id": "test-app-id",
"app_secret": "test-secret",
"allow_from": "user1,user2",
})
if err != nil {
t.Errorf("unexpected error: %v", err)
}
platform := p.(*Platform)
if platform.allowFrom != "user1,user2" {
t.Errorf("allowFrom = %q, want %q", platform.allowFrom, "user1,user2")
}
}
func TestNew_ShareSessionInChannel(t *testing.T) {
p, err := New(map[string]any{
"app_id": "test-app-id",
"app_secret": "test-secret",
"share_session_in_channel": true,
})
if err != nil {
t.Errorf("unexpected error: %v", err)
}
platform := p.(*Platform)
if !platform.shareSessionInChannel {
t.Error("shareSessionInChannel = false, want true")
}
}
func TestNew_MarkdownSupport(t *testing.T) {
p, err := New(map[string]any{
"app_id": "test-app-id",
"app_secret": "test-secret",
"markdown_support": true,
})
if err != nil {
t.Errorf("unexpected error: %v", err)
}
platform := p.(*Platform)
if !platform.markdownSupport {
t.Error("markdownSupport = false, want true")
}
}
func TestPrependQuotedMessage(t *testing.T) {
got := prependQuotedMessage("上一条内容", "现在这条")
want := "[引用消息]\n上一条内容\n\n现在这条"
if got != want {
t.Fatalf("got %q want %q", got, want)
}
}
func TestResolveQuotedText_FromCache(t *testing.T) {
p := &Platform{
messageCache: map[string]cachedMessage{
"msg-1": {Content: "缓存里的原文", UpdatedAt: time.Now()},
},
}
got := p.resolveQuotedText(&messageReference{MessageID: "msg-1"})
if got != "缓存里的原文" {
t.Fatalf("got %q", got)
}
}
func TestHandleC2CMessage_WithMessageReference(t *testing.T) {
p := &Platform{
allowFrom: "*",
messageCache: map[string]cachedMessage{
"msg-ref": {Content: "被引用的那条", UpdatedAt: time.Now()},
},
}
var got *core.Message
p.handler = func(_ core.Platform, msg *core.Message) {
got = msg
}
payload := map[string]any{
"id": "msg-new",
"content": "现在这条",
"timestamp": time.Now().Format(time.RFC3339),
"author": map[string]any{
"user_openid": "user-1",
},
"message_reference": map[string]any{
"message_id": "msg-ref",
},
}
data, err := json.Marshal(payload)
if err != nil {
t.Fatal(err)
}
p.handleC2CMessage(data)
if got == nil {
t.Fatal("expected message")
}
want := "[引用消息]\n被引用的那条\n\n现在这条"
if got.Content != want {
t.Fatalf("content = %q want %q", got.Content, want)
}
if cached := p.messageCache["msg-new"].Content; cached != want {
t.Fatalf("cached content = %q want %q", cached, want)
}
}
// verify Platform implements core.Platform
var _ core.Platform = (*Platform)(nil)
func TestDownloadAttachmentImages_ChecksStatusCode(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = fmt.Fprint(w, "not found")
}))
defer server.Close()
origClient := core.HTTPClient
t.Cleanup(func() { core.HTTPClient = origClient })
core.HTTPClient = server.Client()
attachments := []attachment{
{ContentType: "image/png", URL: server.URL + "/image.png"},
}
images := downloadAttachmentImages(attachments)
if len(images) != 0 {
t.Fatalf("expected 0 images on non-200 status, got %d", len(images))
}
}
func TestDownloadAttachmentImages_Success(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/png")
w.Write([]byte("fake-png-data"))
}))
defer server.Close()
origClient := core.HTTPClient
t.Cleanup(func() { core.HTTPClient = origClient })
core.HTTPClient = server.Client()
attachments := []attachment{
{ContentType: "image/png", URL: server.URL + "/image.png", Filename: "test.png"},
}
images := downloadAttachmentImages(attachments)
if len(images) != 1 {
t.Fatalf("expected 1 image, got %d", len(images))
}
if string(images[0].Data) != "fake-png-data" {
t.Fatalf("image data = %q, want %q", string(images[0].Data), "fake-png-data")
}
if images[0].FileName != "test.png" {
t.Fatalf("filename = %q, want %q", images[0].FileName, "test.png")
}
}
func TestDownloadAttachmentFiles_ChecksStatusCode(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, _ = fmt.Fprint(w, "internal error")
}))
defer server.Close()
origClient := core.HTTPClient
t.Cleanup(func() { core.HTTPClient = origClient })
core.HTTPClient = server.Client()
attachments := []attachment{
{ContentType: "application/pdf", URL: server.URL + "/file.pdf"},
}
files := downloadAttachmentFiles(attachments)
if len(files) != 0 {
t.Fatalf("expected 0 files on non-200 status, got %d", len(files))
}
}
func TestDownloadAttachmentFiles_Success(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/pdf")
w.Write([]byte("fake-pdf-data"))
}))
defer server.Close()
origClient := core.HTTPClient
t.Cleanup(func() { core.HTTPClient = origClient })
core.HTTPClient = server.Client()
attachments := []attachment{
{ContentType: "application/pdf", URL: server.URL + "/file.pdf", Filename: "doc.pdf"},
}
files := downloadAttachmentFiles(attachments)
if len(files) != 1 {
t.Fatalf("expected 1 file, got %d", len(files))
}
if string(files[0].Data) != "fake-pdf-data" {
t.Fatalf("file data = %q, want %q", string(files[0].Data), "fake-pdf-data")
}
if files[0].FileName != "doc.pdf" {
t.Fatalf("filename = %q, want %q", files[0].FileName, "doc.pdf")
}
}
func TestDownloadAttachmentFiles_SkipsImages(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("data"))
}))
defer server.Close()
origClient := core.HTTPClient
t.Cleanup(func() { core.HTTPClient = origClient })
core.HTTPClient = server.Client()
attachments := []attachment{
{ContentType: "image/png", URL: server.URL + "/image.png"},
{ContentType: "application/pdf", URL: server.URL + "/file.pdf"},
}
// Verify that downloadAttachmentFiles skips image content types
files := downloadAttachmentFiles(attachments)
for _, f := range files {
if f.MimeType == "image/png" {
t.Fatal("expected no image files in downloadAttachmentFiles result")
}
}
}
func TestDownloadAttachmentFiles_SkipsEmptyURL(t *testing.T) {
attachments := []attachment{
{ContentType: "application/pdf", URL: ""},
}
files := downloadAttachmentFiles(attachments)
if len(files) != 0 {
t.Fatalf("expected 0 files for empty URL, got %d", len(files))
}
}
func TestUploadRichMedia_IncludesFileNameForFileType4(t *testing.T) {
var receivedBody map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Handle token request
if r.URL.Path == "/app/getAppAccessToken" {
_, _ = fmt.Fprint(w, `{"access_token":"test-token","expires_in":"7200"}`)
return
}
// Handle file upload request
_ = json.NewDecoder(r.Body).Decode(&receivedBody)
_, _ = fmt.Fprint(w, `{"file_info":"test-file-info"}`)
}))
defer server.Close()
origClient := core.HTTPClient
t.Cleanup(func() { core.HTTPClient = origClient })
core.HTTPClient = server.Client()
origTokenURL := tokenURL
origApiBaseProduction := apiBaseProduction
tokenURL = server.URL + "/app/getAppAccessToken"
apiBaseProduction = server.URL
t.Cleanup(func() {
tokenURL = origTokenURL
apiBaseProduction = origApiBaseProduction
})
p := &Platform{
sandbox: false,
token: "test-token",
tokenExpiry: time.Now().Add(time.Hour),
}
rctx := &replyContext{
messageType: "c2c",
userOpenID: "user-123",
}
fileInfo, err := p.uploadRichMedia(rctx, 4, []byte("file-data"), "document.pdf")
if err != nil {
t.Fatalf("uploadRichMedia returned error: %v", err)
}
if fileInfo != "test-file-info" {
t.Fatalf("fileInfo = %q, want %q", fileInfo, "test-file-info")
}
if receivedBody["file_name"] != "document.pdf" {
t.Fatalf("file_name = %v, want %q", receivedBody["file_name"], "document.pdf")
}
if receivedBody["file_type"].(float64) != 4 {
t.Fatalf("file_type = %v, want 4", receivedBody["file_type"])
}
}
func TestUploadRichMedia_NoFileNameForOtherFileTypes(t *testing.T) {
var receivedBody map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Handle token request
if r.URL.Path == "/app/getAppAccessToken" {
_, _ = fmt.Fprint(w, `{"access_token":"test-token","expires_in":"7200"}`)
return
}
// Handle file upload request
_ = json.NewDecoder(r.Body).Decode(&receivedBody)
_, _ = fmt.Fprint(w, `{"file_info":"test-file-info"}`)
}))
defer server.Close()
origClient := core.HTTPClient
t.Cleanup(func() { core.HTTPClient = origClient })
core.HTTPClient = server.Client()
origTokenURL := tokenURL
origApiBaseProduction := apiBaseProduction
tokenURL = server.URL + "/app/getAppAccessToken"
apiBaseProduction = server.URL
t.Cleanup(func() {
tokenURL = origTokenURL
apiBaseProduction = origApiBaseProduction
})
p := &Platform{
sandbox: false,
token: "test-token",
tokenExpiry: time.Now().Add(time.Hour),
}
rctx := &replyContext{
messageType: "c2c",
userOpenID: "user-123",
}
// fileType 1 (image) should NOT include file_name
fileInfo, err := p.uploadRichMedia(rctx, 1, []byte("image-data"), "")
if err != nil {
t.Fatalf("uploadRichMedia returned error: %v", err)
}
if fileInfo != "test-file-info" {
t.Fatalf("fileInfo = %q, want %q", fileInfo, "test-file-info")
}
if _, hasFileName := receivedBody["file_name"]; hasFileName {
t.Fatalf("expected no file_name for fileType 1, got %v", receivedBody["file_name"])
}
}
func TestQuotedTextFromElements(t *testing.T) {
tests := []struct {
name string
elements []msgElement
want string
}{
{
name: "empty elements",
elements: nil,
want: "",
},
{
name: "element with content",
elements: []msgElement{{Content: "被引用的消息"}},
want: "被引用的消息",
},
{
name: "element with whitespace content",
elements: []msgElement{{Content: " "}},
want: "",
},
{
name: "element with only attachments",
elements: []msgElement{
{Attachments: []attachment{{ContentType: "image/png", URL: "https://example.com/img.png"}}},
},
want: "[图片]",
},
{
name: "content takes priority over attachments",
elements: []msgElement{
{
Content: "有内容的消息",
Attachments: []attachment{{ContentType: "image/png", URL: "https://example.com/img.png"}},
},
},
want: "有内容的消息",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := quotedTextFromElements(tt.elements)
if got != tt.want {
t.Errorf("quotedTextFromElements() = %q, want %q", got, tt.want)
}
})
}
}
func TestHandleC2CMessage_QuoteFromMsgElements(t *testing.T) {
p := &Platform{
allowFrom: "*",
messageCache: map[string]cachedMessage{},
}
var got *core.Message
p.handler = func(_ core.Platform, msg *core.Message) {
got = msg
}
// Simulate a quote message (message_type=103) with msg_elements[0] containing the quoted content
msgType := 103
payload := map[string]any{
"id": "msg-new",
"content": "我的回复",
"timestamp": time.Now().Format(time.RFC3339),
"author": map[string]any{
"user_openid": "user-1",
},
"message_type": msgType,
"msg_elements": []map[string]any{
{"content": "这是被引用的消息内容"},
},
}
data, err := json.Marshal(payload)
if err != nil {
t.Fatal(err)
}
p.handleC2CMessage(data)
if got == nil {
t.Fatal("expected message")
}
want := "[引用消息]\n这是被引用的消息内容\n\n我的回复"
if got.Content != want {
t.Fatalf("content = %q, want %q", got.Content, want)
}
}
func TestHandleGroupMessage_QuoteFromMsgElements(t *testing.T) {
p := &Platform{
allowFrom: "*",
messageCache: map[string]cachedMessage{},
}
var got *core.Message
p.handler = func(_ core.Platform, msg *core.Message) {
got = msg
}
// Simulate a group quote message (message_type=103) with msg_elements[0]
msgType := 103
payload := map[string]any{
"id": "msg-new",
"group_openid": "group-1",
"content": "<@!bot123> 看看这个",
"timestamp": time.Now().Format(time.RFC3339),
"message_type": msgType,
"msg_elements": []map[string]any{
{"content": "之前的讨论内容"},
},
"author": map[string]any{
"member_openid": "user-1",
},
}
data, err := json.Marshal(payload)
if err != nil {
t.Fatal(err)
}
p.handleGroupMessage(data)
if got == nil {
t.Fatal("expected message")
}
want := "[引用消息]\n之前的讨论内容\n\n看看这个"
if got.Content != want {
t.Fatalf("content = %q, want %q", got.Content, want)
}
}
// ---------------------------------------------------------------------------
// SendWithButtons tests
// ---------------------------------------------------------------------------
func TestSendWithButtons_GroupMessage(t *testing.T) {
var receivedBody map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewDecoder(r.Body).Decode(&receivedBody)
w.WriteHeader(http.StatusOK)
_, _ = fmt.Fprint(w, `{"id": "msg-out"}`)
}))
defer server.Close()
origClient := core.HTTPClient
t.Cleanup(func() { core.HTTPClient = origClient })
core.HTTPClient = server.Client()
origProd := apiBaseProduction
apiBaseProduction = server.URL
t.Cleanup(func() { apiBaseProduction = origProd })
p := &Platform{
token: "test-token",
tokenExpiry: time.Now().Add(time.Hour),
}
ctx := context.Background()
rctx := &replyContext{
messageType: "group",
groupOpenID: "group-1",
userOpenID: "user-1",
eventMsgID: "evt-123",
sessionKey: "qqbot:group-1:user-1",
}
buttons := [][]core.ButtonOption{
{
{Text: "允许", Data: "perm:allow"},
{Text: "拒绝", Data: "perm:deny"},
},
{
{Text: "允许所有", Data: "perm:allow_all"},
},
}
err := p.SendWithButtons(ctx, rctx, "权限请求", buttons)
if err != nil {
t.Fatalf("SendWithButtons returned error: %v", err)
}
if receivedBody == nil {
t.Fatal("expected API request body")
}
// Check basic message fields
if got, ok := receivedBody["content"].(string); !ok || got != "权限请求" {
t.Errorf("content = %v, want %q", receivedBody["content"], "权限请求")
}
if got, ok := receivedBody["msg_type"].(float64); !ok || got != 0 {
t.Errorf("msg_type = %v, want 0", receivedBody["msg_type"])
}
if got, ok := receivedBody["msg_id"].(string); !ok || got != "evt-123" {
t.Errorf("msg_id = %v, want %q", receivedBody["msg_id"], "evt-123")
}
if _, ok := receivedBody["msg_seq"]; !ok {
t.Error("msg_seq is missing")
}
// Check keyboard structure
keyboard, ok := receivedBody["keyboard"].(map[string]any)
if !ok {
t.Fatal("keyboard is missing or not a map")
}
content, ok := keyboard["content"].(map[string]any)
if !ok {
t.Fatal("keyboard.content is missing or not a map")
}
rows, ok := content["rows"].([]any)
if !ok {
t.Fatal("keyboard.content.rows is missing or not a slice")
}
if len(rows) != 2 {
t.Fatalf("expected 2 rows, got %d", len(rows))
}
// Row 0: two buttons (allow, deny)
row0, _ := rows[0].(map[string]any)
btns0, _ := row0["buttons"].([]any)
if len(btns0) != 2 {
t.Fatalf("row 0: expected 2 buttons, got %d", len(btns0))
}
// Button 0: allow
btn0, _ := btns0[0].(map[string]any)
rd0, _ := btn0["render_data"].(map[string]any)
if rd0["label"] != "允许" {
t.Errorf("allow button label = %v, want %q", rd0["label"], "允许")
}
if rd0["visited_label"] != "已允许" {
t.Errorf("allow button visited_label = %v", rd0["visited_label"])
}
act0, _ := btn0["action"].(map[string]any)
if data, ok := act0["data"].(string); !ok || !strings.HasPrefix(data, "perm:allow:qqbot:group-1:user-1") {
t.Errorf("allow button_data = %v", act0["data"])
}
if act0["type"] != float64(1) {
t.Errorf("allow action type = %v, want 1", act0["type"])
}
if btn0["group_id"] != "perm" {
t.Errorf("allow button group_id = %v", btn0["group_id"])
}
// Button 1: deny
btn1, _ := btns0[1].(map[string]any)
rd1, _ := btn1["render_data"].(map[string]any)
if rd1["label"] != "拒绝" {
t.Errorf("deny button label = %v", rd1["label"])
}
if rd1["visited_label"] != "已拒绝" {
t.Errorf("deny button visited_label = %v", rd1["visited_label"])
}
if style, ok := rd1["style"].(float64); !ok || style != 0 {
t.Errorf("deny button style = %v, want 0 (grey)", style)
}
act1, _ := btn1["action"].(map[string]any)
if data, ok := act1["data"].(string); !ok || !strings.HasPrefix(data, "perm:deny:qqbot:group-1:user-1") {
t.Errorf("deny button_data = %v", act1["data"])
}
// Row 1: allow_all button
row1, _ := rows[1].(map[string]any)
btns1, _ := row1["buttons"].([]any)
if len(btns1) != 1 {
t.Fatalf("row 1: expected 1 button, got %d", len(btns1))
}
btn2, _ := btns1[0].(map[string]any)
rd2, _ := btn2["render_data"].(map[string]any)
if rd2["label"] != "允许所有" {
t.Errorf("allow_all button label = %v", rd2["label"])
}
if rd2["visited_label"] != "已始终允许" {
t.Errorf("allow_all button visited_label = %v", rd2["visited_label"])
}
act2, _ := btn2["action"].(map[string]any)
if data, ok := act2["data"].(string); !ok || !strings.HasPrefix(data, "perm:allow_all:qqbot:group-1:user-1") {
t.Errorf("allow_all button_data = %v", act2["data"])
}
}
func TestSendWithButtons_C2CMessage(t *testing.T) {
var receivedBody map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewDecoder(r.Body).Decode(&receivedBody)
w.WriteHeader(http.StatusOK)
_, _ = fmt.Fprint(w, `{"id": "msg-out"}`)
}))
defer server.Close()
origClient := core.HTTPClient
t.Cleanup(func() { core.HTTPClient = origClient })
core.HTTPClient = server.Client()
origProd := apiBaseProduction
apiBaseProduction = server.URL
t.Cleanup(func() { apiBaseProduction = origProd })
p := &Platform{
token: "test-token",
tokenExpiry: time.Now().Add(time.Hour),
}
rctx := &replyContext{
messageType: "c2c",
userOpenID: "user-1",
sessionKey: "qqbot:user-1",
}
buttons := [][]core.ButtonOption{
{{Text: "允许", Data: "perm:allow"}},
}
err := p.SendWithButtons(context.Background(), rctx, "test", buttons)
if err != nil {
t.Fatalf("SendWithButtons returned error: %v", err)
}
// Verify C2C session key in button_data
keyboard := receivedBody["keyboard"].(map[string]any)
content := keyboard["content"].(map[string]any)
rows := content["rows"].([]any)
row0 := rows[0].(map[string]any)
btns0 := row0["buttons"].([]any)
btn0 := btns0[0].(map[string]any)
act0 := btn0["action"].(map[string]any)
if data, ok := act0["data"].(string); !ok || !strings.HasPrefix(data, "perm:allow:qqbot:user-1") {
t.Errorf("C2C button_data = %v, want prefix perm:allow:qqbot:user-1", act0["data"])
}
}
// TestSendWithButtons_ShareSessionInChannel verifies that when shareSessionInChannel
// is enabled, the button_data embedded session key uses the "qqbot:g:" prefix
// so it matches the session key the engine uses for shared channel sessions.
func TestSendWithButtons_ShareSessionInChannel(t *testing.T) {
var receivedBody map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewDecoder(r.Body).Decode(&receivedBody)
w.WriteHeader(http.StatusOK)
_, _ = fmt.Fprint(w, `{"id": "msg-out"}`)
}))
defer server.Close()
origClient := core.HTTPClient
t.Cleanup(func() { core.HTTPClient = origClient })
core.HTTPClient = server.Client()
origProd := apiBaseProduction
apiBaseProduction = server.URL
t.Cleanup(func() { apiBaseProduction = origProd })
p := &Platform{
token: "test-token",
tokenExpiry: time.Now().Add(time.Hour),
}
// Simulate replyContext built with shareSessionInChannel = true
// (sessionKey would be "qqbot:g:<groupOpenID>")
rctx := &replyContext{
messageType: "group",
groupOpenID: "group-1",
userOpenID: "user-1",
eventMsgID: "evt-123",
sessionKey: "qqbot:g:group-1",
}
err := p.SendWithButtons(context.Background(), rctx, "test", [][]core.ButtonOption{
{{Text: "允许", Data: "perm:allow"}},
})
if err != nil {
t.Fatalf("SendWithButtons returned error: %v", err)
}
keyboard := receivedBody["keyboard"].(map[string]any)
content := keyboard["content"].(map[string]any)
rows := content["rows"].([]any)
row0 := rows[0].(map[string]any)
btns0 := row0["buttons"].([]any)
btn0 := btns0[0].(map[string]any)
act0 := btn0["action"].(map[string]any)
// Must use the shared session key from replyContext, not the default group key
if data, ok := act0["data"].(string); !ok || data != "perm:allow:qqbot:g:group-1" {
t.Errorf("button_data = %v, want perm:allow:qqbot:g:group-1", act0["data"])
}
}
// TestSendWithButtons_EmptySessionKey verifies that SendWithButtons returns
// an error when the replyContext has no sessionKey (e.g., constructed externally).
func TestSendWithButtons_EmptySessionKey(t *testing.T) {
p := &Platform{
token: "test-token",
tokenExpiry: time.Now().Add(time.Hour),
}
rctx := &replyContext{
messageType: "group",
groupOpenID: "group-1",
userOpenID: "user-1",
// sessionKey intentionally empty
}
err := p.SendWithButtons(context.Background(), rctx, "test", nil)
if err == nil {
t.Fatal("expected error for empty sessionKey, got nil")
}
}
// TestHandleInteractionCreate_RouterPermission is a table-driven test covering
// all permission decision routing for INTERACTION_CREATE events.
func TestHandleInteractionCreate_RouterPermission(t *testing.T) {
tests := []struct {
name string
buttonData string
chatType int
wantContent string
wantSession string
wantMsgType string // "group" or "c2c"
wantCall bool // whether handler should be called
}{
{
name: "group allow",
buttonData: "perm:allow:qqbot:group-1:user-1",
chatType: 1,
wantContent: "allow",
wantSession: "qqbot:group-1:user-1",
wantMsgType: "group",
wantCall: true,
},
{
name: "group deny",
buttonData: "perm:deny:qqbot:group-2:user-2",
chatType: 1,
wantContent: "deny",
wantSession: "qqbot:group-2:user-2",
wantMsgType: "group",
wantCall: true,
},
{
name: "group allow_all",
buttonData: "perm:allow_all:qqbot:group-3:user-3",
chatType: 1,
wantContent: "allow all",
wantSession: "qqbot:group-3:user-3",
wantMsgType: "group",
wantCall: true,
},
{
name: "c2c allow",
buttonData: "perm:allow:qqbot:user-1",
chatType: 2,
wantContent: "allow",
wantSession: "qqbot:user-1",
wantMsgType: "c2c",
wantCall: true,
},
{
name: "c2c deny",
buttonData: "perm:deny:qqbot:user-2",
chatType: 2,
wantContent: "deny",
wantSession: "qqbot:user-2",
wantMsgType: "c2c",
wantCall: true,
},
{
name: "shared channel allow",
buttonData: "perm:allow:qqbot:g:group-shared",
chatType: 1,
wantContent: "allow",
wantSession: "qqbot:g:group-shared",
wantMsgType: "group",
wantCall: true,
},
{
name: "unknown button_data prefix",
buttonData: "something_else:data",
chatType: 2,
wantCall: false,
},
{
name: "empty decision",
buttonData: "perm:",
chatType: 2,
wantCall: false,
},
{
name: "invalid decision",
buttonData: "perm:unknown:qqbot:user-1",
chatType: 2,
wantCall: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
p := &Platform{
allowFrom: "*",
}
var got *core.Message
p.handler = func(_ core.Platform, msg *core.Message) {
got = msg
}
payload := map[string]any{
"id": "interact-" + tt.name,
"group_openid": "group-1",
"group_member_openid": "user-1",
"user_openid": "user-1",
"chat_type": tt.chatType,
"data": map[string]any{
"type": 11,
"resolved": map[string]any{
"button_data": tt.buttonData,
"button_id": "b_0_0",
},
},
}
data, _ := json.Marshal(payload)
p.handleInteractionCreate(data)
if tt.wantCall && got == nil {
t.Fatal("expected synthetic message, got nil")
}
if !tt.wantCall && got != nil {
t.Fatal("expected no message, but handler was called")
}
if !tt.wantCall {
return
}
if got.Content != tt.wantContent {
t.Errorf("content = %q, want %q", got.Content, tt.wantContent)
}
if got.SessionKey != tt.wantSession {
t.Errorf("session_key = %q, want %q", got.SessionKey, tt.wantSession)
}
if got.Platform != "qqbot" {
t.Errorf("platform = %q, want qqbot", got.Platform)
}
rctx, ok := got.ReplyCtx.(*replyContext)
if !ok {
t.Fatal("replyCtx is not *replyContext")
}
if rctx.messageType != tt.wantMsgType {
t.Errorf("messageType = %q, want %q", rctx.messageType, tt.wantMsgType)
}
if rctx.sessionKey != tt.wantSession {
t.Errorf("replyCtx.sessionKey = %q, want %q", rctx.sessionKey, tt.wantSession)
}
})
}
}
func TestSendWithButtons_InvalidReplyCtx(t *testing.T) {
p := &Platform{}
err := p.SendWithButtons(context.Background(), "invalid", "test", nil)
if err == nil {
t.Fatal("expected error for invalid reply context, got nil")
}
}
func TestSendWithButtons_EmptyEventMsgID(t *testing.T) {
var receivedBody map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewDecoder(r.Body).Decode(&receivedBody)
w.WriteHeader(http.StatusOK)
_, _ = fmt.Fprint(w, `{"id": "msg-out"}`)
}))
defer server.Close()
origClient := core.HTTPClient
t.Cleanup(func() { core.HTTPClient = origClient })
core.HTTPClient = server.Client()
origProd := apiBaseProduction
apiBaseProduction = server.URL
t.Cleanup(func() { apiBaseProduction = origProd })
p := &Platform{
token: "test-token",
tokenExpiry: time.Now().Add(time.Hour),
}
rctx := &replyContext{
messageType: "c2c",
userOpenID: "user-1",
sessionKey: "qqbot:user-1",
// eventMsgID is empty
}
err := p.SendWithButtons(context.Background(), rctx, "test", [][]core.ButtonOption{{{Text: "允许", Data: "perm:allow"}}})
if err != nil {
t.Fatalf("SendWithButtons returned error: %v", err)
}
// msg_id should NOT be present when eventMsgID is empty
if _, ok := receivedBody["msg_id"]; ok {
t.Error("msg_id should not be present when eventMsgID is empty")
}
if _, ok := receivedBody["msg_seq"]; ok {
t.Error("msg_seq should not be present when eventMsgID is empty")
}
}
// ---------------------------------------------------------------------------
// handleInteractionCreate tests
// ---------------------------------------------------------------------------
func TestHandleInteractionCreate_Allow(t *testing.T) {
p := &Platform{
allowFrom: "*",
}
var got *core.Message
p.handler = func(_ core.Platform, msg *core.Message) {
got = msg
}
// Create an interaction event for group allow
payload := map[string]any{
"id": "interact-1",
"group_openid": "group-1",
"group_member_openid": "user-1",
"user_openid": "user-1",
"chat_type": 1,
"data": map[string]any{
"type": 11,
"resolved": map[string]any{
"button_data": "perm:allow:qqbot:group-1:user-1",
"button_id": "b_0_0",
},
},
}
data, _ := json.Marshal(payload)
p.handleInteractionCreate(data)
if got == nil {
t.Fatal("expected synthetic message, got nil")
}
if got.Content != "allow" {
t.Errorf("content = %q, want %q", got.Content, "allow")
}
if got.SessionKey != "qqbot:group-1:user-1" {
t.Errorf("session_key = %q", got.SessionKey)
}
if got.MessageID != "interact-1" {
t.Errorf("message_id = %q", got.MessageID)
}
if got.Platform != "qqbot" {
t.Errorf("platform = %q", got.Platform)
}
// Verify reply context
rctx, ok := got.ReplyCtx.(*replyContext)
if !ok {
t.Fatal("replyCtx is not *replyContext")
}
if rctx.messageType != "group" {
t.Errorf("messageType = %q", rctx.messageType)
}
if rctx.groupOpenID != "group-1" {
t.Errorf("groupOpenID = %q", rctx.groupOpenID)
}
if rctx.userOpenID != "user-1" {
t.Errorf("userOpenID = %q", rctx.userOpenID)
}
}
func TestHandleInteractionCreate_Deny(t *testing.T) {
p := &Platform{
allowFrom: "*",
}
var got *core.Message
p.handler = func(_ core.Platform, msg *core.Message) {
got = msg
}
payload := map[string]any{
"id": "interact-2",
"user_openid": "user-1",
"chat_type": 2,
"data": map[string]any{
"type": 11,
"resolved": map[string]any{
"button_data": "perm:deny:qqbot:user-1",
"button_id": "b_0_1",
},
},
}
data, _ := json.Marshal(payload)
p.handleInteractionCreate(data)
if got == nil {
t.Fatal("expected synthetic message, got nil")
}
if got.Content != "deny" {
t.Errorf("content = %q, want %q", got.Content, "deny")
}
if got.SessionKey != "qqbot:user-1" {
t.Errorf("session_key = %q", got.SessionKey)
}
rctx, ok := got.ReplyCtx.(*replyContext)
if !ok {
t.Fatal("replyCtx is not *replyContext")
}
if rctx.messageType != "c2c" {
t.Errorf("messageType = %q", rctx.messageType)
}
if rctx.userOpenID != "user-1" {
t.Errorf("userOpenID = %q", rctx.userOpenID)
}
}
func TestHandleInteractionCreate_AllowAll(t *testing.T) {
p := &Platform{
allowFrom: "*",
}
var got *core.Message
p.handler = func(_ core.Platform, msg *core.Message) {
got = msg
}
payload := map[string]any{
"id": "interact-3",
"group_openid": "group-1",
"group_member_openid": "user-2",
"user_openid": "user-2",
"chat_type": 1,
"data": map[string]any{
"type": 11,
"resolved": map[string]any{
"button_data": "perm:allow_all:qqbot:group-1:user-2",
"button_id": "b_1_0",
},
},
}
data, _ := json.Marshal(payload)
p.handleInteractionCreate(data)
if got == nil {
t.Fatal("expected synthetic message, got nil")
}
if got.Content != "allow all" {
t.Errorf("content = %q, want %q", got.Content, "allow all")
}
if got.SessionKey != "qqbot:group-1:user-2" {
t.Errorf("session_key = %q", got.SessionKey)
}
}
func TestHandleInteractionCreate_UnknownButtonData(t *testing.T) {
p := &Platform{
allowFrom: "*",
}
called := false
p.handler = func(_ core.Platform, msg *core.Message) {
called = true
}
payload := map[string]any{
"id": "interact-4",
"chat_type": 2,
"data": map[string]any{
"type": 11,
"resolved": map[string]any{
"button_data": "something_else:data",
"button_id": "unknown",
},
},
}
data, _ := json.Marshal(payload)
p.handleInteractionCreate(data)
if called {
t.Error("handler should not be called for unknown button_data prefix")
}
}
func TestHandleInteractionCreate_InvalidFormat(t *testing.T) {
p := &Platform{
allowFrom: "*",
}
called := false
p.handler = func(_ core.Platform, msg *core.Message) {
called = true
}
payload := map[string]any{
"id": "interact-5",
"chat_type": 2,
"data": map[string]any{
"type": 11,
"resolved": map[string]any{
"button_data": "perm:",
"button_id": "bad",
},
},
}
data, _ := json.Marshal(payload)
p.handleInteractionCreate(data)
if called {
t.Error("handler should not be called for empty button_data")
}
}
func TestHandleInteractionCreate_EmptyID(t *testing.T) {
p := &Platform{
allowFrom: "*",
}
called := false
p.handler = func(_ core.Platform, msg *core.Message) {
called = true
}
payload := map[string]any{
"id": "",
"chat_type": 2,
"data": map[string]any{
"resolved": map[string]any{
"button_data": "perm:allow:qqbot:user-1",
},
},
}
data, _ := json.Marshal(payload)
p.handleInteractionCreate(data)
if called {
t.Error("handler should not be called for empty interaction ID")
}
}
func TestHandleInteractionCreate_ACKFailure(t *testing.T) {
// Test that interaction processing proceeds even if ACK fails
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer server.Close()
p := &Platform{
allowFrom: "*",
token: "test-token",
tokenExpiry: time.Now().Add(time.Hour),
}
// Point to test server
origProd := apiBaseProduction
apiBaseProduction = server.URL
t.Cleanup(func() { apiBaseProduction = origProd })
var got *core.Message
p.handler = func(_ core.Platform, msg *core.Message) {
got = msg
}
payload := map[string]any{
"id": "interact-ack",
"user_openid": "user-1",
"chat_type": 2,
"data": map[string]any{
"type": 11,
"resolved": map[string]any{
"button_data": "perm:allow:qqbot:user-1",
"button_id": "b_0_0",
},
},
}
data, _ := json.Marshal(payload)
// Should not panic, handler should still receive the message
p.handleInteractionCreate(data)
if got == nil {
t.Fatal("expected synthetic message even when ACK fails")
}
if got.Content != "allow" {
t.Errorf("content = %q, want %q", got.Content, "allow")
}
}
// ---------------------------------------------------------------------------
// ackInteraction tests
// ---------------------------------------------------------------------------
func TestAckInteraction(t *testing.T) {
var method, reqURL string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
method = r.Method
reqURL = r.URL.String()
w.WriteHeader(http.StatusNoContent)
}))
defer server.Close()
origClient := core.HTTPClient
t.Cleanup(func() { core.HTTPClient = origClient })
core.HTTPClient = server.Client()
p := &Platform{
token: "test-token",
tokenExpiry: time.Now().Add(time.Hour),
}
// Point to test server
origProd := apiBaseProduction
apiBaseProduction = server.URL
t.Cleanup(func() { apiBaseProduction = origProd })
err := p.ackInteraction("interact-1")
if err != nil {
t.Fatalf("ackInteraction returned error: %v", err)
}
if method != "PUT" {
t.Errorf("method = %q, want %q", method, "PUT")
}
if reqURL != "/interactions/interact-1" {
t.Errorf("url = %q, want %q", reqURL, "/interactions/interact-1")
}
}
// ---------------------------------------------------------------------------
// Interface compliance tests
// ---------------------------------------------------------------------------
func TestPlatformImplementsInlineButtonSender(t *testing.T) {
p := &Platform{}
if _, ok := any(p).(core.InlineButtonSender); !ok {
t.Error("Platform does not implement core.InlineButtonSender")
}
}