Files
2026-06-02 23:14:41 +08:00

1792 lines
48 KiB
Go

package qqbot
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"time"
"unicode/utf8"
"github.com/chenhg5/cc-connect/core"
"github.com/gorilla/websocket"
)
func init() {
core.RegisterPlatform("qqbot", New)
}
const (
// Default intents: GROUP_AT_MESSAGE_CREATE (1 << 25) | INTERACTION_CREATE (1 << 26)
defaultIntents = (1 << 25) | (1 << 26)
maxReconnectBackoff = 60 * time.Second
maxReconnectAttempts = 30
tokenRefreshMargin = 5 * time.Minute
messageMaxLen = 2000
msgSeqTTL = 5 * time.Minute
messageCacheTTL = 7 * 24 * time.Hour
messageCacheMaxItems = 1000
quotedTextMaxRunes = 800
// Max attachment download size (20MB), aligned with QQ Bot platform limits.
maxAttachmentSize = 20 * 1024 * 1024
)
var (
apiBaseProduction = "https://api.sgroup.qq.com"
apiBaseSandbox = "https://sandbox.api.sgroup.qq.com"
tokenURL = "https://bots.qq.com/app/getAppAccessToken"
)
// WebSocket opcodes for the QQ Bot gateway protocol.
const (
opDispatch = 0
opHeartbeat = 1
opIdentify = 2
opResume = 6
opReconnect = 7
opInvalidSession = 9
opHello = 10
opHeartbeatACK = 11
)
// Platform implements core.Platform for the official QQ Bot API v2.
type Platform struct {
appID string
appSecret string
sandbox bool
allowFrom string
shareSessionInChannel bool
intents int
markdownSupport bool // enable markdown messages (msg_type: 2)
handler core.MessageHandler
ctx context.Context // lifetime context for the platform
cancel context.CancelFunc
// OAuth2 token management
token string
tokenExpiry time.Time
tokenMu sync.RWMutex
// WebSocket state
wsConn *websocket.Conn
wsMu sync.Mutex
sessionID string
lastSeq atomic.Int64
heartbeatMs int
heartbeatOK atomic.Bool
reconnecting atomic.Bool
connCancel context.CancelFunc // cancels per-connection goroutines (heartbeatLoop, readLoop)
// Message dedup
dedup core.MessageDedup
// msg_seq counter per event msg_id (for multiple replies to same event)
msgSeqMu sync.Mutex
msgSeqMap map[string]*msgSeqEntry
messageCacheMu sync.Mutex
messageCache map[string]cachedMessage
messageCachePath string
}
// msgSeqEntry tracks msg_seq counter with a creation timestamp for TTL eviction.
type msgSeqEntry struct {
seq atomic.Int32
createdAt time.Time
}
type cachedMessage struct {
Content string `json:"content"`
UpdatedAt time.Time `json:"updated_at"`
}
// replyContext carries the information needed to reply to a QQ Bot message.
type replyContext struct {
messageType string // "group" or "c2c"
groupOpenID string // for group messages
userOpenID string // user's openid (member_openid for group, user_openid for c2c)
eventMsgID string // msg_id from the incoming event, used for passive reply
sessionKey string // pre-computed session key for button_data embedding and session routing
}
type quotedMessage struct {
Content string `json:"content,omitempty"`
Title string `json:"title,omitempty"`
Attachments []attachment `json:"attachments,omitempty"`
}
type messageReference struct {
MessageID string `json:"message_id"`
Content string `json:"content,omitempty"`
Title string `json:"title,omitempty"`
Message *quotedMessage `json:"message,omitempty"`
ReferencedMessage *quotedMessage `json:"referenced_message,omitempty"`
SourceMessage *quotedMessage `json:"source_message,omitempty"`
}
// msgTypeQuote indicates a quote (reply) message in the QQ Bot API.
const msgTypeQuote = 103
// msgElement represents a message element in QQ Bot event.
// For quote messages (message_type=103), msg_elements[0] contains the quoted content.
type msgElement struct {
Content string `json:"content"`
Attachments []attachment `json:"attachments"`
}
// New creates a new QQ Bot platform from config options.
func New(opts map[string]any) (core.Platform, error) {
appID, _ := opts["app_id"].(string)
if appID == "" {
return nil, fmt.Errorf("qqbot: app_id is required")
}
appSecret, _ := opts["app_secret"].(string)
if appSecret == "" {
return nil, fmt.Errorf("qqbot: app_secret is required")
}
sandbox, _ := opts["sandbox"].(bool)
allowFrom, _ := opts["allow_from"].(string)
shareSessionInChannel, _ := opts["share_session_in_channel"].(bool)
markdownSupport, _ := opts["markdown_support"].(bool)
intents := defaultIntents
if v, ok := opts["intents"].(int); ok && v > 0 {
intents = v
} else if v, ok := opts["intents"].(float64); ok && v > 0 {
intents = int(v)
}
core.CheckAllowFrom("qqbot", allowFrom)
dataDir, _ := opts["cc_data_dir"].(string)
return &Platform{
appID: appID,
appSecret: appSecret,
sandbox: sandbox,
allowFrom: allowFrom,
shareSessionInChannel: shareSessionInChannel,
intents: intents,
markdownSupport: markdownSupport,
messageCachePath: qqbotMessageCachePath(dataDir),
}, nil
}
func (p *Platform) Name() string { return "qqbot" }
// Start connects to the QQ Bot gateway and begins receiving events.
func (p *Platform) Start(handler core.MessageHandler) error {
p.handler = handler
if err := p.loadMessageCache(); err != nil {
slog.Warn("qqbot: load message cache failed", "error", err)
}
// Get initial access token
if err := p.refreshToken(); err != nil {
return fmt.Errorf("qqbot: failed to get access token: %w", err)
}
ctx, cancel := context.WithCancel(context.Background())
p.ctx = ctx
p.cancel = cancel
if err := p.connectGateway(ctx); err != nil {
cancel()
return fmt.Errorf("qqbot: failed to connect gateway: %w", err)
}
slog.Info("qqbot: connected to QQ Bot gateway", "sandbox", p.sandbox)
return nil
}
// Reply sends a message as a reply to an incoming message.
func (p *Platform) Reply(ctx context.Context, replyCtx any, content string) error {
return p.Send(ctx, replyCtx, content)
}
// Send sends a message to the conversation identified by replyCtx.
func (p *Platform) Send(ctx context.Context, replyCtx any, content string) error {
rctx, ok := replyCtx.(*replyContext)
if !ok {
return fmt.Errorf("qqbot: invalid reply context")
}
chunks := core.SplitMessageCodeFenceAware(content, messageMaxLen)
for _, chunk := range chunks {
if err := p.sendMessage(rctx, chunk); err != nil {
return err
}
}
return nil
}
// SendImage uploads and sends an image via QQ Bot rich media API.
// Implements core.ImageSender.
func (p *Platform) SendImage(ctx context.Context, replyCtx any, img core.ImageAttachment) error {
rctx, ok := replyCtx.(*replyContext)
if !ok {
return fmt.Errorf("qqbot: SendImage: invalid reply context type %T", replyCtx)
}
fileInfo, err := p.uploadRichMedia(rctx, 1, img.Data, "")
if err != nil {
return fmt.Errorf("qqbot: upload image: %w", err)
}
var url string
switch rctx.messageType {
case "group":
url = fmt.Sprintf("%s/v2/groups/%s/messages", p.apiBase(), rctx.groupOpenID)
case "c2c":
url = fmt.Sprintf("%s/v2/users/%s/messages", p.apiBase(), rctx.userOpenID)
default:
return fmt.Errorf("qqbot: unknown message type %q", rctx.messageType)
}
body := map[string]any{
"msg_type": 7,
"media": map[string]any{"file_info": fileInfo},
}
if rctx.eventMsgID != "" {
body["msg_id"] = rctx.eventMsgID
body["msg_seq"] = p.nextMsgSeq(rctx.eventMsgID)
}
return p.apiRequest("POST", url, body)
}
// uploadRichMedia uploads a file to QQ Bot rich media API and returns the file_info.
// fileType: 1=image, 2=video, 3=audio, 4=file.
func (p *Platform) uploadRichMedia(rctx *replyContext, fileType int, data []byte, fileName string) (string, error) {
var url string
switch rctx.messageType {
case "group":
url = fmt.Sprintf("%s/v2/groups/%s/files", p.apiBase(), rctx.groupOpenID)
case "c2c":
url = fmt.Sprintf("%s/v2/users/%s/files", p.apiBase(), rctx.userOpenID)
default:
return "", fmt.Errorf("qqbot: unknown message type %q", rctx.messageType)
}
b64 := base64.StdEncoding.EncodeToString(data)
reqBody := map[string]any{
"file_type": fileType,
"file_data": b64,
"srv_send_msg": false,
}
if fileType == 4 && fileName != "" {
reqBody["file_name"] = fileName
}
var result struct {
FileInfo string `json:"file_info"`
}
if err := p.apiRequestJSON("POST", url, reqBody, &result); err != nil {
return "", err
}
if result.FileInfo == "" {
return "", fmt.Errorf("qqbot: upload rich media: empty file_info")
}
return result.FileInfo, nil
}
// apiRequestJSON is like apiRequest but also decodes the response body into result.
func (p *Platform) apiRequestJSON(method, url string, body any, result any) error {
var bodyReader io.Reader
if body != nil {
data, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("qqbot: marshal body: %w", err)
}
bodyReader = bytes.NewReader(data)
}
token, err := p.getAccessToken()
if err != nil {
return fmt.Errorf("qqbot: get token: %w", err)
}
req, err := http.NewRequest(method, url, bodyReader)
if err != nil {
return err
}
req.Header.Set("Authorization", "QQBot "+token)
req.Header.Set("Content-Type", "application/json")
resp, err := core.HTTPClient.Do(req)
if err != nil {
return fmt.Errorf("qqbot: api request failed: %w", err)
}
defer resp.Body.Close()
// Retry once on 401
if resp.StatusCode == http.StatusUnauthorized {
if err := p.refreshToken(); err != nil {
return fmt.Errorf("qqbot: token refresh on 401: %w", err)
}
token, _ = p.getAccessToken()
if body != nil {
data, _ := json.Marshal(body)
bodyReader = bytes.NewReader(data)
}
req2, err := http.NewRequest(method, url, bodyReader)
if err != nil {
return fmt.Errorf("qqbot: build retry request: %w", err)
}
req2.Header.Set("Authorization", "QQBot "+token)
req2.Header.Set("Content-Type", "application/json")
resp2, err := core.HTTPClient.Do(req2)
if err != nil {
return fmt.Errorf("qqbot: api retry failed: %w", err)
}
defer resp2.Body.Close()
if resp2.StatusCode >= 300 {
raw, _ := io.ReadAll(resp2.Body)
return fmt.Errorf("qqbot: api %s %s returned %d (after retry): %s", method, url, resp2.StatusCode, raw)
}
if result != nil {
if err := json.NewDecoder(resp2.Body).Decode(result); err != nil {
return fmt.Errorf("qqbot: decode response: %w", err)
}
}
return nil
}
if resp.StatusCode >= 300 {
raw, _ := io.ReadAll(resp.Body)
return fmt.Errorf("qqbot: api %s %s returned %d: %s", method, url, resp.StatusCode, raw)
}
if result != nil {
if err := json.NewDecoder(resp.Body).Decode(result); err != nil {
return fmt.Errorf("qqbot: decode response: %w", err)
}
}
return nil
}
var _ core.ImageSender = (*Platform)(nil)
// buttonDataPrefix is the prefix for QQ Bot keyboard button_data values.
// Format: perm:<decision>:<session_key>
const buttonDataPrefix = "perm:"
// SendFile uploads and sends a file via QQ Bot rich media API.
// Implements core.FileSender.
func (p *Platform) SendFile(ctx context.Context, replyCtx any, file core.FileAttachment) error {
rctx, ok := replyCtx.(*replyContext)
if !ok {
return fmt.Errorf("qqbot: SendFile: invalid reply context type %T", replyCtx)
}
fileInfo, err := p.uploadRichMedia(rctx, 4, file.Data, file.FileName)
if err != nil {
return fmt.Errorf("qqbot: upload file: %w", err)
}
var url string
switch rctx.messageType {
case "group":
url = fmt.Sprintf("%s/v2/groups/%s/messages", p.apiBase(), rctx.groupOpenID)
case "c2c":
url = fmt.Sprintf("%s/v2/users/%s/messages", p.apiBase(), rctx.userOpenID)
default:
return fmt.Errorf("qqbot: unknown message type %q", rctx.messageType)
}
body := map[string]any{
"msg_type": 7,
"media": map[string]any{"file_info": fileInfo},
}
if rctx.eventMsgID != "" {
body["msg_id"] = rctx.eventMsgID
body["msg_seq"] = p.nextMsgSeq(rctx.eventMsgID)
}
return p.apiRequest("POST", url, body)
}
var _ core.FileSender = (*Platform)(nil)
var _ core.InlineButtonSender = (*Platform)(nil)
// SendWithButtons sends a message with QQ Bot inline keyboard buttons.
// Implements core.InlineButtonSender.
func (p *Platform) SendWithButtons(ctx context.Context, replyCtx any, content string, buttons [][]core.ButtonOption) error {
rctx, ok := replyCtx.(*replyContext)
if !ok {
return fmt.Errorf("qqbot: SendWithButtons: invalid reply context type %T", replyCtx)
}
// Use session key from replyContext to embed in button_data
sessionKey := rctx.sessionKey
if sessionKey == "" {
return fmt.Errorf("qqbot: empty session key in reply context")
}
// Build QQ Bot keyboard rows from button options
var rows []map[string]any
for i, row := range buttons {
var btns []map[string]any
for j, btn := range row {
// Encode decision + session key into button_data so we can route
// the INTERACTION_CREATE event back to the right session.
// btn.Data is already "perm:allow", "perm:deny", or "perm:allow_all"
buttonData := btn.Data + ":" + sessionKey
btnID := fmt.Sprintf("b_%d_%d", i, j)
visitedLabel := "已操作"
style := 1 // blue
if strings.Contains(btn.Data, "deny") {
visitedLabel = "已拒绝"
style = 0 // grey
} else if strings.Contains(btn.Data, "allow_all") || strings.Contains(btn.Data, "allow all") {
visitedLabel = "已始终允许"
} else if strings.Contains(btn.Data, "allow") {
visitedLabel = "已允许"
}
btns = append(btns, map[string]any{
"id": btnID,
"render_data": map[string]any{
"label": btn.Text,
"visited_label": visitedLabel,
"style": style,
},
"action": map[string]any{
"type": 1, // callback
"data": buttonData,
"permission": map[string]int{"type": 2},
"click_limit": 1,
},
"group_id": "perm",
})
}
rows = append(rows, map[string]any{"buttons": btns})
}
keyboard := map[string]any{
"content": map[string]any{"rows": rows},
}
// Send message with keyboard.
// When markdown support is enabled, use msg_type 2 so QQ Bot
// properly renders the keyboard alongside markdown content.
msgType := 0
sendContent := content
if p.markdownSupport {
msgType = 2
}
body := map[string]any{
"msg_type": msgType,
"keyboard": keyboard,
}
if p.markdownSupport {
body["markdown"] = map[string]any{"content": sendContent}
} else {
body["content"] = sendContent
}
if rctx.eventMsgID != "" {
body["msg_id"] = rctx.eventMsgID
body["msg_seq"] = p.nextMsgSeq(rctx.eventMsgID)
}
var url string
switch rctx.messageType {
case "group":
url = fmt.Sprintf("%s/v2/groups/%s/messages", p.apiBase(), rctx.groupOpenID)
case "c2c":
url = fmt.Sprintf("%s/v2/users/%s/messages", p.apiBase(), rctx.userOpenID)
default:
return fmt.Errorf("qqbot: unknown message type %q", rctx.messageType)
}
slog.Debug("qqbot: sending message with keyboard",
"type", rctx.messageType, "session_key", sessionKey,
"num_buttons", len(rows), "content_len", len(content))
return p.apiRequest("POST", url, body)
}
// Stop shuts down the platform.
func (p *Platform) Stop() error {
if p.cancel != nil {
p.cancel()
}
p.wsMu.Lock()
defer p.wsMu.Unlock()
if p.wsConn != nil {
return p.wsConn.Close()
}
return nil
}
// ReconstructReplyCtx implements core.ReplyContextReconstructor.
// Session key format: "qqbot:{group_openid}:{member_openid}", "qqbot:g:{group_openid}" or "qqbot:{user_openid}"
func (p *Platform) ReconstructReplyCtx(sessionKey string) (any, error) {
parts := strings.SplitN(sessionKey, ":", 3)
if len(parts) < 2 || parts[0] != "qqbot" {
return nil, fmt.Errorf("qqbot: invalid session key %q", sessionKey)
}
if len(parts) == 3 {
if parts[1] == "g" {
return &replyContext{
messageType: "group",
groupOpenID: parts[2],
sessionKey: sessionKey,
}, nil
}
return &replyContext{
messageType: "group",
groupOpenID: parts[1],
userOpenID: parts[2],
sessionKey: sessionKey,
}, nil
}
return &replyContext{
messageType: "c2c",
userOpenID: parts[1],
sessionKey: sessionKey,
}, nil
}
// ---------------------------------------------------------------------------
// OAuth2 Token Management
// ---------------------------------------------------------------------------
func (p *Platform) refreshToken() error {
body, _ := json.Marshal(map[string]string{
"appId": p.appID,
"clientSecret": p.appSecret,
})
resp, err := core.HTTPClient.Post(tokenURL, "application/json", bytes.NewReader(body))
if err != nil {
return fmt.Errorf("token request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
raw, _ := io.ReadAll(resp.Body)
return fmt.Errorf("token request returned %d: %s", resp.StatusCode, raw)
}
var result struct {
AccessToken string `json:"access_token"`
ExpiresIn string `json:"expires_in"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return fmt.Errorf("token response decode: %w", err)
}
if result.AccessToken == "" {
return fmt.Errorf("empty access_token in response")
}
var expiresSec int
_, _ = fmt.Sscanf(result.ExpiresIn, "%d", &expiresSec)
if expiresSec <= 0 {
expiresSec = 7200
}
p.tokenMu.Lock()
p.token = result.AccessToken
p.tokenExpiry = time.Now().Add(time.Duration(expiresSec) * time.Second)
p.tokenMu.Unlock()
slog.Debug("qqbot: access token refreshed", "expires_in", expiresSec)
return nil
}
// getAccessToken returns the current token, refreshing if expired or near-expiry.
func (p *Platform) getAccessToken() (string, error) {
p.tokenMu.RLock()
token := p.token
expiry := p.tokenExpiry
p.tokenMu.RUnlock()
if token != "" && time.Now().Before(expiry.Add(-tokenRefreshMargin)) {
return token, nil
}
if err := p.refreshToken(); err != nil {
return "", err
}
p.tokenMu.RLock()
defer p.tokenMu.RUnlock()
return p.token, nil
}
// ---------------------------------------------------------------------------
// WebSocket Gateway
// ---------------------------------------------------------------------------
func (p *Platform) connectGateway(ctx context.Context) error {
token, err := p.getAccessToken()
if err != nil {
return err
}
// Get gateway URL
gatewayURL, err := p.getGatewayURL(token)
if err != nil {
return err
}
// Connect WebSocket
conn, _, err := websocket.DefaultDialer.Dial(gatewayURL, nil)
if err != nil {
return fmt.Errorf("ws connect: %w", err)
}
p.wsMu.Lock()
p.wsConn = conn
p.wsMu.Unlock()
// Wait for Hello (op 10)
if err := p.waitForHello(conn); err != nil {
conn.Close()
return err
}
// Send Identify (op 2)
if err := p.sendIdentify(conn, token); err != nil {
conn.Close()
return err
}
// Wait for READY event
if err := p.waitForReady(conn); err != nil {
conn.Close()
return err
}
// Start heartbeat and read loop with a per-connection context
// so we can cancel them cleanly on reconnect.
connCtx, connCancel := context.WithCancel(ctx)
p.connCancel = connCancel
go p.heartbeatLoop(connCtx)
go p.readLoop(connCtx)
return nil
}
func (p *Platform) getGatewayURL(token string) (string, error) {
req, err := http.NewRequest("GET", p.apiBase()+"/gateway/bot", nil)
if err != nil {
return "", err
}
req.Header.Set("Authorization", "QQBot "+token)
resp, err := core.HTTPClient.Do(req)
if err != nil {
return "", fmt.Errorf("gateway request failed: %w", err)
}
defer resp.Body.Close()
var result struct {
URL string `json:"url"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", fmt.Errorf("gateway response decode: %w", err)
}
if result.URL == "" {
return "", fmt.Errorf("empty gateway URL in response")
}
return result.URL, nil
}
type wsPayload struct {
Op int `json:"op"`
D json.RawMessage `json:"d,omitempty"`
S *int64 `json:"s,omitempty"`
T string `json:"t,omitempty"`
}
func (p *Platform) waitForHello(conn *websocket.Conn) error {
_ = conn.SetReadDeadline(time.Now().Add(15 * time.Second))
defer func() { _ = conn.SetReadDeadline(time.Time{}) }()
var msg wsPayload
if err := conn.ReadJSON(&msg); err != nil {
return fmt.Errorf("waiting for hello: %w", err)
}
if msg.Op != opHello {
return fmt.Errorf("expected op 10 (Hello), got op %d", msg.Op)
}
var hello struct {
HeartbeatInterval int `json:"heartbeat_interval"`
}
if err := json.Unmarshal(msg.D, &hello); err != nil {
slog.Warn("qqbot: failed to parse Hello payload", "error", err)
}
if hello.HeartbeatInterval > 0 {
p.heartbeatMs = hello.HeartbeatInterval
} else {
p.heartbeatMs = 41250 // sane default
}
p.heartbeatOK.Store(true)
slog.Debug("qqbot: received Hello", "heartbeat_interval", p.heartbeatMs)
return nil
}
func (p *Platform) sendIdentify(conn *websocket.Conn, token string) error {
identify := map[string]any{
"op": opIdentify,
"d": map[string]any{
"token": fmt.Sprintf("QQBot %s", token),
"intents": p.intents,
"shard": [2]int{0, 1},
},
}
p.wsMu.Lock()
err := conn.WriteJSON(identify)
p.wsMu.Unlock()
return err
}
func (p *Platform) waitForReady(conn *websocket.Conn) error {
_ = conn.SetReadDeadline(time.Now().Add(15 * time.Second))
defer func() { _ = conn.SetReadDeadline(time.Time{}) }()
var msg wsPayload
if err := conn.ReadJSON(&msg); err != nil {
return fmt.Errorf("waiting for ready: %w", err)
}
if msg.Op != opDispatch || msg.T != "READY" {
return fmt.Errorf("expected READY event, got op=%d t=%s", msg.Op, msg.T)
}
var ready struct {
SessionID string `json:"session_id"`
}
if err := json.Unmarshal(msg.D, &ready); err != nil {
slog.Warn("qqbot: failed to parse READY payload", "error", err)
}
p.sessionID = ready.SessionID
if msg.S != nil {
p.lastSeq.Store(*msg.S)
}
slog.Info("qqbot: gateway READY", "session_id", p.sessionID)
return nil
}
func (p *Platform) heartbeatLoop(ctx context.Context) {
ticker := time.NewTicker(time.Duration(p.heartbeatMs) * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if !p.heartbeatOK.Load() {
slog.Warn("qqbot: no heartbeat ACK received, reconnecting")
p.triggerReconnect(ctx)
return
}
p.heartbeatOK.Store(false)
p.sendHeartbeat()
}
}
}
func (p *Platform) sendHeartbeat() {
seq := p.lastSeq.Load()
var d json.RawMessage
if seq > 0 {
d, _ = json.Marshal(seq)
} else {
d = json.RawMessage("null")
}
p.wsMu.Lock()
defer p.wsMu.Unlock()
if p.wsConn != nil {
if err := p.wsConn.WriteJSON(wsPayload{Op: opHeartbeat, D: d}); err != nil {
slog.Debug("qqbot: heartbeat send failed", "error", err)
}
}
}
func (p *Platform) readLoop(ctx context.Context) {
for {
select {
case <-ctx.Done():
return
default:
}
p.wsMu.Lock()
conn := p.wsConn
p.wsMu.Unlock()
if conn == nil {
return
}
var msg wsPayload
if err := conn.ReadJSON(&msg); err != nil {
if ctx.Err() != nil {
return
}
slog.Error("qqbot: ws read error, reconnecting", "error", err)
p.triggerReconnect(ctx)
return
}
if msg.S != nil {
p.lastSeq.Store(*msg.S)
}
switch msg.Op {
case opDispatch:
p.handleDispatch(msg.T, msg.D)
case opHeartbeat:
// Server requested heartbeat
p.sendHeartbeat()
case opReconnect:
slog.Info("qqbot: server requested reconnect")
p.triggerReconnect(ctx)
return
case opInvalidSession:
var resumable bool
if err := json.Unmarshal(msg.D, &resumable); err != nil {
slog.Warn("qqbot: failed to parse InvalidSession payload", "error", err)
}
if resumable {
slog.Info("qqbot: invalid session (resumable), attempting resume")
} else {
slog.Info("qqbot: invalid session (not resumable), re-identifying")
p.sessionID = ""
}
p.triggerReconnect(ctx)
return
case opHeartbeatACK:
p.heartbeatOK.Store(true)
}
}
}
func (p *Platform) triggerReconnect(_ context.Context) {
if p.reconnecting.CompareAndSwap(false, true) {
go func() {
defer p.reconnecting.Store(false)
// Use the platform lifetime context, NOT the per-connection context
// that was just canceled. The caller's ctx is a child of connCtx
// which connCancel() will cancel inside reconnectLoop.
p.reconnectLoop(p.ctx)
}()
}
}
func (p *Platform) reconnectLoop(ctx context.Context) {
// Cancel old heartbeatLoop/readLoop goroutines before closing the
// connection, so they stop promptly instead of racing with the new pair.
if p.connCancel != nil {
p.connCancel()
}
// Close existing connection
p.wsMu.Lock()
if p.wsConn != nil {
p.wsConn.Close()
p.wsConn = nil
}
p.wsMu.Unlock()
backoff := time.Second
for attempt := 1; attempt <= maxReconnectAttempts; attempt++ {
select {
case <-ctx.Done():
return
default:
}
time.Sleep(backoff)
// Refresh token before reconnecting (may have expired)
if err := p.refreshToken(); err != nil {
slog.Warn("qqbot: token refresh failed during reconnect", "error", err, "attempt", attempt)
backoff = min(backoff*2, maxReconnectBackoff)
continue
}
token, _ := p.getAccessToken()
gatewayURL, err := p.getGatewayURL(token)
if err != nil {
slog.Warn("qqbot: get gateway failed during reconnect", "error", err, "attempt", attempt)
backoff = min(backoff*2, maxReconnectBackoff)
continue
}
conn, _, err := websocket.DefaultDialer.Dial(gatewayURL, nil)
if err != nil {
slog.Warn("qqbot: ws reconnect failed", "error", err, "attempt", attempt, "backoff", backoff)
backoff = min(backoff*2, maxReconnectBackoff)
continue
}
p.wsMu.Lock()
p.wsConn = conn
p.wsMu.Unlock()
if err := p.waitForHello(conn); err != nil {
slog.Warn("qqbot: hello failed during reconnect", "error", err)
p.wsMu.Lock()
p.wsConn = nil
p.wsMu.Unlock()
conn.Close()
backoff = min(backoff*2, maxReconnectBackoff)
continue
}
// closeAndNil closes the conn and clears p.wsConn so Stop()
// and other code paths don't operate on a stale reference.
closeAndNil := func() {
p.wsMu.Lock()
p.wsConn = nil
p.wsMu.Unlock()
conn.Close()
}
// Try Resume if we have a session_id, otherwise Identify
if p.sessionID != "" {
if err := p.sendResume(conn, token); err != nil {
slog.Warn("qqbot: resume failed, falling back to identify", "error", err)
p.sessionID = ""
if err := p.sendIdentify(conn, token); err != nil {
closeAndNil()
backoff = min(backoff*2, maxReconnectBackoff)
continue
}
if err := p.waitForReady(conn); err != nil {
closeAndNil()
backoff = min(backoff*2, maxReconnectBackoff)
continue
}
}
} else {
if err := p.sendIdentify(conn, token); err != nil {
closeAndNil()
backoff = min(backoff*2, maxReconnectBackoff)
continue
}
if err := p.waitForReady(conn); err != nil {
closeAndNil()
backoff = min(backoff*2, maxReconnectBackoff)
continue
}
}
slog.Info("qqbot: reconnected successfully")
connCtx, connCancel := context.WithCancel(ctx)
p.connCancel = connCancel
go p.heartbeatLoop(connCtx)
go p.readLoop(connCtx)
return
}
slog.Error("qqbot: failed to reconnect after max attempts", "attempts", maxReconnectAttempts)
}
func (p *Platform) sendResume(conn *websocket.Conn, token string) error {
resume := map[string]any{
"op": opResume,
"d": map[string]any{
"token": fmt.Sprintf("QQBot %s", token),
"session_id": p.sessionID,
"seq": p.lastSeq.Load(),
},
}
p.wsMu.Lock()
err := conn.WriteJSON(resume)
p.wsMu.Unlock()
return err
}
// ---------------------------------------------------------------------------
// Event Handling
// ---------------------------------------------------------------------------
func (p *Platform) handleDispatch(eventType string, data json.RawMessage) {
switch eventType {
case "GROUP_AT_MESSAGE_CREATE":
p.handleGroupMessage(data)
case "C2C_MESSAGE_CREATE":
p.handleC2CMessage(data)
case "INTERACTION_CREATE":
p.handleInteractionCreate(data)
case "RESUMED":
slog.Info("qqbot: session resumed successfully")
default:
slog.Debug("qqbot: unhandled event", "type", eventType)
}
}
// handleInteractionCreate handles inline keyboard button click events.
// When a user clicks a button on a message with keyboard, QQ Bot dispatches
// an INTERACTION_CREATE event. This method parses the button_data to extract
// the permission decision and session key, then creates a synthetic message
// so the engine can process it as a permission response.
func (p *Platform) handleInteractionCreate(data json.RawMessage) {
var d struct {
ID string `json:"id"`
GroupOpenID string `json:"group_openid"`
GroupMemberOpenID string `json:"group_member_openid"`
UserOpenID string `json:"user_openid"`
ChatType int `json:"chat_type"` // 1=group, 2=c2c
Data struct {
Type int `json:"type"`
Resolved struct {
ButtonData string `json:"button_data"`
ButtonID string `json:"button_id"`
} `json:"resolved"`
} `json:"data"`
}
if err := json.Unmarshal(data, &d); err != nil {
slog.Warn("qqbot: failed to parse interaction create event", "error", err)
return
}
if d.ID == "" {
return
}
slog.Debug("qqbot: INTERACTION_CREATE", "id", d.ID, "button_data", d.Data.Resolved.ButtonData)
// ACK the interaction (required by QQ Bot API to prevent "请求超时" on buttons)
_ = p.ackInteraction(d.ID)
// Parse button_data: perm:<decision>:<session_key>
buttonData := d.Data.Resolved.ButtonData
if !strings.HasPrefix(buttonData, buttonDataPrefix) {
slog.Debug("qqbot: unknown interaction button_data format", "data", buttonData)
return
}
rest := strings.TrimPrefix(buttonData, buttonDataPrefix)
// rest = "<decision>:<session_key>"
colonIdx := strings.Index(rest, ":")
if colonIdx < 0 {
slog.Warn("qqbot: invalid interaction button_data", "data", buttonData)
return
}
decision := rest[:colonIdx]
sessionKey := rest[colonIdx+1:]
if decision == "" || sessionKey == "" {
slog.Warn("qqbot: empty decision or session_key in button_data", "data", buttonData)
return
}
// Map decision to response text the engine understands
var responseText string
switch decision {
case "allow":
responseText = "allow"
case "deny":
responseText = "deny"
case "allow_all":
responseText = "allow all"
default:
slog.Warn("qqbot: unknown interaction decision", "decision", decision)
return
}
// Build reply context for sending confirmation messages
var rctx *replyContext
var userID string
switch d.ChatType {
case 1: // group
rctx = &replyContext{
messageType: "group",
groupOpenID: d.GroupOpenID,
userOpenID: d.GroupMemberOpenID,
sessionKey: sessionKey,
}
userID = d.GroupMemberOpenID
case 2: // c2c
rctx = &replyContext{
messageType: "c2c",
userOpenID: d.UserOpenID,
sessionKey: sessionKey,
}
userID = d.UserOpenID
default:
slog.Warn("qqbot: unknown interaction chat_type", "chat_type", d.ChatType)
return
}
// Create synthetic message and forward to engine as a permission response
msg := &core.Message{
SessionKey: sessionKey,
Platform: "qqbot",
MessageID: d.ID,
UserID: userID,
Content: responseText,
ReplyCtx: rctx,
}
slog.Debug("qqbot: forwarding button click as permission response",
"decision", decision, "session_key", sessionKey, "chat_type", d.ChatType)
p.handler(p, msg)
}
// ackInteraction acknowledges an INTERACTION_CREATE event.
// Uses the same pattern as hermes-agent: PUT /interactions/{id}
// with JSON body {"code": 0}. Note: no /v2/ prefix for this endpoint.
func (p *Platform) ackInteraction(interactionID string) error {
url := fmt.Sprintf("%s/interactions/%s", p.apiBase(), interactionID)
body := map[string]int{"code": 0}
return p.apiRequestJSON("PUT", url, body, nil)
}
func (p *Platform) handleGroupMessage(data json.RawMessage) {
var d struct {
ID string `json:"id"`
GroupOpenID string `json:"group_openid"`
Content string `json:"content"`
Timestamp string `json:"timestamp"`
Attachments []attachment `json:"attachments"`
MessageReference *messageReference `json:"message_reference"`
MessageType *int `json:"message_type"`
MsgElements []msgElement `json:"msg_elements"`
Author struct {
MemberOpenID string `json:"member_openid"`
} `json:"author"`
}
if err := json.Unmarshal(data, &d); err != nil {
slog.Warn("qqbot: failed to parse group message", "error", err)
return
}
if d.ID == "" || d.GroupOpenID == "" {
return
}
// Check timestamp for old messages
if ts, err := time.Parse(time.RFC3339, d.Timestamp); err == nil {
if core.IsOldMessage(ts) {
slog.Debug("qqbot: ignoring old group message", "id", d.ID)
return
}
}
if p.dedup.IsDuplicate(d.ID) {
slog.Debug("qqbot: duplicate group message ignored", "id", d.ID)
return
}
if !core.AllowList(p.allowFrom, d.Author.MemberOpenID) {
return
}
// Strip leading @bot mention (the official API includes it as content prefix)
content := stripAtMention(d.Content)
quotedText := p.resolveQuotedText(d.MessageReference)
if quotedText == "" && d.MessageType != nil && *d.MessageType == msgTypeQuote {
quotedText = quotedTextFromElements(d.MsgElements)
}
content = prependQuotedMessage(quotedText, content)
images := downloadAttachmentImages(d.Attachments)
files := downloadAttachmentFiles(d.Attachments)
p.cacheMessage(d.ID, contentOrAttachmentSummary(content, d.Attachments))
if content == "" && len(images) == 0 && len(files) == 0 {
return
}
var sessionKey string
if p.shareSessionInChannel {
sessionKey = fmt.Sprintf("qqbot:g:%s", d.GroupOpenID)
} else {
sessionKey = fmt.Sprintf("qqbot:%s:%s", d.GroupOpenID, d.Author.MemberOpenID)
}
rctx := &replyContext{
messageType: "group",
groupOpenID: d.GroupOpenID,
userOpenID: d.Author.MemberOpenID,
eventMsgID: d.ID,
sessionKey: sessionKey,
}
msg := &core.Message{
SessionKey: sessionKey,
Platform: "qqbot",
MessageID: d.ID,
UserID: d.Author.MemberOpenID,
UserName: d.Author.MemberOpenID, // official API only provides openid, no nickname
ChatName: d.GroupOpenID, // group openid as fallback (no group name API)
Content: content,
Images: images,
Files: files,
ReplyCtx: rctx,
}
slog.Debug("qqbot: group message received", "group", d.GroupOpenID, "user", d.Author.MemberOpenID, "len", len(content), "images", len(images), "files", len(files))
p.handler(p, msg)
}
func (p *Platform) handleC2CMessage(data json.RawMessage) {
var d struct {
ID string `json:"id"`
Content string `json:"content"`
Timestamp string `json:"timestamp"`
Attachments []attachment `json:"attachments"`
MessageReference *messageReference `json:"message_reference"`
MessageType *int `json:"message_type"`
MsgElements []msgElement `json:"msg_elements"`
Author struct {
UserOpenID string `json:"user_openid"`
} `json:"author"`
}
if err := json.Unmarshal(data, &d); err != nil {
slog.Warn("qqbot: failed to parse c2c message", "error", err)
return
}
if d.ID == "" {
return
}
// Check timestamp for old messages
if ts, err := time.Parse(time.RFC3339, d.Timestamp); err == nil {
if core.IsOldMessage(ts) {
slog.Debug("qqbot: ignoring old c2c message", "id", d.ID)
return
}
}
if p.dedup.IsDuplicate(d.ID) {
slog.Debug("qqbot: duplicate c2c message ignored", "id", d.ID)
return
}
if !core.AllowList(p.allowFrom, d.Author.UserOpenID) {
return
}
content := strings.TrimSpace(d.Content)
quotedText := p.resolveQuotedText(d.MessageReference)
if quotedText == "" && d.MessageType != nil && *d.MessageType == msgTypeQuote {
quotedText = quotedTextFromElements(d.MsgElements)
}
content = prependQuotedMessage(quotedText, content)
// Download image and file attachments
images := downloadAttachmentImages(d.Attachments)
files := downloadAttachmentFiles(d.Attachments)
p.cacheMessage(d.ID, contentOrAttachmentSummary(content, d.Attachments))
if content == "" && len(images) == 0 && len(files) == 0 {
return
}
sessionKey := fmt.Sprintf("qqbot:%s", d.Author.UserOpenID)
rctx := &replyContext{
messageType: "c2c",
userOpenID: d.Author.UserOpenID,
eventMsgID: d.ID,
sessionKey: sessionKey,
}
msg := &core.Message{
SessionKey: sessionKey,
Platform: "qqbot",
MessageID: d.ID,
UserID: d.Author.UserOpenID,
UserName: d.Author.UserOpenID,
Content: content,
Images: images,
Files: files,
ReplyCtx: rctx,
}
slog.Debug("qqbot: c2c message received", "user", d.Author.UserOpenID, "len", len(content), "images", len(images), "files", len(files))
p.handler(p, msg)
}
// ---------------------------------------------------------------------------
// Message Sending
// ---------------------------------------------------------------------------
func (p *Platform) sendMessage(rctx *replyContext, content string) error {
var url string
switch rctx.messageType {
case "group":
url = fmt.Sprintf("%s/v2/groups/%s/messages", p.apiBase(), rctx.groupOpenID)
case "c2c":
url = fmt.Sprintf("%s/v2/users/%s/messages", p.apiBase(), rctx.userOpenID)
default:
return fmt.Errorf("qqbot: unknown message type %q", rctx.messageType)
}
var body map[string]any
if p.markdownSupport {
// Markdown format (msg_type: 2)
body = map[string]any{
"markdown": map[string]any{
"content": content,
},
"msg_type": 2,
}
slog.Debug("qqbot: sending markdown message", "content_len", len(content), "msg_type", 2)
} else {
// Plain text format (msg_type: 0)
body = map[string]any{
"content": content,
"msg_type": 0,
}
slog.Debug("qqbot: sending text message", "content_len", len(content), "msg_type", 0)
}
// Include msg_id for passive reply if available
if rctx.eventMsgID != "" {
body["msg_id"] = rctx.eventMsgID
body["msg_seq"] = p.nextMsgSeq(rctx.eventMsgID)
}
var resp struct {
ID string `json:"id"`
MsgID string `json:"msg_id"`
}
if err := p.apiRequestJSON("POST", url, body, &resp); err != nil {
return err
}
msgID := strings.TrimSpace(resp.ID)
if msgID == "" {
msgID = strings.TrimSpace(resp.MsgID)
}
if msgID != "" {
p.cacheMessage(msgID, content)
}
return nil
}
func (p *Platform) nextMsgSeq(eventMsgID string) int32 {
p.msgSeqMu.Lock()
defer p.msgSeqMu.Unlock()
if p.msgSeqMap == nil {
p.msgSeqMap = make(map[string]*msgSeqEntry)
}
// Evict expired entries
now := time.Now()
for k, v := range p.msgSeqMap {
if now.Sub(v.createdAt) > msgSeqTTL {
delete(p.msgSeqMap, k)
}
}
entry, ok := p.msgSeqMap[eventMsgID]
if !ok {
entry = &msgSeqEntry{createdAt: now}
p.msgSeqMap[eventMsgID] = entry
}
return entry.seq.Add(1)
}
// ---------------------------------------------------------------------------
// HTTP API Helper
// ---------------------------------------------------------------------------
func (p *Platform) apiBase() string {
if p.sandbox {
return apiBaseSandbox
}
return apiBaseProduction
}
func (p *Platform) apiRequest(method, url string, body any) error {
var bodyReader io.Reader
if body != nil {
data, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("qqbot: marshal body: %w", err)
}
bodyReader = bytes.NewReader(data)
}
token, err := p.getAccessToken()
if err != nil {
return fmt.Errorf("qqbot: get token: %w", err)
}
req, err := http.NewRequest(method, url, bodyReader)
if err != nil {
return err
}
req.Header.Set("Authorization", "QQBot "+token)
req.Header.Set("Content-Type", "application/json")
resp, err := core.HTTPClient.Do(req)
if err != nil {
return fmt.Errorf("qqbot: api request failed: %w", err)
}
defer resp.Body.Close()
// Retry once on 401 (token may have expired)
if resp.StatusCode == http.StatusUnauthorized {
if err := p.refreshToken(); err != nil {
return fmt.Errorf("qqbot: token refresh on 401: %w", err)
}
token, _ = p.getAccessToken()
// Rebuild the request body reader
if body != nil {
data, _ := json.Marshal(body)
bodyReader = bytes.NewReader(data)
}
req2, err := http.NewRequest(method, url, bodyReader)
if err != nil {
return fmt.Errorf("qqbot: build retry request: %w", err)
}
req2.Header.Set("Authorization", "QQBot "+token)
req2.Header.Set("Content-Type", "application/json")
resp2, err := core.HTTPClient.Do(req2)
if err != nil {
return fmt.Errorf("qqbot: api retry failed: %w", err)
}
defer resp2.Body.Close()
if resp2.StatusCode >= 300 {
raw, _ := io.ReadAll(resp2.Body)
return fmt.Errorf("qqbot: api %s %s returned %d (after retry): %s", method, url, resp2.StatusCode, raw)
}
return nil
}
if resp.StatusCode >= 300 {
raw, _ := io.ReadAll(resp.Body)
return fmt.Errorf("qqbot: api %s %s returned %d: %s", method, url, resp.StatusCode, raw)
}
return nil
}
// attachment represents an image/file attachment in the QQ Bot API event payload.
type attachment struct {
ContentType string `json:"content_type"`
URL string `json:"url"`
Filename string `json:"filename"`
}
// downloadAttachmentImages downloads all image attachments and returns ImageAttachments.
func downloadAttachmentImages(attachments []attachment) []core.ImageAttachment {
var images []core.ImageAttachment
for _, att := range attachments {
if !strings.HasPrefix(att.ContentType, "image/") {
continue
}
url := att.URL
if url == "" {
continue
}
// The official API may omit the https:// prefix
if !strings.HasPrefix(url, "http") {
url = "https://" + url
}
resp, err := core.HTTPClient.Get(url)
if err != nil {
slog.Warn("qqbot: download image failed", "url", url, "error", err)
continue
}
if resp.StatusCode != http.StatusOK {
slog.Warn("qqbot: download image returned non-200 status", "url", url, "status", resp.StatusCode)
resp.Body.Close()
continue
}
data, err := io.ReadAll(io.LimitReader(resp.Body, maxAttachmentSize))
resp.Body.Close()
if err != nil {
slog.Warn("qqbot: read image body failed", "error", err)
continue
}
mime := att.ContentType
if mime == "" {
mime = http.DetectContentType(data)
}
images = append(images, core.ImageAttachment{
MimeType: mime,
Data: data,
FileName: att.Filename,
})
}
return images
}
// downloadAttachmentFiles downloads all non-image file attachments and returns FileAttachments.
func downloadAttachmentFiles(attachments []attachment) []core.FileAttachment {
var files []core.FileAttachment
for _, att := range attachments {
if strings.HasPrefix(att.ContentType, "image/") {
continue
}
url := att.URL
if url == "" {
continue
}
if !strings.HasPrefix(url, "http") {
url = "https://" + url
}
resp, err := core.HTTPClient.Get(url)
if err != nil {
slog.Warn("qqbot: download file failed", "url", url, "error", err)
continue
}
if resp.StatusCode != http.StatusOK {
slog.Warn("qqbot: download file returned non-200 status", "url", url, "status", resp.StatusCode)
resp.Body.Close()
continue
}
data, err := io.ReadAll(io.LimitReader(resp.Body, maxAttachmentSize))
resp.Body.Close()
if err != nil {
slog.Warn("qqbot: read file body failed", "error", err)
continue
}
mime := att.ContentType
if mime == "" {
mime = http.DetectContentType(data)
}
files = append(files, core.FileAttachment{
MimeType: mime,
Data: data,
FileName: att.Filename,
})
}
return files
}
// stripAtMention removes the leading @bot mention from group message content.
// The official QQ Bot API prefixes GROUP_AT_MESSAGE_CREATE content with an
// @mention tag like "<@!botid> " or sometimes just whitespace after the tag.
func stripAtMention(content string) string {
content = strings.TrimSpace(content)
// The official API formats the @mention as "<@!{bot_id}>"
if strings.HasPrefix(content, "<@!") {
if idx := strings.Index(content, ">"); idx >= 0 {
content = strings.TrimSpace(content[idx+1:])
}
}
return content
}
func qqbotMessageCachePath(dataDir string) string {
dataDir = strings.TrimSpace(dataDir)
if dataDir == "" {
return ""
}
return filepath.Join(dataDir, "run", "qqbot_message_cache.json")
}
func (p *Platform) loadMessageCache() error {
if p.messageCachePath == "" {
return nil
}
data, err := os.ReadFile(p.messageCachePath)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
cache := make(map[string]cachedMessage)
if err := json.Unmarshal(data, &cache); err != nil {
return err
}
p.messageCacheMu.Lock()
defer p.messageCacheMu.Unlock()
p.messageCache = cache
p.purgeMessageCacheLocked(time.Now())
return nil
}
func (p *Platform) cacheMessage(messageID, content string) {
messageID = strings.TrimSpace(messageID)
content = strings.TrimSpace(content)
if messageID == "" || content == "" {
return
}
entry := cachedMessage{
Content: truncateRunes(content, quotedTextMaxRunes),
UpdatedAt: time.Now(),
}
p.messageCacheMu.Lock()
if p.messageCache == nil {
p.messageCache = make(map[string]cachedMessage)
}
p.messageCache[messageID] = entry
p.purgeMessageCacheLocked(entry.UpdatedAt)
err := p.saveMessageCacheLocked()
p.messageCacheMu.Unlock()
if err != nil {
slog.Warn("qqbot: save message cache failed", "error", err)
}
}
func (p *Platform) resolveQuotedText(ref *messageReference) string {
if ref == nil {
return ""
}
if text := inlineQuotedText(ref); text != "" {
return text
}
messageID := strings.TrimSpace(ref.MessageID)
if messageID == "" {
return ""
}
p.messageCacheMu.Lock()
defer p.messageCacheMu.Unlock()
if p.messageCache == nil {
return ""
}
p.purgeMessageCacheLocked(time.Now())
entry, ok := p.messageCache[messageID]
if !ok {
return ""
}
return strings.TrimSpace(entry.Content)
}
func (p *Platform) purgeMessageCacheLocked(now time.Time) {
for id, entry := range p.messageCache {
if now.Sub(entry.UpdatedAt) > messageCacheTTL {
delete(p.messageCache, id)
}
}
for len(p.messageCache) > messageCacheMaxItems {
var oldestID string
var oldestAt time.Time
for id, entry := range p.messageCache {
if oldestID == "" || entry.UpdatedAt.Before(oldestAt) {
oldestID = id
oldestAt = entry.UpdatedAt
}
}
if oldestID == "" {
break
}
delete(p.messageCache, oldestID)
}
}
func (p *Platform) saveMessageCacheLocked() error {
if p.messageCachePath == "" {
return nil
}
if err := os.MkdirAll(filepath.Dir(p.messageCachePath), 0o755); err != nil {
return err
}
data, err := json.Marshal(p.messageCache)
if err != nil {
return err
}
return os.WriteFile(p.messageCachePath, data, 0o644)
}
func inlineQuotedText(ref *messageReference) string {
if ref == nil {
return ""
}
parts := []string{
strings.TrimSpace(ref.Content),
quotedMessageText(ref.Message),
quotedMessageText(ref.ReferencedMessage),
quotedMessageText(ref.SourceMessage),
strings.TrimSpace(ref.Title),
}
var chosen string
for _, part := range parts {
if part != "" {
chosen = part
break
}
}
return strings.TrimSpace(chosen)
}
func quotedMessageText(msg *quotedMessage) string {
if msg == nil {
return ""
}
content := strings.TrimSpace(msg.Content)
if content != "" {
return content
}
if title := strings.TrimSpace(msg.Title); title != "" {
return title
}
return contentOrAttachmentSummary("", msg.Attachments)
}
// quotedTextFromElements extracts quoted message text from msg_elements[0].
// QQ Bot sends the referenced message content in msg_elements[0] for quote messages (message_type=103).
func quotedTextFromElements(elements []msgElement) string {
if len(elements) == 0 {
return ""
}
elem := elements[0]
content := strings.TrimSpace(elem.Content)
if content != "" {
return content
}
return contentOrAttachmentSummary("", elem.Attachments)
}
func prependQuotedMessage(quoted, content string) string {
quoted = strings.TrimSpace(quoted)
content = strings.TrimSpace(content)
if quoted == "" {
return content
}
if content == "" {
return "[引用消息]\n" + quoted
}
return "[引用消息]\n" + quoted + "\n\n" + content
}
func contentOrAttachmentSummary(content string, attachments []attachment) string {
content = strings.TrimSpace(content)
if content != "" {
return content
}
if len(attachments) == 0 {
return ""
}
var parts []string
for _, att := range attachments {
switch {
case strings.HasPrefix(att.ContentType, "image/"):
parts = append(parts, "[图片]")
case strings.TrimSpace(att.Filename) != "":
parts = append(parts, "[附件: "+strings.TrimSpace(att.Filename)+"]")
default:
parts = append(parts, "[附件]")
}
}
return strings.Join(parts, " ")
}
func truncateRunes(s string, max int) string {
if max <= 0 || utf8.RuneCountInString(s) <= max {
return s
}
return string([]rune(s)[:max])
}