Files
2026-07-13 13:00:08 +08:00

749 lines
20 KiB
Go

package qq
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"os"
"regexp"
"strconv"
"strings"
"sync"
"time"
"reasonix/internal/bot"
"reasonix/internal/textutil"
"golang.org/x/net/websocket"
)
const (
qqTokenURL = "https://bots.qq.com/app/getAppAccessToken"
qqBaseURL = "https://api.sgroup.qq.com"
qqSandboxURL = "https://sandbox.api.sgroup.qq.com"
qqGatewayURL = "wss://api.sgroup.qq.com/websocket"
qqMaxChunkBytes = 1500
qqMaxPassiveReplyChunks = 5
qqMinHeartbeat = 5 * time.Second
qqMaxHeartbeat = time.Minute
qqStartupValidationTimeout = 10 * time.Second
qqPassiveReplyTruncationNotice = "\n\n[Truncated: QQ allows at most 5 passive replies for one incoming message.]"
qqHTTPTimeout = 30 * time.Second
opDispatch = 0
opHeartbeat = 1
opIdentify = 2
opResume = 6
opReconnect = 7
opInvalid = 9
opHello = 10
opHeartbeatAck = 11
)
var qqMarkdownWrapperRe = regexp.MustCompile("(?is)^```(?:markdown|md)\\s*\\r?\\n([\\s\\S]*?)\\r?\\n```$")
var qqHTTPClient = &http.Client{Timeout: qqHTTPTimeout}
var allowedGatewayHosts = []string{
"api.sgroup.qq.com",
"sandbox.api.sgroup.qq.com",
"qq.com",
}
// gatewayPayload QQ WebSocket 消息载荷。
type gatewayPayload struct {
Op int `json:"op"`
D json.RawMessage `json:"d,omitempty"`
S int64 `json:"s,omitempty"`
T string `json:"t,omitempty"`
}
type helloData struct {
HeartbeatInterval int `json:"heartbeat_interval"`
}
type identifyData struct {
Token string `json:"token"`
Intents int `json:"intents"`
Shard [2]int `json:"shard"`
Properties properties `json:"properties"`
}
type properties struct {
OS string `json:"$os"`
Browser string `json:"$browser"`
Device string `json:"$device"`
}
type dispatchEvent struct {
ID string `json:"id"`
Type string `json:"type"`
Content string `json:"content"`
Timestamp string `json:"timestamp"`
Author struct {
ID string `json:"id"`
UserOpenID string `json:"user_openid"`
MemberOpenID string `json:"member_openid"`
UnionOpenID string `json:"union_openid"`
Username string `json:"username"`
} `json:"author"`
ChannelID string `json:"channel_id"`
GuildID string `json:"guild_id"`
GroupOpenID string `json:"group_openid"`
}
// wsClient 管理 QQ WebSocket 连接。
type wsClient struct {
mu sync.Mutex
conn *websocket.Conn
heartbeatMs int
sessionID string
lastSeq int64
token string
logger *slog.Logger
}
func (a *adapter) gatewayLoop(ctx context.Context) {
bot.RunWithRetry(ctx, a.logger, "qq gateway", bot.RetryConfig{}, func(ctx context.Context) error {
token, err := a.getAccessToken(ctx)
if err != nil {
return err
}
// connectGateway blocks for the connection's lifetime, returning on
// disconnect or error; RunWithRetry handles the cancellation-aware
// backoff and reconnect.
return a.connectGateway(ctx, token)
})
}
func (a *adapter) getAccessToken(ctx context.Context) (string, error) {
a.tokenMu.Lock()
if a.token != "" && time.Now().Before(a.tokenExpiry) {
token := a.token
a.tokenMu.Unlock()
return token, nil
}
a.tokenMu.Unlock()
appID := a.appID()
appSecret := a.appSecret()
if appID == "" {
return "", fmt.Errorf("qq app_id is empty")
}
if appSecret == "" {
return "", fmt.Errorf("qq app secret is empty: set the %s environment variable", a.appSecretEnvName())
}
body, err := json.Marshal(map[string]string{
"appId": appID,
"clientSecret": appSecret,
})
if err != nil {
return "", err
}
req, err := http.NewRequestWithContext(ctx, "POST", qqTokenURL, bytes.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
resp, err := qqHTTPClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
if resp.StatusCode >= 400 {
return "", fmt.Errorf("qq token api error %d: %s", resp.StatusCode, string(respBody))
}
var result struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"-"`
ExpiresRaw any `json:"expires_in"`
}
if err := json.Unmarshal(respBody, &result); err != nil {
return "", err
}
result.ExpiresIn, err = qqExpiresInSeconds(result.ExpiresRaw)
if err != nil {
return "", err
}
if result.AccessToken == "" {
return "", fmt.Errorf("empty access token")
}
a.tokenMu.Lock()
a.token = result.AccessToken
expiresIn := int(result.ExpiresIn)
if expiresIn > 60 {
a.tokenExpiry = time.Now().Add(time.Duration(expiresIn-60) * time.Second)
} else {
a.tokenExpiry = time.Now().Add(5 * time.Minute)
}
a.tokenMu.Unlock()
a.logger.Info("qq access token acquired", "expires_in_seconds", result.ExpiresIn)
return result.AccessToken, nil
}
func qqExpiresInSeconds(value any) (int, error) {
switch v := value.(type) {
case nil:
return 0, nil
case float64:
return int(v), nil
case string:
trimmed := strings.TrimSpace(v)
if trimmed == "" {
return 0, nil
}
n, err := strconv.Atoi(trimmed)
if err != nil {
return 0, fmt.Errorf("invalid qq token expires_in %q: %w", v, err)
}
return n, nil
default:
return 0, fmt.Errorf("invalid qq token expires_in type %T", value)
}
}
func (a *adapter) connectGateway(ctx context.Context, token string) error {
gatewayURL, err := a.getGatewayURL(ctx, token)
if err != nil {
return err
}
if parsed, parseErr := url.Parse(gatewayURL); parseErr == nil {
a.logger.Info("qq gateway endpoint resolved", "host", parsed.Hostname(), "sandbox", a.cfg.Sandbox)
}
cfg, err := websocket.NewConfig(gatewayURL, gatewayURL)
if err != nil {
return err
}
cfg.Header = http.Header{}
cfg.Header.Set("Authorization", "QQBot "+token)
cfg.Header.Set("X-Union-Appid", a.appID())
conn, err := websocket.DialConfig(cfg)
if err != nil {
return fmt.Errorf("dial gateway: %w", err)
}
defer conn.Close()
a.logger.Info("qq gateway connected", "sandbox", a.cfg.Sandbox)
ws := &wsClient{conn: conn, token: token, logger: a.logger}
a.ws = ws
var msg gatewayPayload
decoder := json.NewDecoder(conn)
// 第一次读取必须是 Hello
if err := decoder.Decode(&msg); err != nil {
return fmt.Errorf("read hello: %w", err)
}
if msg.Op != opHello {
return fmt.Errorf("expected op=%d hello, got op=%d", opHello, msg.Op)
}
var hello helloData
if err := json.Unmarshal(msg.D, &hello); err != nil {
return err
}
ws.heartbeatMs = int(sanitizeHeartbeatInterval(time.Duration(hello.HeartbeatInterval) * time.Millisecond).Milliseconds())
// Identify
identify := identifyData{
Token: fmt.Sprintf("QQBot %s", token),
Intents: 1<<0 | 1<<1 | 1<<9 | 1<<10 | 1<<12 | 1<<25 | 1<<26,
Shard: [2]int{0, 1},
Properties: properties{
OS: "linux",
Browser: "reasonix",
Device: "reasonix-bot",
},
}
identifyJSON, _ := json.Marshal(identify)
if err := ws.send(opIdentify, identifyJSON); err != nil {
return fmt.Errorf("send identify: %w", err)
}
// 读取 Ready
if err := decoder.Decode(&msg); err != nil {
return fmt.Errorf("read ready: %w", err)
}
if msg.Op == opDispatch && msg.T == "READY" {
var ready struct {
SessionID string `json:"session_id"`
}
if err := json.Unmarshal(msg.D, &ready); err != nil {
return fmt.Errorf("decode ready: %w", err)
}
ws.sessionID = ready.SessionID
a.sessionID = ready.SessionID
a.seq = msg.S
a.logger.Info("qq gateway ready", "sandbox", a.cfg.Sandbox, "heartbeat_ms", ws.heartbeatMs)
} else {
a.logger.Warn("qq gateway expected ready event", "op", msg.Op, "event", msg.T)
}
// 启动 heartbeat
heartbeatCtx, heartbeatCancel := context.WithCancel(ctx)
defer heartbeatCancel()
heartbeatDone := make(chan struct{})
go func() {
defer close(heartbeatDone)
ticker := time.NewTicker(time.Duration(ws.heartbeatMs) * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-heartbeatCtx.Done():
return
case <-ticker.C:
ws.mu.Lock()
payload := json.RawMessage("null")
if ws.lastSeq != 0 {
payload = json.RawMessage(fmt.Sprintf(`%d`, ws.lastSeq))
}
if err := ws.send(opHeartbeat, payload); err != nil {
ws.logger.Error("heartbeat failed", "err", err)
ws.mu.Unlock()
return
}
ws.mu.Unlock()
}
}
}()
// 主循环:读取 dispatch 事件
for {
if err := decoder.Decode(&msg); err != nil {
a.logger.Error("decode gateway message", "err", err)
heartbeatCancel()
<-heartbeatDone
return err
}
ws.lastSeq = msg.S
a.seq = msg.S
switch msg.Op {
case opDispatch:
a.handleDispatch(msg)
case opHeartbeatAck:
case opReconnect:
a.logger.Info("gateway requested reconnect")
heartbeatCancel()
<-heartbeatDone
return nil
case opInvalid:
a.sessionID = ""
a.seq = 0
a.logger.Info("gateway session invalidated")
heartbeatCancel()
<-heartbeatDone
return nil
}
}
}
func (a *adapter) appID() string {
if value := strings.TrimSpace(a.cfg.AppID); value != "" {
return value
}
return strings.TrimSpace(os.Getenv("QQ_APPID"))
}
func (a *adapter) appSecretEnvName() string {
if value := strings.TrimSpace(a.cfg.AppSecretEnv); value != "" {
return value
}
return "QQ_BOT_APP_SECRET"
}
func (a *adapter) appSecret() string {
return strings.TrimSpace(os.Getenv(a.appSecretEnvName()))
}
func (a *adapter) apiBaseURL() string {
if a.cfg.Sandbox {
return qqSandboxURL
}
return qqBaseURL
}
func (a *adapter) getGatewayURL(ctx context.Context, token string) (string, error) {
req, err := http.NewRequestWithContext(ctx, "GET", a.apiBaseURL()+"/gateway", nil)
if err != nil {
return "", err
}
req.Header.Set("Authorization", "QQBot "+token)
resp, err := qqHTTPClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
if resp.StatusCode >= 400 {
return "", fmt.Errorf("qq gateway api error %d: %s", resp.StatusCode, string(respBody))
}
var result struct {
URL string `json:"url"`
}
if err := json.Unmarshal(respBody, &result); err != nil {
return "", err
}
return validateGatewayURL(result.URL)
}
func validateGatewayURL(raw string) (string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", fmt.Errorf("empty qq gateway url")
}
u, err := url.Parse(raw)
if err != nil {
return "", err
}
if u.Scheme != "wss" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || !allowedGatewayHost(u.Hostname()) {
return "", fmt.Errorf("unexpected qq gateway url: %s", raw)
}
return u.String(), nil
}
func allowedGatewayHost(hostname string) bool {
hostname = strings.ToLower(strings.TrimSpace(hostname))
for _, allowed := range allowedGatewayHosts {
if hostname == allowed || strings.HasSuffix(hostname, "."+allowed) {
return true
}
}
return false
}
func sanitizeHeartbeatInterval(interval time.Duration) time.Duration {
if interval <= 0 {
return qqMinHeartbeat
}
if interval < qqMinHeartbeat {
return qqMinHeartbeat
}
if interval > qqMaxHeartbeat {
return qqMaxHeartbeat
}
return interval
}
func (ws *wsClient) send(op int, d json.RawMessage) error {
payload := gatewayPayload{Op: op, D: d}
data, _ := json.Marshal(payload)
_, err := ws.conn.Write(data)
return err
}
func (a *adapter) handleDispatch(msg gatewayPayload) {
var evt dispatchEvent
if err := json.Unmarshal(msg.D, &evt); err != nil {
a.logger.Error("parse dispatch", "err", err)
return
}
ib := bot.InboundMessage{
Platform: bot.PlatformQQ,
UserID: qqAuthorID(evt),
UserName: evt.Author.Username,
Text: evt.Content,
MessageID: evt.ID,
}
switch msg.T {
case "C2C_MESSAGE_CREATE":
ib.ChatType = bot.ChatDM
ib.ChatID = ib.UserID
case "GROUP_AT_MESSAGE_CREATE":
ib.ChatType = bot.ChatGroup
ib.ChatID = evt.GroupOpenID
case "AT_MESSAGE_CREATE":
ib.ChatType = bot.ChatGuild
ib.ChatID = evt.ChannelID
case "DIRECT_MESSAGE_CREATE":
ib.ChatType = bot.ChatDirect
ib.ChatID = evt.GuildID
case "MESSAGE_CREATE":
ib.ChatType = bot.ChatDM
ib.ChatID = evt.ChannelID
default:
if strings.TrimSpace(msg.T) != "" {
a.logger.Info("qq dispatch ignored", "event", msg.T)
}
return // 忽略其他事件
}
a.logger.Info("qq dispatch received", "event", msg.T, "chat_type", ib.ChatType)
select {
case a.msgCh <- ib:
default:
a.logger.Warn("message channel full, dropping message")
}
}
func qqAuthorID(evt dispatchEvent) string {
for _, value := range []string{evt.Author.UserOpenID, evt.Author.MemberOpenID, evt.Author.UnionOpenID, evt.Author.ID} {
if value = strings.TrimSpace(value); value != "" {
return value
}
}
return ""
}
// sendMessage 使用 QQ REST API 发送消息。
func (a *adapter) sendMessage(ctx context.Context, msg bot.OutboundMessage) (bot.SendResult, error) {
text := normalizeQQMarkdownReply(msg.Text)
chunks := splitQQMessage(text, qqMaxChunkBytes)
if len(chunks) == 0 {
chunks = []string{""}
}
originalChunkCount := len(chunks)
var truncated bool
chunks, truncated = capQQPassiveReplyChunks(msg, chunks)
if truncated {
a.logger.Warn("qq passive reply truncated", "chat_type", msg.ChatType, "chunks", originalChunkCount, "limit", len(chunks))
}
var delivered bot.SendResult
for _, chunk := range chunks {
seq := a.nextMessageSeq(msg.ReplyToMsgID)
var result bot.SendResult
var err error
if msg.Keyboard == nil && a.markdownDeliveryDisabled() {
result, err = a.sendPlainMessageChunk(ctx, msg, chunk, seq)
} else {
result, err = a.sendMessageChunk(ctx, msg, chunk, seq)
}
if err != nil && msg.Keyboard == nil {
a.disableMarkdownDelivery()
a.logger.Warn("qq markdown delivery failed, retrying plain text", "chat_type", msg.ChatType, "err", err)
result, err = a.sendPlainMessageChunk(ctx, msg, chunk, a.nextMessageSeq(msg.ReplyToMsgID))
}
if err != nil {
a.logger.Error("qq message send failed", "chat_type", msg.ChatType, "err", err)
return delivered, err
}
a.logger.Info("qq message sent", "chat_type", msg.ChatType, "message_id_set", strings.TrimSpace(result.MessageID) != "")
delivered.Merge(result)
}
return delivered, nil
}
func (a *adapter) sendPlainMessageChunk(ctx context.Context, msg bot.OutboundMessage, text string, seq int) (bot.SendResult, error) {
return a.sendMessagePayload(ctx, msg, map[string]any{
"content": text,
"msg_type": 0,
}, seq)
}
func (a *adapter) sendMessageChunk(ctx context.Context, msg bot.OutboundMessage, text string, seq int) (bot.SendResult, error) {
if msg.Keyboard != nil {
payload := map[string]any{
"content": text,
"msg_type": 2,
}
rows := make([]map[string]any, 0, len(msg.Keyboard.Rows))
for _, row := range msg.Keyboard.Rows {
buttons := make([]map[string]any, 0, len(row.Buttons))
for _, btn := range row.Buttons {
buttons = append(buttons, map[string]any{
"id": strings.TrimSpace(btn.ID),
"render_data": map[string]any{
"label": btn.Label,
"style": btn.Style,
},
"action": map[string]any{
"type": 2,
"data": btn.CallbackID,
},
})
}
rows = append(rows, map[string]any{"buttons": buttons})
}
payload["keyboard"] = map[string]interface{}{
"content": rows,
}
return a.sendMessagePayload(ctx, msg, payload, seq)
}
return a.sendMessagePayload(ctx, msg, map[string]any{
"markdown": map[string]string{"content": text},
"msg_type": 2,
}, seq)
}
func (a *adapter) sendMessagePayload(ctx context.Context, msg bot.OutboundMessage, payload map[string]any, seq int) (bot.SendResult, error) {
token, err := a.getAccessToken(ctx)
if err != nil {
return bot.SendResult{}, err
}
if msg.ReplyToMsgID != "" {
payload["msg_id"] = msg.ReplyToMsgID
}
if seq > 0 {
payload["msg_seq"] = seq
}
url := a.qqSendURL(msg)
body, _ := json.Marshal(payload)
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(body))
if err != nil {
return bot.SendResult{}, err
}
req.Header.Set("Authorization", "QQBot "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Union-Appid", a.appID())
resp, err := qqHTTPClient.Do(req)
if err != nil {
return bot.SendResult{}, err
}
defer resp.Body.Close()
var result struct {
ID string `json:"id"`
Timestamp string `json:"timestamp"`
}
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 400 {
return bot.SendResult{}, fmt.Errorf("qq api error %d: %s", resp.StatusCode, string(respBody))
}
if err := json.Unmarshal(respBody, &result); err != nil {
return bot.SendResult{}, fmt.Errorf("decode send response: %w", err)
}
return bot.SendResult{MessageID: result.ID}, nil
}
func (a *adapter) qqSendURL(msg bot.OutboundMessage) string {
base := a.apiBaseURL()
switch msg.ChatType {
case bot.ChatGroup:
return fmt.Sprintf("%s/v2/groups/%s/messages", base, url.PathEscape(msg.ChatID))
case bot.ChatGuild, bot.ChatThread:
return fmt.Sprintf("%s/v2/channels/%s/messages", base, url.PathEscape(msg.ChatID))
case bot.ChatDirect:
return fmt.Sprintf("%s/v2/dms/%s/messages", base, url.PathEscape(msg.ChatID))
default:
return fmt.Sprintf("%s/v2/users/%s/messages", base, url.PathEscape(msg.ChatID))
}
}
func qqSendURL(msg bot.OutboundMessage) string {
return (&adapter{}).qqSendURL(msg)
}
func (a *adapter) nextMessageSeq(replyTo string) int {
if strings.TrimSpace(replyTo) == "" {
return 0
}
a.sendMu.Lock()
defer a.sendMu.Unlock()
if a.nextOutboundMsgSeq <= 0 {
a.nextOutboundMsgSeq = 1
}
seq := a.nextOutboundMsgSeq
a.nextOutboundMsgSeq++
return seq
}
func (a *adapter) markdownDeliveryDisabled() bool {
a.sendMu.Lock()
defer a.sendMu.Unlock()
return a.markdownDisabled
}
func (a *adapter) disableMarkdownDelivery() {
a.sendMu.Lock()
defer a.sendMu.Unlock()
a.markdownDisabled = true
}
func normalizeQQMarkdownReply(text string) string {
match := qqMarkdownWrapperRe.FindStringSubmatch(strings.TrimSpace(text))
if len(match) != 2 {
return text
}
return match[1]
}
func splitQQMessage(text string, maxBytes int) []string {
if maxBytes <= 0 {
maxBytes = qqMaxChunkBytes
}
var chunks []string
remaining := text
for remaining != "" {
if len([]byte(remaining)) <= maxBytes {
chunks = append(chunks, remaining)
break
}
candidate := fitUTF8Slice(remaining, maxBytes)
splitAt := pickNaturalSplit(candidate)
chunks = append(chunks, candidate[:splitAt])
remaining = strings.TrimLeft(remaining[splitAt:], " \t\r\n")
}
return chunks
}
func capQQPassiveReplyChunks(msg bot.OutboundMessage, chunks []string) ([]string, bool) {
if !qqUsesPassiveReplyLimit(msg) || len(chunks) <= qqMaxPassiveReplyChunks {
return chunks, false
}
capped := make([]string, 0, qqMaxPassiveReplyChunks)
capped = append(capped, chunks[:qqMaxPassiveReplyChunks-1]...)
capped = append(capped, fitQQChunkWithSuffix(chunks[qqMaxPassiveReplyChunks-1], qqPassiveReplyTruncationNotice, qqMaxChunkBytes))
return capped, true
}
func qqUsesPassiveReplyLimit(msg bot.OutboundMessage) bool {
if strings.TrimSpace(msg.ReplyToMsgID) == "" {
return false
}
return msg.ChatType == bot.ChatDM || msg.ChatType == bot.ChatGroup
}
func fitQQChunkWithSuffix(text, suffix string, maxBytes int) string {
if maxBytes <= 0 {
maxBytes = qqMaxChunkBytes
}
suffixBytes := len([]byte(suffix))
if suffixBytes >= maxBytes {
return fitUTF8Slice(suffix, maxBytes)
}
prefix := strings.TrimRight(fitUTF8Slice(text, maxBytes-suffixBytes), " \t\r\n")
if prefix == "" {
return strings.TrimLeft(fitUTF8Slice(suffix, maxBytes), " \t\r\n")
}
return prefix + suffix
}
func fitUTF8Slice(text string, maxBytes int) string {
return textutil.FitGraphemeBytes(text, maxBytes)
}
func pickNaturalSplit(candidate string) int {
if candidate == "" {
return 0
}
minSplit := len(candidate) * 6 / 10
for _, sep := range []string{"\n\n", "\n", " "} {
if at := strings.LastIndex(candidate, sep); at >= minSplit {
return at + len(sep)
}
}
return len(candidate)
}