返回 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 plain := msg.Keyboard == nil && a.markdownDeliveryDisabled()
572 if plain {
573 result, err = a.sendPlainMessageChunk(ctx, msg, chunk, seq)
574 } else {
575 result, err = a.sendMessageChunk(ctx, msg, chunk, seq)
576 }
577 if err != nil && !plain && ctx.Err() == nil {
578 if msg.Keyboard == nil {
579 a.disableMarkdownDelivery()
580 }
581 a.logger.Warn("qq rich message delivery failed, retrying plain text", "chat_type", msg.ChatType, "keyboard", msg.Keyboard != nil, "err", err)
582 result, err = a.sendPlainMessageChunk(ctx, msg, chunk, a.nextMessageSeq(msg.ReplyToMsgID))
583 }
584 if err != nil {
585 a.logger.Error("qq message send failed", "chat_type", msg.ChatType, "err", err)
586 return delivered, err
587 }
588 a.logger.Info("qq message sent", "chat_type", msg.ChatType, "message_id_set", strings.TrimSpace(result.MessageID) != "")
589 delivered.Merge(result)
590 }
591 return delivered, nil
592 }
593
594 func (a *adapter) sendPlainMessageChunk(ctx context.Context, msg bot.OutboundMessage, text string, seq int) (bot.SendResult, error) {
595 return a.sendMessagePayload(ctx, msg, map[string]any{
596 "content": text,
597 "msg_type": 0,
598 }, seq)
599 }
600
601 func (a *adapter) sendMessageChunk(ctx context.Context, msg bot.OutboundMessage, text string, seq int) (bot.SendResult, error) {
602 payload := map[string]any{
603 "markdown": map[string]string{"content": text},
604 "msg_type": 2,
605 }
606 if msg.Keyboard != nil {
607 payload["keyboard"] = qqKeyboardContent(msg.Keyboard, msg.ChatType)
608 }
609 return a.sendMessagePayload(ctx, msg, payload, seq)
610 }
611
612 func (a *adapter) sendMessagePayload(ctx context.Context, msg bot.OutboundMessage, payload map[string]any, seq int) (bot.SendResult, error) {
613 token, err := a.getAccessToken(ctx)
614 if err != nil {
615 return bot.SendResult{}, err
616 }
617
618 if msg.ReplyToMsgID != "" {
619 payload["msg_id"] = msg.ReplyToMsgID
620 }
621 if seq > 0 {
622 payload["msg_seq"] = seq
623 }
624
625 url := a.qqSendURL(msg)
626
627 body, _ := json.Marshal(payload)
628 req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewBuffer(body))
629 if err != nil {
630 return bot.SendResult{}, err
631 }
632 req.Header.Set("Authorization", "QQBot "+token)
633 req.Header.Set("Content-Type", "application/json")
634 req.Header.Set("X-Union-Appid", a.appID())
635
636 resp, err := qqHTTPClient.Do(req)
637 if err != nil {
638 return bot.SendResult{}, err
639 }
640 defer resp.Body.Close()
641
642 var result struct {
643 ID string `json:"id"`
644 Timestamp string `json:"timestamp"`
645 }
646 respBody, _ := io.ReadAll(resp.Body)
647 if resp.StatusCode >= 400 {
648 return bot.SendResult{}, fmt.Errorf("qq api error %d: %s", resp.StatusCode, string(respBody))
649 }
650 if err := json.Unmarshal(respBody, &result); err != nil {
651 return bot.SendResult{}, fmt.Errorf("decode send response: %w", err)
652 }
653
654 return bot.SendResult{MessageID: result.ID}, nil
655 }
656
657 func (a *adapter) qqSendURL(msg bot.OutboundMessage) string {
658 base := a.apiBaseURL()
659 switch msg.ChatType {
660 case bot.ChatGroup:
661 return fmt.Sprintf("%s/v2/groups/%s/messages", base, url.PathEscape(msg.ChatID))
662 case bot.ChatGuild, bot.ChatThread:
663 return fmt.Sprintf("%s/v2/channels/%s/messages", base, url.PathEscape(msg.ChatID))
664 case bot.ChatDirect:
665 return fmt.Sprintf("%s/v2/dms/%s/messages", base, url.PathEscape(msg.ChatID))
666 default:
667 return fmt.Sprintf("%s/v2/users/%s/messages", base, url.PathEscape(msg.ChatID))
668 }
669 }
670
671 func qqSendURL(msg bot.OutboundMessage) string {
672 return (&adapter{}).qqSendURL(msg)
673 }
674
675 func (a *adapter) nextMessageSeq(replyTo string) int {
676 if strings.TrimSpace(replyTo) == "" {
677 return 0
678 }
679 a.sendMu.Lock()
680 defer a.sendMu.Unlock()
681 if a.nextOutboundMsgSeq <= 0 {
682 a.nextOutboundMsgSeq = 1
683 }
684 seq := a.nextOutboundMsgSeq
685 a.nextOutboundMsgSeq++
686 return seq
687 }
688
689 func (a *adapter) markdownDeliveryDisabled() bool {
690 a.sendMu.Lock()
691 defer a.sendMu.Unlock()
692 return a.markdownDisabled
693 }
694
695 func (a *adapter) disableMarkdownDelivery() {
696 a.sendMu.Lock()
697 defer a.sendMu.Unlock()
698 a.markdownDisabled = true
699 }
700
701 func normalizeQQMarkdownReply(text string) string {
702 match := qqMarkdownWrapperRe.FindStringSubmatch(strings.TrimSpace(text))
703 if len(match) != 2 {
704 return text
705 }
706 return match[1]
707 }
708
709 func splitQQMessage(text string, maxBytes int) []string {
710 if maxBytes <= 0 {
711 maxBytes = qqMaxChunkBytes
712 }
713 var chunks []string
714 remaining := text
715 for remaining != "" {
716 if len([]byte(remaining)) <= maxBytes {
717 chunks = append(chunks, remaining)
718 break
719 }
720 candidate := fitUTF8Slice(remaining, maxBytes)
721 splitAt := pickNaturalSplit(candidate)
722 chunks = append(chunks, candidate[:splitAt])
723 remaining = strings.TrimLeft(remaining[splitAt:], " \t\r\n")
724 }
725 return chunks
726 }
727
728 func capQQPassiveReplyChunks(msg bot.OutboundMessage, chunks []string) ([]string, bool) {
729 if !qqUsesPassiveReplyLimit(msg) || len(chunks) <= qqMaxPassiveReplyChunks {
730 return chunks, false
731 }
732 capped := make([]string, 0, qqMaxPassiveReplyChunks)
733 capped = append(capped, chunks[:qqMaxPassiveReplyChunks-1]...)
734 capped = append(capped, fitQQChunkWithSuffix(chunks[qqMaxPassiveReplyChunks-1], qqPassiveReplyTruncationNotice, qqMaxChunkBytes))
735 return capped, true
736 }
737
738 func qqUsesPassiveReplyLimit(msg bot.OutboundMessage) bool {
739 if strings.TrimSpace(msg.ReplyToMsgID) == "" {
740 return false
741 }
742 return msg.ChatType == bot.ChatDM || msg.ChatType == bot.ChatGroup
743 }
744
745 func fitQQChunkWithSuffix(text, suffix string, maxBytes int) string {
746 if maxBytes <= 0 {
747 maxBytes = qqMaxChunkBytes
748 }
749 suffixBytes := len([]byte(suffix))
750 if suffixBytes >= maxBytes {
751 return fitUTF8Slice(suffix, maxBytes)
752 }
753 prefix := strings.TrimRight(fitUTF8Slice(text, maxBytes-suffixBytes), " \t\r\n")
754 if prefix == "" {
755 return strings.TrimLeft(fitUTF8Slice(suffix, maxBytes), " \t\r\n")
756 }
757 return prefix + suffix
758 }
759
760 func fitUTF8Slice(text string, maxBytes int) string {
761 return textutil.FitGraphemeBytes(text, maxBytes)
762 }
763
764 func pickNaturalSplit(candidate string) int {
765 if candidate == "" {
766 return 0
767 }
768 minSplit := len(candidate) * 6 / 10
769 for _, sep := range []string{"\n\n", "\n", " "} {
770 if at := strings.LastIndex(candidate, sep); at >= minSplit {
771 return at + len(sep)
772 }
773 }
774 return len(candidate)
775 }
776
776 lines GO