749 lines
20 KiB
Go
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)
|
|
}
|