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