返回 DeepSeek-Reasonix
gateway.go
根目录 / internal / bot / qq / gateway.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 "net/url"
12 "os"
13 "regexp"
14 "strconv"
15 "strings"
16 "sync"
17 "time"
18
19 "reasonix/internal/bot"
20 "reasonix/internal/textutil"
21
22 "golang.org/x/net/websocket"
23 )
24
25 const (
26 qqTokenURL = "https://bots.qq.com/app/getAppAccessToken"
27 qqBaseURL = "https://api.sgroup.qq.com"
28 qqSandboxURL = "https://sandbox.api.sgroup.qq.com"
29 qqGatewayURL = "wss://api.sgroup.qq.com/websocket"
30 qqMaxChunkBytes = 1500
31 qqMaxPassiveReplyChunks = 5
32 qqMinHeartbeat = 5 * time.Second
33 qqMaxHeartbeat = time.Minute
34 qqStartupValidationTimeout = 10 * time.Second
35 qqPassiveReplyTruncationNotice = "\n\n[Truncated: QQ allows at most 5 passive replies for one incoming message.]"
36 qqHTTPTimeout = 30 * time.Second
37
38 opDispatch = 0
39 opHeartbeat = 1
40 opIdentify = 2
41 opResume = 6
42 opReconnect = 7
43 opInvalid = 9
44 opHello = 10
45 opHeartbeatAck = 11
46 )
47
48 var qqMarkdownWrapperRe = regexp.MustCompile("(?is)^```(?:markdown|md)\\s*\\r?\\n([\\s\\S]*?)\\r?\\n```$")
49
50 var qqHTTPClient = &http.Client{Timeout: qqHTTPTimeout}
51
52 var allowedGatewayHosts = []string{
53 "api.sgroup.qq.com",
54 "sandbox.api.sgroup.qq.com",
55 "qq.com",
56 }
57
58 // gatewayPayload QQ WebSocket 消息载荷。
59 type gatewayPayload struct {
60 Op int `json:"op"`
61 D json.RawMessage `json:"d,omitempty"`
62 S int64 `json:"s,omitempty"`
63 T string `json:"t,omitempty"`
64 }
65
66 type helloData struct {
67 HeartbeatInterval int `json:"heartbeat_interval"`
68 }
69
70 type identifyData struct {
71 Token string `json:"token"`
72 Intents int `json:"intents"`
73 Shard [2]int `json:"shard"`
74 Properties properties `json:"properties"`
75 }
76
77 type properties struct {
78 OS string `json:"$os"`
79 Browser string `json:"$browser"`
80 Device string `json:"$device"`
81 }
82
83 type dispatchEvent struct {
84 ID string `json:"id"`
85 Type string `json:"type"`
86 Content string `json:"content"`
87 Timestamp string `json:"timestamp"`
88 Author struct {
89 ID string `json:"id"`
90 UserOpenID string `json:"user_openid"`
91 MemberOpenID string `json:"member_openid"`
92 UnionOpenID string `json:"union_openid"`
93 Username string `json:"username"`
94 } `json:"author"`
95 ChannelID string `json:"channel_id"`
96 GuildID string `json:"guild_id"`
97 GroupOpenID string `json:"group_openid"`
98 }
99
100 // wsClient 管理 QQ WebSocket 连接。
101 type wsClient struct {
102 mu sync.Mutex
103 conn *websocket.Conn
104 heartbeatMs int
105 sessionID string
106 lastSeq int64
107 token string
108 logger *slog.Logger
109 }
110
111 func (a *adapter) gatewayLoop(ctx context.Context) {
112 selectedIntents := qqPrivateIdentifyIntents
113 bot.RunWithRetry(ctx, a.logger, "qq gateway", bot.RetryConfig{}, func(ctx context.Context) error {
114 token, err := a.getAccessToken(ctx)
115 if err != nil {
116 return err
117 }
118 // connectGateway blocks for the connection's lifetime, returning on
119 // disconnect or error; RunWithRetry handles the cancellation-aware
120 // backoff and reconnect.
121 _, err = connectQQGatewayWithIntentFallback(ctx, token, &selectedIntents, a.connectGateway, func() {
122 a.logger.Warn("qq private-guild intent rejected; retrying with public-guild events")
123 })
124 return err
125 })
126 }
127
128 func (a *adapter) getAccessToken(ctx context.Context) (string, error) {
129 a.tokenMu.Lock()
130 if a.token != "" && time.Now().Before(a.tokenExpiry) {
131 token := a.token
132 a.tokenMu.Unlock()
133 return token, nil
134 }
135 a.tokenMu.Unlock()
136
137 appID := a.appID()
138 appSecret := a.appSecret()
139 if appID == "" {
140 return "", fmt.Errorf("qq app_id is empty")
141 }
142 if appSecret == "" {
143 return "", fmt.Errorf("qq app secret is empty: set the %s environment variable", a.appSecretEnvName())
144 }
145 body, err := json.Marshal(map[string]string{
146 "appId": appID,
147 "clientSecret": appSecret,
148 })
149 if err != nil {
150 return "", err
151 }
152
153 req, err := http.NewRequestWithContext(ctx, http.MethodPost, qqTokenURL, bytes.NewReader(body))
154 if err != nil {
155 return "", err
156 }
157 req.Header.Set("Content-Type", "application/json")
158
159 resp, err := qqHTTPClient.Do(req)
160 if err != nil {
161 return "", err
162 }
163 defer resp.Body.Close()
164 respBody, err := io.ReadAll(resp.Body)
165 if err != nil {
166 return "", err
167 }
168 if resp.StatusCode >= 400 {
169 return "", fmt.Errorf("qq token api error %d: %s", resp.StatusCode, string(respBody))
170 }
171
172 var result struct {
173 AccessToken string `json:"access_token"`
174 ExpiresIn int `json:"-"`
175 ExpiresRaw any `json:"expires_in"`
176 }
177 if err := json.Unmarshal(respBody, &result); err != nil {
178 return "", err
179 }
180 result.ExpiresIn, err = qqExpiresInSeconds(result.ExpiresRaw)
181 if err != nil {
182 return "", err
183 }
184 if result.AccessToken == "" {
185 return "", fmt.Errorf("empty access token")
186 }
187 a.tokenMu.Lock()
188 a.token = result.AccessToken
189 expiresIn := int(result.ExpiresIn)
190 if expiresIn > 60 {
191 a.tokenExpiry = time.Now().Add(time.Duration(expiresIn-60) * time.Second)
192 } else {
193 a.tokenExpiry = time.Now().Add(5 * time.Minute)
194 }
195 a.tokenMu.Unlock()
196 a.logger.Info("qq access token acquired", "expires_in_seconds", result.ExpiresIn)
197 return result.AccessToken, nil
198 }
199
200 func qqExpiresInSeconds(value any) (int, error) {
201 switch v := value.(type) {
202 case nil:
203 return 0, nil
204 case float64:
205 return int(v), nil
206 case string:
207 trimmed := strings.TrimSpace(v)
208 if trimmed == "" {
209 return 0, nil
210 }
211 n, err := strconv.Atoi(trimmed)
212 if err != nil {
213 return 0, fmt.Errorf("invalid qq token expires_in %q: %w", v, err)
214 }
215 return n, nil
216 default:
217 return 0, fmt.Errorf("invalid qq token expires_in type %T", value)
218 }
219 }
220
221 func (a *adapter) connectGateway(ctx context.Context, token string, intents int) error {
222 gatewayURL, err := a.getGatewayURL(ctx, token)
223 if err != nil {
224 return err
225 }
226 if parsed, parseErr := url.Parse(gatewayURL); parseErr == nil {
227 a.logger.Info("qq gateway endpoint resolved", "host", parsed.Hostname(), "sandbox", a.cfg.Sandbox)
228 }
229 conn, err := a.dialGateway(ctx, gatewayURL, token)
230 if err != nil {
231 return fmt.Errorf("dial gateway: %w", err)
232 }
233 defer conn.Close()
234 defer a.dropConn(conn)
235 if !a.trackConn(ctx, conn) {
236 // Stop already closed the tracked conn slot; entering a blocking read
237 // now would leave a connection Stop can no longer unblock.
238 return ctx.Err()
239 }
240 a.logger.Info("qq gateway connected", "sandbox", a.cfg.Sandbox)
241
242 ws := &wsClient{conn: conn, token: token, logger: a.logger}
243
244 var msg gatewayPayload
245 decoder := json.NewDecoder(conn)
246
247 // 第一次读取必须是 Hello
248 if err := decoder.Decode(&msg); err != nil {
249 return fmt.Errorf("read hello: %w", err)
250 }
251 if msg.Op != opHello {
252 return fmt.Errorf("expected op=%d hello, got op=%d", opHello, msg.Op)
253 }
254 var hello helloData
255 if err := json.Unmarshal(msg.D, &hello); err != nil {
256 return err
257 }
258 ws.heartbeatMs = int(sanitizeHeartbeatInterval(time.Duration(hello.HeartbeatInterval) * time.Millisecond).Milliseconds())
259
260 // Identify
261 identify := identifyData{
262 Token: fmt.Sprintf("QQBot %s", token),
263 Intents: intents,
264 Shard: [2]int{0, 1},
265 Properties: properties{
266 OS: "linux",
267 Browser: "reasonix",
268 Device: "reasonix-bot",
269 },
270 }
271 identifyJSON, _ := json.Marshal(identify)
272 if err := ws.send(opIdentify, identifyJSON); err != nil {
273 return fmt.Errorf("send identify: %w", err)
274 }
275
276 // 读取 Ready
277 if err := decoder.Decode(&msg); err != nil {
278 return fmt.Errorf("read ready: %w", err)
279 }
280 if err := validateQQReadyPayload(msg); err != nil {
281 return err
282 }
283 var ready struct {
284 SessionID string `json:"session_id"`
285 }
286 if err := json.Unmarshal(msg.D, &ready); err != nil {
287 return fmt.Errorf("decode ready: %w", err)
288 }
289 ws.sessionID = ready.SessionID
290 a.sessionID = ready.SessionID
291 a.seq = msg.S
292 a.logger.Info("qq gateway ready", "sandbox", a.cfg.Sandbox, "heartbeat_ms", ws.heartbeatMs)
293
294 // 启动 heartbeat
295 heartbeatCtx, heartbeatCancel := context.WithCancel(ctx)
296 defer heartbeatCancel()
297 heartbeatDone := make(chan struct{})
298 go func() {
299 defer close(heartbeatDone)
300 ticker := time.NewTicker(time.Duration(ws.heartbeatMs) * time.Millisecond)
301 defer ticker.Stop()
302 for {
303 select {
304 case <-heartbeatCtx.Done():
305 return
306 case <-ticker.C:
307 ws.mu.Lock()
308 payload := json.RawMessage("null")
309 if ws.lastSeq != 0 {
310 payload = json.RawMessage(fmt.Sprintf(`%d`, ws.lastSeq))
311 }
312 if err := ws.send(opHeartbeat, payload); err != nil {
313 ws.logger.Error("heartbeat failed", "err", err)
314 ws.mu.Unlock()
315 return
316 }
317 ws.mu.Unlock()
318 }
319 }
320 }()
321
322 // 主循环:读取 dispatch 事件
323 for {
324 if err := decoder.Decode(&msg); err != nil {
325 a.logger.Error("decode gateway message", "err", err)
326 heartbeatCancel()
327 <-heartbeatDone
328 return err
329 }
330 ws.lastSeq = msg.S
331 a.seq = msg.S
332
333 switch msg.Op {
334 case opDispatch:
335 a.handleDispatch(msg)
336 case opHeartbeatAck:
337 case opReconnect:
338 a.logger.Info("gateway requested reconnect")
339 heartbeatCancel()
340 <-heartbeatDone
341 return nil
342 case opInvalid:
343 a.sessionID = ""
344 a.seq = 0
345 a.logger.Info("gateway session invalidated")
346 heartbeatCancel()
347 <-heartbeatDone
348 return nil
349 }
350 }
351 }
352
353 // dialGateway dials the QQ gateway honoring ctx. The conn only becomes
354 // trackable after the dial returns, so Stop can interrupt a stalled TCP dial
355 // or WebSocket/TLS handshake only through ctx cancellation —
356 // websocket.DialConfig would dial with context.Background() and leave Stop's
357 // loopWG.Wait blocked with nothing to close.
358 func (a *adapter) dialGateway(ctx context.Context, gatewayURL, token string) (*websocket.Conn, error) {
359 cfg, err := websocket.NewConfig(gatewayURL, gatewayURL)
360 if err != nil {
361 return nil, err
362 }
363 cfg.Header = http.Header{}
364 cfg.Header.Set("Authorization", "QQBot "+token)
365 cfg.Header.Set("X-Union-Appid", a.appID())
366 return cfg.DialContext(ctx)
367 }
368
369 // trackConn publishes the live gateway connection so Stop can close it and
370 // unblock the blocking websocket reads, which do not honor ctx. Publication is
371 // refused once ctx is cancelled, so a conn that finishes dialing concurrently
372 // with Stop can never be left open but unreachable.
373 func (a *adapter) trackConn(ctx context.Context, conn *websocket.Conn) bool {
374 a.connMu.Lock()
375 defer a.connMu.Unlock()
376 if ctx.Err() != nil {
377 return false
378 }
379 a.conn = conn
380 return true
381 }
382
383 func (a *adapter) dropConn(conn *websocket.Conn) {
384 a.connMu.Lock()
385 if a.conn == conn {
386 a.conn = nil
387 }
388 a.connMu.Unlock()
389 }
390
391 func (a *adapter) closeConn() {
392 a.connMu.Lock()
393 conn := a.conn
394 a.conn = nil
395 a.connMu.Unlock()
396 if conn != nil {
397 conn.Close()
398 }
399 }
400
401 func (a *adapter) appID() string {
402 if value := strings.TrimSpace(a.cfg.AppID); value != "" {
403 return value
404 }
405 return strings.TrimSpace(os.Getenv("QQ_APPID"))
406 }
407
408 func (a *adapter) appSecretEnvName() string {
409 if value := strings.TrimSpace(a.cfg.AppSecretEnv); value != "" {
410 return value
411 }
412 return "QQ_BOT_APP_SECRET"
413 }
414
415 func (a *adapter) appSecret() string {
416 return strings.TrimSpace(os.Getenv(a.appSecretEnvName()))
417 }
418
419 func (a *adapter) apiBaseURL() string {
420 if a.cfg.Sandbox {
421 return qqSandboxURL
422 }
423 return qqBaseURL
424 }
425
426 func (a *adapter) getGatewayURL(ctx context.Context, token string) (string, error) {
427 req, err := http.NewRequestWithContext(ctx, http.MethodGet, a.apiBaseURL()+"/gateway", nil)
428 if err != nil {
429 return "", err
430 }
431 req.Header.Set("Authorization", "QQBot "+token)
432 resp, err := qqHTTPClient.Do(req)
433 if err != nil {
434 return "", err
435 }
436 defer resp.Body.Close()
437 respBody, err := io.ReadAll(resp.Body)
438 if err != nil {
439 return "", err
440 }
441 if resp.StatusCode >= 400 {
442 return "", fmt.Errorf("qq gateway api error %d: %s", resp.StatusCode, string(respBody))
443 }
444 var result struct {
445 URL string `json:"url"`
446 }
447 if err := json.Unmarshal(respBody, &result); err != nil {
448 return "", err
449 }
450 return validateGatewayURL(result.URL)
451 }
452
453 func validateGatewayURL(raw string) (string, error) {
454 raw = strings.TrimSpace(raw)
455 if raw == "" {
456 return "", fmt.Errorf("empty qq gateway url")
457 }
458 u, err := url.Parse(raw)
459 if err != nil {
460 return "", err
461 }
462 if u.Scheme != "wss" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || !allowedGatewayHost(u.Hostname()) {
463 return "", fmt.Errorf("unexpected qq gateway url: %s", raw)
464 }
465 return u.String(), nil
466 }
467
468 func allowedGatewayHost(hostname string) bool {
469 hostname = strings.ToLower(strings.TrimSpace(hostname))
470 for _, allowed := range allowedGatewayHosts {
471 if hostname == allowed || strings.HasSuffix(hostname, "."+allowed) {
472 return true
473 }
474 }
475 return false
476 }
477
478 func sanitizeHeartbeatInterval(interval time.Duration) time.Duration {
479 if interval <= 0 {
480 return qqMinHeartbeat
481 }
482 if interval < qqMinHeartbeat {
483 return qqMinHeartbeat
484 }
485 if interval > qqMaxHeartbeat {
486 return qqMaxHeartbeat
487 }
488 return interval
489 }
490
491 func (ws *wsClient) send(op int, d json.RawMessage) error {
492 payload := gatewayPayload{Op: op, D: d}
493 data, _ := json.Marshal(payload)
494 _, err := ws.conn.Write(data)
495 return err
496 }
497
498 func (a *adapter) handleDispatch(msg gatewayPayload) {
499 var evt dispatchEvent
500 if err := json.Unmarshal(msg.D, &evt); err != nil {
501 a.logger.Error("parse dispatch", "err", err)
502 return
503 }
504
505 ib := bot.InboundMessage{
506 Platform: bot.PlatformQQ,
507 UserID: qqAuthorID(evt),
508 UserName: evt.Author.Username,
509 Text: evt.Content,
510 MessageID: evt.ID,
511 }
512
513 switch msg.T {
514 case "C2C_MESSAGE_CREATE":
515 ib.ChatType = bot.ChatDM
516 ib.ChatID = ib.UserID
517 case "GROUP_AT_MESSAGE_CREATE":
518 ib.ChatType = bot.ChatGroup
519 ib.ChatID = evt.GroupOpenID
520 case "AT_MESSAGE_CREATE":
521 ib.ChatType = bot.ChatGuild
522 ib.ChatID = evt.ChannelID
523 case "DIRECT_MESSAGE_CREATE":
524 ib.ChatType = bot.ChatDirect
525 ib.ChatID = evt.GuildID
526 case "MESSAGE_CREATE":
527 ib.ChatType = bot.ChatDM
528 ib.ChatID = evt.ChannelID
529 default:
530 if strings.TrimSpace(msg.T) != "" {
531 a.logger.Info("qq dispatch ignored", "event", msg.T)
532 }
533 return // 忽略其他事件
534 }
535 a.logger.Info("qq dispatch received", "event", msg.T, "chat_type", ib.ChatType)
536
537 select {
538 case a.msgCh <- ib:
539 default:
540 a.logger.Warn("message channel full, dropping message")
541 }
542 }
543
544 func qqAuthorID(evt dispatchEvent) string {
545 for _, value := range []string{evt.Author.UserOpenID, evt.Author.MemberOpenID, evt.Author.UnionOpenID, evt.Author.ID} {
546 if value = strings.TrimSpace(value); value != "" {
547 return value
548 }
549 }
550 return ""
551 }
552
553 // sendMessage 使用 QQ REST API 发送消息。
554 func (a *adapter) sendMessage(ctx context.Context, msg bot.OutboundMessage) (bot.SendResult, error) {
555 text := normalizeQQMarkdownReply(msg.Text)
556 chunks := splitQQMessage(text, qqMaxChunkBytes)
557 if len(chunks) == 0 {
558 chunks = []string{""}
559 }
560 originalChunkCount := len(chunks)
561 var truncated bool
562 chunks, truncated = capQQPassiveReplyChunks(msg, chunks)
563 if truncated {
564 a.logger.Warn("qq passive reply truncated", "chat_type", msg.ChatType, "chunks", originalChunkCount, "limit", len(chunks))
565 }
566 var delivered bot.SendResult
567 for _, chunk := range chunks {
568 seq := a.nextMessageSeq(msg.ReplyToMsgID)
569 var result bot.SendResult
570 var err error
571 if msg.Keyboard == nil && a.markdownDeliveryDisabled() {
572 result, err = a.sendPlainMessageChunk(ctx, msg, chunk, seq)
573 } else {
574 result, err = a.sendMessageChunk(ctx, msg, chunk, seq)
575 }
576 if err != nil && msg.Keyboard == nil {
577 a.disableMarkdownDelivery()
578 a.logger.Warn("qq markdown delivery failed, retrying plain text", "chat_type", msg.ChatType, "err", err)
579 result, err = a.sendPlainMessageChunk(ctx, msg, chunk, a.nextMessageSeq(msg.ReplyToMsgID))
580 }
581 if err != nil {
582 a.logger.Error("qq message send failed", "chat_type", msg.ChatType, "err", err)
583 return delivered, err
584 }
585 a.logger.Info("qq message sent", "chat_type", msg.ChatType, "message_id_set", strings.TrimSpace(result.MessageID) != "")
586 delivered.Merge(result)
587 }
588 return delivered, nil
589 }
590
591 func (a *adapter) sendPlainMessageChunk(ctx context.Context, msg bot.OutboundMessage, text string, seq int) (bot.SendResult, error) {
592 return a.sendMessagePayload(ctx, msg, map[string]any{
593 "content": text,
594 "msg_type": 0,
595 }, seq)
596 }
597
598 func (a *adapter) sendMessageChunk(ctx context.Context, msg bot.OutboundMessage, text string, seq int) (bot.SendResult, error) {
599 if msg.Keyboard != nil {
600 payload := map[string]any{
601 "content": text,
602 "msg_type": 2,
603 }
604 rows := make([]map[string]any, 0, len(msg.Keyboard.Rows))
605 for _, row := range msg.Keyboard.Rows {
606 buttons := make([]map[string]any, 0, len(row.Buttons))
607 for _, btn := range row.Buttons {
608 buttons = append(buttons, map[string]any{
609 "id": strings.TrimSpace(btn.ID),
610 "render_data": map[string]any{
611 "label": btn.Label,
612 "style": btn.Style,
613 },
614 "action": map[string]any{
615 "type": 2,
616 "data": btn.CallbackID,
617 },
618 })
619 }
620 rows = append(rows, map[string]any{"buttons": buttons})
621 }
622 payload["keyboard"] = map[string]any{
623 "content": rows,
624 }
625 return a.sendMessagePayload(ctx, msg, payload, seq)
626 }
627 return a.sendMessagePayload(ctx, msg, map[string]any{
628 "markdown": map[string]string{"content": text},
629 "msg_type": 2,
630 }, seq)
631 }
632
633 func (a *adapter) sendMessagePayload(ctx context.Context, msg bot.OutboundMessage, payload map[string]any, seq int) (bot.SendResult, error) {
634 token, err := a.getAccessToken(ctx)
635 if err != nil {
636 return bot.SendResult{}, err
637 }
638
639 if msg.ReplyToMsgID != "" {
640 payload["msg_id"] = msg.ReplyToMsgID
641 }
642 if seq > 0 {
643 payload["msg_seq"] = seq
644 }
645
646 url := a.qqSendURL(msg)
647
648 body, _ := json.Marshal(payload)
649 req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewBuffer(body))
650 if err != nil {
651 return bot.SendResult{}, err
652 }
653 req.Header.Set("Authorization", "QQBot "+token)
654 req.Header.Set("Content-Type", "application/json")
655 req.Header.Set("X-Union-Appid", a.appID())
656
657 resp, err := qqHTTPClient.Do(req)
658 if err != nil {
659 return bot.SendResult{}, err
660 }
661 defer resp.Body.Close()
662
663 var result struct {
664 ID string `json:"id"`
665 Timestamp string `json:"timestamp"`
666 }
667 respBody, _ := io.ReadAll(resp.Body)
668 if resp.StatusCode >= 400 {
669 return bot.SendResult{}, fmt.Errorf("qq api error %d: %s", resp.StatusCode, string(respBody))
670 }
671 if err := json.Unmarshal(respBody, &result); err != nil {
672 return bot.SendResult{}, fmt.Errorf("decode send response: %w", err)
673 }
674
675 return bot.SendResult{MessageID: result.ID}, nil
676 }
677
678 func (a *adapter) qqSendURL(msg bot.OutboundMessage) string {
679 base := a.apiBaseURL()
680 switch msg.ChatType {
681 case bot.ChatGroup:
682 return fmt.Sprintf("%s/v2/groups/%s/messages", base, url.PathEscape(msg.ChatID))
683 case bot.ChatGuild, bot.ChatThread:
684 return fmt.Sprintf("%s/v2/channels/%s/messages", base, url.PathEscape(msg.ChatID))
685 case bot.ChatDirect:
686 return fmt.Sprintf("%s/v2/dms/%s/messages", base, url.PathEscape(msg.ChatID))
687 default:
688 return fmt.Sprintf("%s/v2/users/%s/messages", base, url.PathEscape(msg.ChatID))
689 }
690 }
691
692 func qqSendURL(msg bot.OutboundMessage) string {
693 return (&adapter{}).qqSendURL(msg)
694 }
695
696 func (a *adapter) nextMessageSeq(replyTo string) int {
697 if strings.TrimSpace(replyTo) == "" {
698 return 0
699 }
700 a.sendMu.Lock()
701 defer a.sendMu.Unlock()
702 if a.nextOutboundMsgSeq <= 0 {
703 a.nextOutboundMsgSeq = 1
704 }
705 seq := a.nextOutboundMsgSeq
706 a.nextOutboundMsgSeq++
707 return seq
708 }
709
710 func (a *adapter) markdownDeliveryDisabled() bool {
711 a.sendMu.Lock()
712 defer a.sendMu.Unlock()
713 return a.markdownDisabled
714 }
715
716 func (a *adapter) disableMarkdownDelivery() {
717 a.sendMu.Lock()
718 defer a.sendMu.Unlock()
719 a.markdownDisabled = true
720 }
721
722 func normalizeQQMarkdownReply(text string) string {
723 match := qqMarkdownWrapperRe.FindStringSubmatch(strings.TrimSpace(text))
724 if len(match) != 2 {
725 return text
726 }
727 return match[1]
728 }
729
730 func splitQQMessage(text string, maxBytes int) []string {
731 if maxBytes <= 0 {
732 maxBytes = qqMaxChunkBytes
733 }
734 var chunks []string
735 remaining := text
736 for remaining != "" {
737 if len([]byte(remaining)) <= maxBytes {
738 chunks = append(chunks, remaining)
739 break
740 }
741 candidate := fitUTF8Slice(remaining, maxBytes)
742 splitAt := pickNaturalSplit(candidate)
743 chunks = append(chunks, candidate[:splitAt])
744 remaining = strings.TrimLeft(remaining[splitAt:], " \t\r\n")
745 }
746 return chunks
747 }
748
749 func capQQPassiveReplyChunks(msg bot.OutboundMessage, chunks []string) ([]string, bool) {
750 if !qqUsesPassiveReplyLimit(msg) || len(chunks) <= qqMaxPassiveReplyChunks {
751 return chunks, false
752 }
753 capped := make([]string, 0, qqMaxPassiveReplyChunks)
754 capped = append(capped, chunks[:qqMaxPassiveReplyChunks-1]...)
755 capped = append(capped, fitQQChunkWithSuffix(chunks[qqMaxPassiveReplyChunks-1], qqPassiveReplyTruncationNotice, qqMaxChunkBytes))
756 return capped, true
757 }
758
759 func qqUsesPassiveReplyLimit(msg bot.OutboundMessage) bool {
760 if strings.TrimSpace(msg.ReplyToMsgID) == "" {
761 return false
762 }
763 return msg.ChatType == bot.ChatDM || msg.ChatType == bot.ChatGroup
764 }
765
766 func fitQQChunkWithSuffix(text, suffix string, maxBytes int) string {
767 if maxBytes <= 0 {
768 maxBytes = qqMaxChunkBytes
769 }
770 suffixBytes := len([]byte(suffix))
771 if suffixBytes >= maxBytes {
772 return fitUTF8Slice(suffix, maxBytes)
773 }
774 prefix := strings.TrimRight(fitUTF8Slice(text, maxBytes-suffixBytes), " \t\r\n")
775 if prefix == "" {
776 return strings.TrimLeft(fitUTF8Slice(suffix, maxBytes), " \t\r\n")
777 }
778 return prefix + suffix
779 }
780
781 func fitUTF8Slice(text string, maxBytes int) string {
782 return textutil.FitGraphemeBytes(text, maxBytes)
783 }
784
785 func pickNaturalSplit(candidate string) int {
786 if candidate == "" {
787 return 0
788 }
789 minSplit := len(candidate) * 6 / 10
790 for _, sep := range []string{"\n\n", "\n", " "} {
791 if at := strings.LastIndex(candidate, sep); at >= minSplit {
792 return at + len(sep)
793 }
794 }
795 return len(candidate)
796 }
797
797 lines GO