package qq import ( "bytes" "context" "encoding/json" "fmt" "io" "log/slog" "net/http" "strings" "testing" "reasonix/internal/bot" "reasonix/internal/config" ) func TestHandleDispatchDirectMessageUsesDirectChatType(t *testing.T) { a := &adapter{ logger: slog.New(slog.NewTextHandler(io.Discard, nil)), msgCh: make(chan bot.InboundMessage, 1), } raw, err := json.Marshal(map[string]any{ "id": "msg-1", "content": "hello", "guild_id": "guild-1", "author": map[string]string{ "id": "user-1", "username": "user", }, }) if err != nil { t.Fatal(err) } a.handleDispatch(gatewayPayload{T: "DIRECT_MESSAGE_CREATE", D: raw}) msg := <-a.msgCh if msg.ChatType != bot.ChatDirect { t.Fatalf("chat type = %q, want %q", msg.ChatType, bot.ChatDirect) } if msg.ChatID != "guild-1" { t.Fatalf("chat id = %q, want guild-1", msg.ChatID) } } func TestHandleDispatchC2CUsesUserOpenID(t *testing.T) { a := &adapter{ logger: slog.New(slog.NewTextHandler(io.Discard, nil)), msgCh: make(chan bot.InboundMessage, 1), } raw, err := json.Marshal(map[string]any{ "id": "msg-1", "content": "hello", "author": map[string]string{ "user_openid": "openid-user", "username": "user", }, }) if err != nil { t.Fatal(err) } a.handleDispatch(gatewayPayload{T: "C2C_MESSAGE_CREATE", D: raw}) msg := <-a.msgCh if msg.UserID != "openid-user" { t.Fatalf("user id = %q, want openid-user", msg.UserID) } if msg.ChatID != "openid-user" { t.Fatalf("chat id = %q, want openid-user", msg.ChatID) } if msg.ChatType != bot.ChatDM { t.Fatalf("chat type = %q, want %q", msg.ChatType, bot.ChatDM) } } func TestQQSendURLDirectMessage(t *testing.T) { got := qqSendURL(bot.OutboundMessage{ChatType: bot.ChatDirect, ChatID: "guild-1"}) want := fmt.Sprintf("%s/v2/dms/%s/messages", qqBaseURL, "guild-1") if got != want { t.Fatalf("url = %q, want %q", got, want) } } func TestQQSendURLUsesSandboxBase(t *testing.T) { a := &adapter{cfg: config.QQBotConfig{Sandbox: true}} got := a.qqSendURL(bot.OutboundMessage{ChatType: bot.ChatDM, ChatID: "user/open id"}) want := qqSandboxURL + "/v2/users/user%2Fopen%20id/messages" if got != want { t.Fatalf("url = %q, want %q", got, want) } } func TestValidateGatewayURL(t *testing.T) { for _, raw := range []string{ "wss://api.sgroup.qq.com/websocket", "wss://sandbox.api.sgroup.qq.com/websocket", "wss://gateway.qq.com/websocket", } { if _, err := validateGatewayURL(raw); err != nil { t.Fatalf("valid gateway %q rejected: %v", raw, err) } } for _, raw := range []string{ "http://api.sgroup.qq.com/websocket", "wss://evil.example/websocket", "wss://api.sgroup.qq.com/websocket?token=1", "wss://user:pass@api.sgroup.qq.com/websocket", } { if _, err := validateGatewayURL(raw); err == nil { t.Fatalf("invalid gateway %q accepted", raw) } } } func TestNormalizeQQMarkdownReply(t *testing.T) { got := normalizeQQMarkdownReply("```markdown\n# Title\n\n**bold**\n```") if got != "# Title\n\n**bold**" { t.Fatalf("normalized markdown = %q", got) } normal := "Here is code:\n```go\nfmt.Println()\n```" if got := normalizeQQMarkdownReply(normal); got != normal { t.Fatalf("normal code block changed: %q", got) } } func TestSplitQQMessageKeepsUTF8Budget(t *testing.T) { chunks := splitQQMessage(strings.Repeat("δΈ­", 600), 1500) if len(chunks) < 2 { t.Fatalf("chunks = %d, want more than one", len(chunks)) } for _, chunk := range chunks { if len([]byte(chunk)) > 1500 { t.Fatalf("chunk byte length = %d, want <= 1500", len([]byte(chunk))) } } } func TestFitUTF8SliceKeepsGraphemeCluster(t *testing.T) { cluster := "πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦" got := fitUTF8Slice(cluster+"!", len([]byte(cluster))) if got != cluster { t.Fatalf("fitUTF8Slice split grapheme cluster: %q", got) } } func TestCapQQPassiveReplyChunks(t *testing.T) { chunks := splitQQMessage(strings.Repeat("chunk-", 1800), qqMaxChunkBytes) if len(chunks) <= qqMaxPassiveReplyChunks { t.Fatalf("chunks = %d, want more than passive reply limit", len(chunks)) } got, truncated := capQQPassiveReplyChunks(bot.OutboundMessage{ ChatType: bot.ChatDM, ReplyToMsgID: "msg-id", }, chunks) if !truncated { t.Fatal("capQQPassiveReplyChunks truncated = false, want true") } if len(got) != qqMaxPassiveReplyChunks { t.Fatalf("capped chunks = %d, want %d", len(got), qqMaxPassiveReplyChunks) } for _, chunk := range got { if len([]byte(chunk)) > qqMaxChunkBytes { t.Fatalf("chunk byte length = %d, want <= %d", len([]byte(chunk)), qqMaxChunkBytes) } } if !strings.Contains(got[len(got)-1], "Truncated") { t.Fatalf("last chunk = %q, want truncation notice", got[len(got)-1]) } } func TestCapQQPassiveReplyChunksDoesNotCapNonPassiveReplies(t *testing.T) { chunks := splitQQMessage(strings.Repeat("chunk-", 1800), qqMaxChunkBytes) got, truncated := capQQPassiveReplyChunks(bot.OutboundMessage{ ChatType: bot.ChatDM, }, chunks) if truncated { t.Fatal("capQQPassiveReplyChunks truncated non-passive reply") } if len(got) != len(chunks) { t.Fatalf("chunks = %d, want %d", len(got), len(chunks)) } got, truncated = capQQPassiveReplyChunks(bot.OutboundMessage{ ChatType: bot.ChatDirect, ReplyToMsgID: "msg-id", }, chunks) if truncated { t.Fatal("capQQPassiveReplyChunks truncated direct/guild reply") } if len(got) != len(chunks) { t.Fatalf("chunks = %d, want %d", len(got), len(chunks)) } } func TestStartValidatesQQCredentialsBeforeRunning(t *testing.T) { a := &adapter{} if err := a.Start(context.Background()); err == nil { t.Fatal("Start() error = nil, want missing app_id error") } if a.cancel != nil { t.Fatal("Start() installed runtime cancel after validation failure") } } func TestQQExpiresInSecondsAcceptsNumberAndString(t *testing.T) { for _, tt := range []struct { name string value any want int }{ {name: "number", value: float64(3600), want: 3600}, {name: "string", value: "7200", want: 7200}, {name: "blank", value: "", want: 0}, {name: "missing", value: nil, want: 0}, } { t.Run(tt.name, func(t *testing.T) { got, err := qqExpiresInSeconds(tt.value) if err != nil { t.Fatalf("qqExpiresInSeconds() error = %v", err) } if got != tt.want { t.Fatalf("qqExpiresInSeconds() = %d, want %d", got, tt.want) } }) } } func TestSendMessageMarkdownFallbackDisablesMarkdown(t *testing.T) { t.Setenv("QQ_BOT_APP_SECRET", "secret") origTransport := http.DefaultTransport defer func() { http.DefaultTransport = origTransport }() var bodies []map[string]any sendCount := 0 http.DefaultTransport = roundTripFunc(func(req *http.Request) (*http.Response, error) { switch req.URL.Host { case "bots.qq.com": return jsonResponse(200, map[string]any{"access_token": "token", "expires_in": 3600}), nil case "api.sgroup.qq.com": if req.Header.Get("Authorization") != "QQBot token" { t.Fatalf("authorization = %q, want QQBot token", req.Header.Get("Authorization")) } if req.Header.Get("X-Union-Appid") != "app-id" { t.Fatalf("x-union-appid = %q, want app-id", req.Header.Get("X-Union-Appid")) } var body map[string]any if err := json.NewDecoder(req.Body).Decode(&body); err != nil { t.Fatal(err) } bodies = append(bodies, body) sendCount++ if sendCount == 1 { return jsonResponse(400, map[string]any{"message": "markdown rejected"}), nil } return jsonResponse(200, map[string]any{"id": fmt.Sprintf("sent-%d", sendCount)}), nil default: t.Fatalf("unexpected request host: %s", req.URL.Host) return nil, nil } }) a := &adapter{ cfg: config.QQBotConfig{AppID: "app-id", AppSecretEnv: "QQ_BOT_APP_SECRET"}, logger: slog.New(slog.NewTextHandler(io.Discard, nil)), } _, err := a.sendMessage(context.Background(), bot.OutboundMessage{ ChatType: bot.ChatDM, ChatID: "openid-user", Text: "**bold**", ReplyToMsgID: "msg-id", }) if err != nil { t.Fatalf("send message: %v", err) } _, err = a.sendMessage(context.Background(), bot.OutboundMessage{ ChatType: bot.ChatDM, ChatID: "openid-user", Text: "**next**", ReplyToMsgID: "msg-id-2", }) if err != nil { t.Fatalf("second send message: %v", err) } if len(bodies) != 3 { t.Fatalf("sent bodies = %d, want 3", len(bodies)) } if bodies[0]["msg_type"] != float64(2) || bodies[0]["markdown"] == nil || bodies[0]["msg_seq"] != float64(1) { t.Fatalf("first body = %#v, want markdown msg_seq=1", bodies[0]) } if bodies[1]["msg_type"] != float64(0) || bodies[1]["content"] != "**bold**" || bodies[1]["msg_seq"] != float64(2) { t.Fatalf("fallback body = %#v, want plain msg_seq=2", bodies[1]) } if bodies[2]["msg_type"] != float64(0) || bodies[2]["content"] != "**next**" || bodies[2]["msg_seq"] != float64(3) { t.Fatalf("second body = %#v, want plain msg_seq=3", bodies[2]) } } func TestSendMessageReturnsAllChunkIDs(t *testing.T) { t.Setenv("QQ_BOT_APP_SECRET", "secret") origTransport := http.DefaultTransport defer func() { http.DefaultTransport = origTransport }() sendCount := 0 http.DefaultTransport = roundTripFunc(func(req *http.Request) (*http.Response, error) { switch req.URL.Host { case "bots.qq.com": return jsonResponse(200, map[string]any{"access_token": "token", "expires_in": 3600}), nil case "api.sgroup.qq.com": sendCount++ return jsonResponse(200, map[string]any{"id": fmt.Sprintf("sent-%d", sendCount)}), nil default: t.Fatalf("unexpected request host: %s", req.URL.Host) return nil, nil } }) text := strings.Repeat("chunk-", 1800) wantChunks := len(splitQQMessage(text, qqMaxChunkBytes)) a := &adapter{ cfg: config.QQBotConfig{AppID: "app-id", AppSecretEnv: "QQ_BOT_APP_SECRET"}, logger: slog.New(slog.NewTextHandler(io.Discard, nil)), } result, err := a.sendMessage(context.Background(), bot.OutboundMessage{ ChatType: bot.ChatDM, ChatID: "openid-user", Text: text, }) if err != nil { t.Fatalf("send message: %v", err) } if len(result.MessageIDs) != wantChunks { t.Fatalf("message IDs = %v, want %d chunk IDs", result.MessageIDs, wantChunks) } if result.MessageID != fmt.Sprintf("sent-%d", wantChunks) { t.Fatalf("compatibility message ID = %q, want last chunk", result.MessageID) } } type roundTripFunc func(*http.Request) (*http.Response, error) func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) } func jsonResponse(status int, v any) *http.Response { data, _ := json.Marshal(v) return &http.Response{ StatusCode: status, Header: http.Header{"Content-Type": {"application/json"}}, Body: io.NopCloser(bytes.NewReader(data)), } }