返回 DeepSeek-Reasonix
gateway_test.go
根目录 / internal / bot / qq / gateway_test.go
1 package qq
2
3 import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "fmt"
8 "io"
9 "log/slog"
10 "net/http"
11 "strings"
12 "testing"
13
14 "reasonix/internal/bot"
15 "reasonix/internal/config"
16 )
17
18 func TestHandleDispatchDirectMessageUsesDirectChatType(t *testing.T) {
19 a := &adapter{
20 logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
21 msgCh: make(chan bot.InboundMessage, 1),
22 }
23 raw, err := json.Marshal(map[string]any{
24 "id": "msg-1",
25 "content": "hello",
26 "guild_id": "guild-1",
27 "author": map[string]string{
28 "id": "user-1",
29 "username": "user",
30 },
31 })
32 if err != nil {
33 t.Fatal(err)
34 }
35
36 a.handleDispatch(gatewayPayload{T: "DIRECT_MESSAGE_CREATE", D: raw})
37
38 msg := <-a.msgCh
39 if msg.ChatType != bot.ChatDirect {
40 t.Fatalf("chat type = %q, want %q", msg.ChatType, bot.ChatDirect)
41 }
42 if msg.ChatID != "guild-1" {
43 t.Fatalf("chat id = %q, want guild-1", msg.ChatID)
44 }
45 }
46
47 func TestHandleDispatchC2CUsesUserOpenID(t *testing.T) {
48 a := &adapter{
49 logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
50 msgCh: make(chan bot.InboundMessage, 1),
51 }
52 raw, err := json.Marshal(map[string]any{
53 "id": "msg-1",
54 "content": "hello",
55 "author": map[string]string{
56 "user_openid": "openid-user",
57 "username": "user",
58 },
59 })
60 if err != nil {
61 t.Fatal(err)
62 }
63
64 a.handleDispatch(gatewayPayload{T: "C2C_MESSAGE_CREATE", D: raw})
65
66 msg := <-a.msgCh
67 if msg.UserID != "openid-user" {
68 t.Fatalf("user id = %q, want openid-user", msg.UserID)
69 }
70 if msg.ChatID != "openid-user" {
71 t.Fatalf("chat id = %q, want openid-user", msg.ChatID)
72 }
73 if msg.ChatType != bot.ChatDM {
74 t.Fatalf("chat type = %q, want %q", msg.ChatType, bot.ChatDM)
75 }
76 }
77
78 func TestHandleDispatchPublicGuildMessageUsesGuildChatType(t *testing.T) {
79 a := &adapter{
80 logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
81 msgCh: make(chan bot.InboundMessage, 1),
82 }
83 raw, err := json.Marshal(map[string]any{
84 "id": "msg-1",
85 "content": "hello",
86 "channel_id": "channel-1",
87 "author": map[string]string{
88 "member_openid": "member-1",
89 "username": "user",
90 },
91 })
92 if err != nil {
93 t.Fatal(err)
94 }
95
96 a.handleDispatch(gatewayPayload{T: "AT_MESSAGE_CREATE", D: raw})
97
98 msg := <-a.msgCh
99 if msg.ChatType != bot.ChatGuild {
100 t.Fatalf("chat type = %q, want %q", msg.ChatType, bot.ChatGuild)
101 }
102 if msg.ChatID != "channel-1" {
103 t.Fatalf("chat id = %q, want channel-1", msg.ChatID)
104 }
105 }
106
107 func TestQQSendURLDirectMessage(t *testing.T) {
108 got := qqSendURL(bot.OutboundMessage{ChatType: bot.ChatDirect, ChatID: "guild-1"})
109 want := fmt.Sprintf("%s/v2/dms/%s/messages", qqBaseURL, "guild-1")
110 if got != want {
111 t.Fatalf("url = %q, want %q", got, want)
112 }
113 }
114
115 func TestQQSendURLUsesSandboxBase(t *testing.T) {
116 a := &adapter{cfg: config.QQBotConfig{Sandbox: true}}
117 got := a.qqSendURL(bot.OutboundMessage{ChatType: bot.ChatDM, ChatID: "user/open id"})
118 want := qqSandboxURL + "/v2/users/user%2Fopen%20id/messages"
119 if got != want {
120 t.Fatalf("url = %q, want %q", got, want)
121 }
122 }
123
124 func TestValidateGatewayURL(t *testing.T) {
125 for _, raw := range []string{
126 "wss://api.sgroup.qq.com/websocket",
127 "wss://sandbox.api.sgroup.qq.com/websocket",
128 "wss://gateway.qq.com/websocket",
129 } {
130 if _, err := validateGatewayURL(raw); err != nil {
131 t.Fatalf("valid gateway %q rejected: %v", raw, err)
132 }
133 }
134 for _, raw := range []string{
135 "http://api.sgroup.qq.com/websocket",
136 "wss://evil.example/websocket",
137 "wss://api.sgroup.qq.com/websocket?token=1",
138 "wss://user:pass@api.sgroup.qq.com/websocket",
139 } {
140 if _, err := validateGatewayURL(raw); err == nil {
141 t.Fatalf("invalid gateway %q accepted", raw)
142 }
143 }
144 }
145
146 func TestNormalizeQQMarkdownReply(t *testing.T) {
147 got := normalizeQQMarkdownReply("```markdown\n# Title\n\n**bold**\n```")
148 if got != "# Title\n\n**bold**" {
149 t.Fatalf("normalized markdown = %q", got)
150 }
151 normal := "Here is code:\n```go\nfmt.Println()\n```"
152 if got := normalizeQQMarkdownReply(normal); got != normal {
153 t.Fatalf("normal code block changed: %q", got)
154 }
155 }
156
157 func TestSplitQQMessageKeepsUTF8Budget(t *testing.T) {
158 chunks := splitQQMessage(strings.Repeat("中", 600), 1500)
159 if len(chunks) < 2 {
160 t.Fatalf("chunks = %d, want more than one", len(chunks))
161 }
162 for _, chunk := range chunks {
163 if len([]byte(chunk)) > 1500 {
164 t.Fatalf("chunk byte length = %d, want <= 1500", len([]byte(chunk)))
165 }
166 }
167 }
168
169 func TestFitUTF8SliceKeepsGraphemeCluster(t *testing.T) {
170 cluster := "👨‍👩‍👧‍👦"
171 got := fitUTF8Slice(cluster+"!", len([]byte(cluster)))
172 if got != cluster {
173 t.Fatalf("fitUTF8Slice split grapheme cluster: %q", got)
174 }
175 }
176
177 func TestCapQQPassiveReplyChunks(t *testing.T) {
178 chunks := splitQQMessage(strings.Repeat("chunk-", 1800), qqMaxChunkBytes)
179 if len(chunks) <= qqMaxPassiveReplyChunks {
180 t.Fatalf("chunks = %d, want more than passive reply limit", len(chunks))
181 }
182
183 got, truncated := capQQPassiveReplyChunks(bot.OutboundMessage{
184 ChatType: bot.ChatDM,
185 ReplyToMsgID: "msg-id",
186 }, chunks)
187 if !truncated {
188 t.Fatal("capQQPassiveReplyChunks truncated = false, want true")
189 }
190 if len(got) != qqMaxPassiveReplyChunks {
191 t.Fatalf("capped chunks = %d, want %d", len(got), qqMaxPassiveReplyChunks)
192 }
193 for _, chunk := range got {
194 if len([]byte(chunk)) > qqMaxChunkBytes {
195 t.Fatalf("chunk byte length = %d, want <= %d", len([]byte(chunk)), qqMaxChunkBytes)
196 }
197 }
198 if !strings.Contains(got[len(got)-1], "Truncated") {
199 t.Fatalf("last chunk = %q, want truncation notice", got[len(got)-1])
200 }
201 }
202
203 func TestCapQQPassiveReplyChunksDoesNotCapNonPassiveReplies(t *testing.T) {
204 chunks := splitQQMessage(strings.Repeat("chunk-", 1800), qqMaxChunkBytes)
205 got, truncated := capQQPassiveReplyChunks(bot.OutboundMessage{
206 ChatType: bot.ChatDM,
207 }, chunks)
208 if truncated {
209 t.Fatal("capQQPassiveReplyChunks truncated non-passive reply")
210 }
211 if len(got) != len(chunks) {
212 t.Fatalf("chunks = %d, want %d", len(got), len(chunks))
213 }
214
215 got, truncated = capQQPassiveReplyChunks(bot.OutboundMessage{
216 ChatType: bot.ChatDirect,
217 ReplyToMsgID: "msg-id",
218 }, chunks)
219 if truncated {
220 t.Fatal("capQQPassiveReplyChunks truncated direct/guild reply")
221 }
222 if len(got) != len(chunks) {
223 t.Fatalf("chunks = %d, want %d", len(got), len(chunks))
224 }
225 }
226
227 func TestStartValidatesQQCredentialsBeforeRunning(t *testing.T) {
228 a := &adapter{}
229 if err := a.Start(context.Background()); err == nil {
230 t.Fatal("Start() error = nil, want missing app_id error")
231 }
232 if a.cancel != nil {
233 t.Fatal("Start() installed runtime cancel after validation failure")
234 }
235 }
236
237 func TestQQExpiresInSecondsAcceptsNumberAndString(t *testing.T) {
238 for _, tt := range []struct {
239 name string
240 value any
241 want int
242 }{
243 {name: "number", value: float64(3600), want: 3600},
244 {name: "string", value: "7200", want: 7200},
245 {name: "blank", value: "", want: 0},
246 {name: "missing", value: nil, want: 0},
247 } {
248 t.Run(tt.name, func(t *testing.T) {
249 got, err := qqExpiresInSeconds(tt.value)
250 if err != nil {
251 t.Fatalf("qqExpiresInSeconds() error = %v", err)
252 }
253 if got != tt.want {
254 t.Fatalf("qqExpiresInSeconds() = %d, want %d", got, tt.want)
255 }
256 })
257 }
258 }
259
260 func TestSendMessageMarkdownFallbackDisablesMarkdown(t *testing.T) {
261 t.Setenv("QQ_BOT_APP_SECRET", "secret")
262 origTransport := http.DefaultTransport
263 defer func() { http.DefaultTransport = origTransport }()
264
265 var bodies []map[string]any
266 sendCount := 0
267 http.DefaultTransport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
268 switch req.URL.Host {
269 case "bots.qq.com":
270 return jsonResponse(200, map[string]any{"access_token": "token", "expires_in": 3600}), nil
271 case "api.sgroup.qq.com":
272 if req.Header.Get("Authorization") != "QQBot token" {
273 t.Fatalf("authorization = %q, want QQBot token", req.Header.Get("Authorization"))
274 }
275 if req.Header.Get("X-Union-Appid") != "app-id" {
276 t.Fatalf("x-union-appid = %q, want app-id", req.Header.Get("X-Union-Appid"))
277 }
278 var body map[string]any
279 if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
280 t.Fatal(err)
281 }
282 bodies = append(bodies, body)
283 sendCount++
284 if sendCount == 1 {
285 return jsonResponse(400, map[string]any{"message": "markdown rejected"}), nil
286 }
287 return jsonResponse(200, map[string]any{"id": fmt.Sprintf("sent-%d", sendCount)}), nil
288 default:
289 t.Fatalf("unexpected request host: %s", req.URL.Host)
290 return nil, nil
291 }
292 })
293
294 a := &adapter{
295 cfg: config.QQBotConfig{AppID: "app-id", AppSecretEnv: "QQ_BOT_APP_SECRET"},
296 logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
297 }
298 _, err := a.sendMessage(context.Background(), bot.OutboundMessage{
299 ChatType: bot.ChatDM,
300 ChatID: "openid-user",
301 Text: "**bold**",
302 ReplyToMsgID: "msg-id",
303 })
304 if err != nil {
305 t.Fatalf("send message: %v", err)
306 }
307 _, err = a.sendMessage(context.Background(), bot.OutboundMessage{
308 ChatType: bot.ChatDM,
309 ChatID: "openid-user",
310 Text: "**next**",
311 ReplyToMsgID: "msg-id-2",
312 })
313 if err != nil {
314 t.Fatalf("second send message: %v", err)
315 }
316 if len(bodies) != 3 {
317 t.Fatalf("sent bodies = %d, want 3", len(bodies))
318 }
319 if bodies[0]["msg_type"] != float64(2) || bodies[0]["markdown"] == nil || bodies[0]["msg_seq"] != float64(1) {
320 t.Fatalf("first body = %#v, want markdown msg_seq=1", bodies[0])
321 }
322 if bodies[1]["msg_type"] != float64(0) || bodies[1]["content"] != "**bold**" || bodies[1]["msg_seq"] != float64(2) {
323 t.Fatalf("fallback body = %#v, want plain msg_seq=2", bodies[1])
324 }
325 if bodies[2]["msg_type"] != float64(0) || bodies[2]["content"] != "**next**" || bodies[2]["msg_seq"] != float64(3) {
326 t.Fatalf("second body = %#v, want plain msg_seq=3", bodies[2])
327 }
328 }
329
330 func TestSendMessageReturnsAllChunkIDs(t *testing.T) {
331 t.Setenv("QQ_BOT_APP_SECRET", "secret")
332 origTransport := http.DefaultTransport
333 defer func() { http.DefaultTransport = origTransport }()
334
335 sendCount := 0
336 http.DefaultTransport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
337 switch req.URL.Host {
338 case "bots.qq.com":
339 return jsonResponse(200, map[string]any{"access_token": "token", "expires_in": 3600}), nil
340 case "api.sgroup.qq.com":
341 sendCount++
342 return jsonResponse(200, map[string]any{"id": fmt.Sprintf("sent-%d", sendCount)}), nil
343 default:
344 t.Fatalf("unexpected request host: %s", req.URL.Host)
345 return nil, nil
346 }
347 })
348
349 text := strings.Repeat("chunk-", 1800)
350 wantChunks := len(splitQQMessage(text, qqMaxChunkBytes))
351 a := &adapter{
352 cfg: config.QQBotConfig{AppID: "app-id", AppSecretEnv: "QQ_BOT_APP_SECRET"},
353 logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
354 }
355 result, err := a.sendMessage(context.Background(), bot.OutboundMessage{
356 ChatType: bot.ChatDM,
357 ChatID: "openid-user",
358 Text: text,
359 })
360 if err != nil {
361 t.Fatalf("send message: %v", err)
362 }
363 if len(result.MessageIDs) != wantChunks {
364 t.Fatalf("message IDs = %v, want %d chunk IDs", result.MessageIDs, wantChunks)
365 }
366 if result.MessageID != fmt.Sprintf("sent-%d", wantChunks) {
367 t.Fatalf("compatibility message ID = %q, want last chunk", result.MessageID)
368 }
369 }
370
371 type roundTripFunc func(*http.Request) (*http.Response, error)
372
373 func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
374 return f(req)
375 }
376
377 func jsonResponse(status int, v any) *http.Response {
378 data, _ := json.Marshal(v)
379 return &http.Response{
380 StatusCode: status,
381 Header: http.Header{"Content-Type": {"application/json"}},
382 Body: io.NopCloser(bytes.NewReader(data)),
383 }
384 }
385
385 lines GO