1792 lines
48 KiB
Go
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])
|
|
}
|