返回 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 TestQQSendURLDirectMessage(t *testing.T) {
79 got := qqSendURL(bot.OutboundMessage{ChatType: bot.ChatDirect, ChatID: "guild-1"})
80 want := fmt.Sprintf("%s/v2/dms/%s/messages", qqBaseURL, "guild-1")
81 if got != want {
82 t.Fatalf("url = %q, want %q", got, want)
83 }
84 }
85
86 func TestQQSendURLUsesSandboxBase(t *testing.T) {
87 a := &adapter{cfg: config.QQBotConfig{Sandbox: true}}
88 got := a.qqSendURL(bot.OutboundMessage{ChatType: bot.ChatDM, ChatID: "user/open id"})
89 want := qqSandboxURL + "/v2/users/user%2Fopen%20id/messages"
90 if got != want {
91 t.Fatalf("url = %q, want %q", got, want)
92 }
93 }
94
95 func TestValidateGatewayURL(t *testing.T) {
96 for _, raw := range []string{
97 "wss://api.sgroup.qq.com/websocket",
98 "wss://sandbox.api.sgroup.qq.com/websocket",
99 "wss://gateway.qq.com/websocket",
100 } {
101 if _, err := validateGatewayURL(raw); err != nil {
102 t.Fatalf("valid gateway %q rejected: %v", raw, err)
103 }
104 }
105 for _, raw := range []string{
106 "http://api.sgroup.qq.com/websocket",
107 "wss://evil.example/websocket",
108 "wss://api.sgroup.qq.com/websocket?token=1",
109 "wss://user:pass@api.sgroup.qq.com/websocket",
110 } {
111 if _, err := validateGatewayURL(raw); err == nil {
112 t.Fatalf("invalid gateway %q accepted", raw)
113 }
114 }
115 }
116
117 func TestNormalizeQQMarkdownReply(t *testing.T) {
118 got := normalizeQQMarkdownReply("```markdown\n# Title\n\n**bold**\n```")
119 if got != "# Title\n\n**bold**" {
120 t.Fatalf("normalized markdown = %q", got)
121 }
122 normal := "Here is code:\n```go\nfmt.Println()\n```"
123 if got := normalizeQQMarkdownReply(normal); got != normal {
124 t.Fatalf("normal code block changed: %q", got)
125 }
126 }
127
128 func TestSplitQQMessageKeepsUTF8Budget(t *testing.T) {
129 chunks := splitQQMessage(strings.Repeat("中", 600), 1500)
130 if len(chunks) < 2 {
131 t.Fatalf("chunks = %d, want more than one", len(chunks))
132 }
133 for _, chunk := range chunks {
134 if len([]byte(chunk)) > 1500 {
135 t.Fatalf("chunk byte length = %d, want <= 1500", len([]byte(chunk)))
136 }
137 }
138 }
139
140 func TestFitUTF8SliceKeepsGraphemeCluster(t *testing.T) {
141 cluster := "👨‍👩‍👧‍👦"
142 got := fitUTF8Slice(cluster+"!", len([]byte(cluster)))
143 if got != cluster {
144 t.Fatalf("fitUTF8Slice split grapheme cluster: %q", got)
145 }
146 }
147
148 func TestCapQQPassiveReplyChunks(t *testing.T) {
149 chunks := splitQQMessage(strings.Repeat("chunk-", 1800), qqMaxChunkBytes)
150 if len(chunks) <= qqMaxPassiveReplyChunks {
151 t.Fatalf("chunks = %d, want more than passive reply limit", len(chunks))
152 }
153
154 got, truncated := capQQPassiveReplyChunks(bot.OutboundMessage{
155 ChatType: bot.ChatDM,
156 ReplyToMsgID: "msg-id",
157 }, chunks)
158 if !truncated {
159 t.Fatal("capQQPassiveReplyChunks truncated = false, want true")
160 }
161 if len(got) != qqMaxPassiveReplyChunks {
162 t.Fatalf("capped chunks = %d, want %d", len(got), qqMaxPassiveReplyChunks)
163 }
164 for _, chunk := range got {
165 if len([]byte(chunk)) > qqMaxChunkBytes {
166 t.Fatalf("chunk byte length = %d, want <= %d", len([]byte(chunk)), qqMaxChunkBytes)
167 }
168 }
169 if !strings.Contains(got[len(got)-1], "Truncated") {
170 t.Fatalf("last chunk = %q, want truncation notice", got[len(got)-1])
171 }
172 }
173
174 func TestCapQQPassiveReplyChunksDoesNotCapNonPassiveReplies(t *testing.T) {
175 chunks := splitQQMessage(strings.Repeat("chunk-", 1800), qqMaxChunkBytes)
176 got, truncated := capQQPassiveReplyChunks(bot.OutboundMessage{
177 ChatType: bot.ChatDM,
178 }, chunks)
179 if truncated {
180 t.Fatal("capQQPassiveReplyChunks truncated non-passive reply")
181 }
182 if len(got) != len(chunks) {
183 t.Fatalf("chunks = %d, want %d", len(got), len(chunks))
184 }
185
186 got, truncated = capQQPassiveReplyChunks(bot.OutboundMessage{
187 ChatType: bot.ChatDirect,
188 ReplyToMsgID: "msg-id",
189 }, chunks)
190 if truncated {
191 t.Fatal("capQQPassiveReplyChunks truncated direct/guild reply")
192 }
193 if len(got) != len(chunks) {
194 t.Fatalf("chunks = %d, want %d", len(got), len(chunks))
195 }
196 }
197
198 func TestStartValidatesQQCredentialsBeforeRunning(t *testing.T) {
199 a := &adapter{}
200 if err := a.Start(context.Background()); err == nil {
201 t.Fatal("Start() error = nil, want missing app_id error")
202 }
203 if a.cancel != nil {
204 t.Fatal("Start() installed runtime cancel after validation failure")
205 }
206 }
207
208 func TestQQExpiresInSecondsAcceptsNumberAndString(t *testing.T) {
209 for _, tt := range []struct {
210 name string
211 value any
212 want int
213 }{
214 {name: "number", value: float64(3600), want: 3600},
215 {name: "string", value: "7200", want: 7200},
216 {name: "blank", value: "", want: 0},
217 {name: "missing", value: nil, want: 0},
218 } {
219 t.Run(tt.name, func(t *testing.T) {
220 got, err := qqExpiresInSeconds(tt.value)
221 if err != nil {
222 t.Fatalf("qqExpiresInSeconds() error = %v", err)
223 }
224 if got != tt.want {
225 t.Fatalf("qqExpiresInSeconds() = %d, want %d", got, tt.want)
226 }
227 })
228 }
229 }
230
231 func TestSendMessageMarkdownFallbackDisablesMarkdown(t *testing.T) {
232 t.Setenv("QQ_BOT_APP_SECRET", "secret")
233 origTransport := http.DefaultTransport
234 defer func() { http.DefaultTransport = origTransport }()
235
236 var bodies []map[string]any
237 sendCount := 0
238 http.DefaultTransport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
239 switch req.URL.Host {
240 case "bots.qq.com":
241 return jsonResponse(200, map[string]any{"access_token": "token", "expires_in": 3600}), nil
242 case "api.sgroup.qq.com":
243 if req.Header.Get("Authorization") != "QQBot token" {
244 t.Fatalf("authorization = %q, want QQBot token", req.Header.Get("Authorization"))
245 }
246 if req.Header.Get("X-Union-Appid") != "app-id" {
247 t.Fatalf("x-union-appid = %q, want app-id", req.Header.Get("X-Union-Appid"))
248 }
249 var body map[string]any
250 if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
251 t.Fatal(err)
252 }
253 bodies = append(bodies, body)
254 sendCount++
255 if sendCount == 1 {
256 return jsonResponse(400, map[string]any{"message": "markdown rejected"}), nil
257 }
258 return jsonResponse(200, map[string]any{"id": fmt.Sprintf("sent-%d", sendCount)}), nil
259 default:
260 t.Fatalf("unexpected request host: %s", req.URL.Host)
261 return nil, nil
262 }
263 })
264
265 a := &adapter{
266 cfg: config.QQBotConfig{AppID: "app-id", AppSecretEnv: "QQ_BOT_APP_SECRET"},
267 logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
268 }
269 _, err := a.sendMessage(context.Background(), bot.OutboundMessage{
270 ChatType: bot.ChatDM,
271 ChatID: "openid-user",
272 Text: "**bold**",
273 ReplyToMsgID: "msg-id",
274 })
275 if err != nil {
276 t.Fatalf("send message: %v", err)
277 }
278 _, err = a.sendMessage(context.Background(), bot.OutboundMessage{
279 ChatType: bot.ChatDM,
280 ChatID: "openid-user",
281 Text: "**next**",
282 ReplyToMsgID: "msg-id-2",
283 })
284 if err != nil {
285 t.Fatalf("second send message: %v", err)
286 }
287 if len(bodies) != 3 {
288 t.Fatalf("sent bodies = %d, want 3", len(bodies))
289 }
290 if bodies[0]["msg_type"] != float64(2) || bodies[0]["markdown"] == nil || bodies[0]["msg_seq"] != float64(1) {
291 t.Fatalf("first body = %#v, want markdown msg_seq=1", bodies[0])
292 }
293 if bodies[1]["msg_type"] != float64(0) || bodies[1]["content"] != "**bold**" || bodies[1]["msg_seq"] != float64(2) {
294 t.Fatalf("fallback body = %#v, want plain msg_seq=2", bodies[1])
295 }
296 if bodies[2]["msg_type"] != float64(0) || bodies[2]["content"] != "**next**" || bodies[2]["msg_seq"] != float64(3) {
297 t.Fatalf("second body = %#v, want plain msg_seq=3", bodies[2])
298 }
299 }
300
301 func TestSendMessageReturnsAllChunkIDs(t *testing.T) {
302 t.Setenv("QQ_BOT_APP_SECRET", "secret")
303 origTransport := http.DefaultTransport
304 defer func() { http.DefaultTransport = origTransport }()
305
306 sendCount := 0
307 http.DefaultTransport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
308 switch req.URL.Host {
309 case "bots.qq.com":
310 return jsonResponse(200, map[string]any{"access_token": "token", "expires_in": 3600}), nil
311 case "api.sgroup.qq.com":
312 sendCount++
313 return jsonResponse(200, map[string]any{"id": fmt.Sprintf("sent-%d", sendCount)}), nil
314 default:
315 t.Fatalf("unexpected request host: %s", req.URL.Host)
316 return nil, nil
317 }
318 })
319
320 text := strings.Repeat("chunk-", 1800)
321 wantChunks := len(splitQQMessage(text, qqMaxChunkBytes))
322 a := &adapter{
323 cfg: config.QQBotConfig{AppID: "app-id", AppSecretEnv: "QQ_BOT_APP_SECRET"},
324 logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
325 }
326 result, err := a.sendMessage(context.Background(), bot.OutboundMessage{
327 ChatType: bot.ChatDM,
328 ChatID: "openid-user",
329 Text: text,
330 })
331 if err != nil {
332 t.Fatalf("send message: %v", err)
333 }
334 if len(result.MessageIDs) != wantChunks {
335 t.Fatalf("message IDs = %v, want %d chunk IDs", result.MessageIDs, wantChunks)
336 }
337 if result.MessageID != fmt.Sprintf("sent-%d", wantChunks) {
338 t.Fatalf("compatibility message ID = %q, want last chunk", result.MessageID)
339 }
340 }
341
342 type roundTripFunc func(*http.Request) (*http.Response, error)
343
344 func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
345 return f(req)
346 }
347
348 func jsonResponse(status int, v any) *http.Response {
349 data, _ := json.Marshal(v)
350 return &http.Response{
351 StatusCode: status,
352 Header: http.Header{"Content-Type": {"application/json"}},
353 Body: io.NopCloser(bytes.NewReader(data)),
354 }
355 }
356
356 lines GO