From 9d65af13e48b6beb0c68e4c4d7f58e0809873951 Mon Sep 17 00:00:00 2001 From: boydzheng Date: Sun, 8 Mar 2026 09:32:58 +0800 Subject: [PATCH 1/2] feat: Add QQ Bot multimedia message sending and upload functionality, supporting four types of media: image/file/video/audio --- pkg/channels/qq/media.go | 344 +++++++++++++++++++++++++++++++++++++++ pkg/channels/qq/qq.go | 77 +++++++++ 2 files changed, 421 insertions(+) create mode 100644 pkg/channels/qq/media.go diff --git a/pkg/channels/qq/media.go b/pkg/channels/qq/media.go new file mode 100644 index 0000000000..68d726f8ce --- /dev/null +++ b/pkg/channels/qq/media.go @@ -0,0 +1,344 @@ +package qq + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "math/rand" + "net/http" + "strings" + "sync" + "time" + + "github.com/tencent-connect/botgo/dto" + "github.com/tencent-connect/botgo/openapi" + "golang.org/x/oauth2" +) + +// https://bot.q.qq.com/wiki/develop/api-v2/server-inter/message/send-receive/rich-media.html +const ( + apiBase = "https://api.sgroup.qq.com" + + defaultAPITimeout = 30 * time.Second + fileUploadTimeout = 120 * time.Second + + uploadMaxRetries = 2 + uploadBaseDelayMs = 1000 // 1s +) + +// MediaFileType represents the type of media file +type MediaFileType int + +const ( + MediaFileTypeImage MediaFileType = 1 + MediaFileTypeVideo MediaFileType = 2 + MediaFileTypeVoice MediaFileType = 3 + MediaFileTypeFile MediaFileType = 4 +) + +// UploadMediaResponse represents the response from uploading media +type UploadMediaResponse struct { + FileUUID string `json:"file_uuid"` + FileInfo string `json:"file_info"` + TTL int `json:"ttl"` + ID string `json:"id,omitempty"` +} + +// MessageResponse represents the response from sending a message +type MessageResponse struct { + ID string `json:"id"` + Timestamp string `json:"timestamp"` +} + +// Client wraps access to the QQ Bot API +type Client struct { + httpClient *http.Client + api openapi.OpenAPI + tokenSource oauth2.TokenSource + + // file_info cache (content hash -> file_info) + fileCache sync.Map // key: string, value: *fileCacheEntry +} + +type fileCacheEntry struct { + fileInfo string + expiresAt time.Time +} + +// NewClient creates a new QQ Bot API client +func NewClient(api openapi.OpenAPI, tokenSource oauth2.TokenSource) *Client { + return &Client{ + httpClient: &http.Client{}, + api: api, + tokenSource: tokenSource, + } +} + +func (c *Client) doFetchToken() (string, error) { + token, err := c.tokenSource.Token() + if err != nil { + return "", err + } + return token.AccessToken, nil +} + +// apiRequest performs an authenticated API request +func (c *Client) apiRequest(ctx context.Context, accessToken, method, path string, body any) ([]byte, error) { + url := apiBase + path + + // Choose timeout based on whether this is a file upload + timeout := defaultAPITimeout + if strings.Contains(path, "/files") { + timeout = fileUploadTimeout + } + + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + var reqBody io.Reader + if body != nil { + data, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("marshal request body: %w", err) + } + reqBody = bytes.NewReader(data) + } + + req, err := http.NewRequestWithContext(ctx, method, url, reqBody) + if err != nil { + return nil, fmt.Errorf("create request [%s]: %w", path, err) + } + req.Header.Set("Authorization", "QQBot "+accessToken) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + if ctx.Err() != nil { + return nil, fmt.Errorf("request timeout [%s]: exceeded %v", path, timeout) + } + return nil, fmt.Errorf("network error [%s]: %w", path, err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response [%s]: %w", path, err) + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + var apiErr struct { + Message string `json:"message"` + Code int `json:"code"` + } + _ = json.Unmarshal(respBody, &apiErr) + if apiErr.Message != "" { + return nil, fmt.Errorf("API error [%s]: %s", path, apiErr.Message) + } + return nil, fmt.Errorf("API error [%s]: %s", path, string(respBody)) + } + + return respBody, nil +} + +// apiRequestWithRetry wraps apiRequest with exponential backoff retry for upload +func (c *Client) apiRequestWithRetry(ctx context.Context, accessToken, method, path string, body any) ([]byte, error) { + var lastErr error + + for attempt := 0; attempt <= uploadMaxRetries; attempt++ { + respBody, err := c.apiRequest(ctx, accessToken, method, path, body) + if err == nil { + return respBody, nil + } + + lastErr = err + errMsg := err.Error() + + // Fast-fail on non-retriable errors + if strings.Contains(errMsg, "400") || strings.Contains(errMsg, "401") || + strings.Contains(errMsg, "Invalid") || strings.Contains(errMsg, "上传超时") || + strings.Contains(errMsg, "timeout") || strings.Contains(errMsg, "Timeout") { + return nil, lastErr + } + + if attempt < uploadMaxRetries { + delay := time.Duration(uploadBaseDelayMs*pow(2, attempt)) * time.Millisecond + fmt.Printf("[qqbot-api] Upload attempt %d failed, retrying in %v: %s\n", attempt+1, delay, truncate(errMsg, 100)) + + select { + case <-time.After(delay): + case <-ctx.Done(): + return nil, ctx.Err() + } + } + } + + return nil, lastErr +} + +// GetNextMsgSeq generates a unique message sequence number (0~65535) +func GetNextMsgSeq() int { + timePart := time.Now().UnixMilli() % 100000000 + random := rand.Intn(65536) + return int((timePart ^ int64(random)) % 65536) +} + +// computeFileHash computes SHA-256 hash of the given data +func computeFileHash(data string) string { + h := sha256.Sum256([]byte(data)) + return hex.EncodeToString(h[:]) +} + +// getCachedFileInfo looks up cached file_info +func (c *Client) getCachedFileInfo(contentHash, scope, targetID string, fileType MediaFileType) (string, bool) { + key := fmt.Sprintf("%s:%s:%s:%d", contentHash, scope, targetID, fileType) + val, ok := c.fileCache.Load(key) + if !ok { + return "", false + } + entry := val.(*fileCacheEntry) + if time.Now().After(entry.expiresAt) { + c.fileCache.Delete(key) + return "", false + } + return entry.fileInfo, true +} + +// setCachedFileInfo stores file_info in cache +func (c *Client) setCachedFileInfo(contentHash, scope, targetID string, fileType MediaFileType, fileInfo string, ttl int) { + key := fmt.Sprintf("%s:%s:%s:%d", contentHash, scope, targetID, fileType) + c.fileCache.Store(key, &fileCacheEntry{ + fileInfo: fileInfo, + expiresAt: time.Now().Add(time.Duration(ttl) * time.Second), + }) +} + +// uploadC2CMedia uploads a media file for C2C chat +func (c *Client) uploadC2CMedia( + ctx context.Context, + accessToken string, + openid string, + fileType MediaFileType, + url string, // public URL (mutually exclusive with fileData) + fileData string, // base64 encoded data (mutually exclusive with url) + srvSendMsg bool, +) (*UploadMediaResponse, error) { + if url == "" && fileData == "" { + return nil, fmt.Errorf("uploadC2CMedia: url or fileData is required") + } + + // Check cache if fileData is provided + if fileData != "" { + contentHash := computeFileHash(fileData) + if cached, ok := c.getCachedFileInfo(contentHash, "c2c", openid, fileType); ok { + fmt.Println("[qqbot-api] uploadC2CMedia: using cached file_info (skip upload)") + return &UploadMediaResponse{FileInfo: cached}, nil + } + } + + body := map[string]any{ + "file_type": int(fileType), + "srv_send_msg": srvSendMsg, + } + if url != "" { + body["url"] = url + } else { + body["file_data"] = fileData + } + + path := fmt.Sprintf("/v2/users/%s/files", openid) + respBody, err := c.apiRequestWithRetry(ctx, accessToken, http.MethodPost, path, body) + if err != nil { + return nil, fmt.Errorf("uploadC2CMedia: %w", err) + } + + var result UploadMediaResponse + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("uploadC2CMedia parse response: %w", err) + } + + // Store in cache + if fileData != "" && result.FileInfo != "" && result.TTL > 0 { + contentHash := computeFileHash(fileData) + c.setCachedFileInfo(contentHash, "c2c", openid, fileType, result.FileInfo, result.TTL) + } + + return &result, nil +} + +// sendC2CMediaMessage sends a rich media message to a C2C chat +func (c *Client) sendC2CMediaMessage( + ctx context.Context, + accessToken string, + openid string, + fileInfo string, + content string, +) (*MessageResponse, error) { + + body := map[string]any{ + "msg_type": dto.RichMediaMsg, + "media": map[string]string{"file_info": fileInfo}, + } + if content != "" { + body["content"] = content + } + + path := fmt.Sprintf("/v2/users/%s/messages", openid) + respBody, err := c.apiRequest(ctx, accessToken, http.MethodPost, path, body) + if err != nil { + return nil, fmt.Errorf("sendC2CMediaMessage: %w", err) + } + + var result MessageResponse + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("sendC2CMediaMessage parse response: %w", err) + } + + return &result, nil +} + +// SendC2CMediaMessage uploads a media file and sends it as a C2C message. +func (c *Client) SendC2CMediaMessage( + ctx context.Context, + openid string, + content string, + fileType MediaFileType, + fileData []byte, +) (*MessageResponse, error) { + var uploadResult *UploadMediaResponse + var err error + + accessToken, err := c.doFetchToken() + if err != nil { + return nil, fmt.Errorf("sendC2CMediaMessage get access token: %w", err) + } + base64Data := base64.StdEncoding.EncodeToString(fileData) + uploadResult, err = c.uploadC2CMedia(ctx, accessToken, openid, fileType, "", base64Data, false) + if err != nil { + return nil, fmt.Errorf("sendC2CMediaMessage upload: %w", err) + } + + // Send rich media message + return c.sendC2CMediaMessage(ctx, accessToken, openid, uploadResult.FileInfo, content) +} + +// --- Helper functions --- + +func pow(base, exp int) int { + result := 1 + for i := 0; i < exp; i++ { + result *= base + } + return result +} + +func truncate(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + return s[:maxLen] +} diff --git a/pkg/channels/qq/qq.go b/pkg/channels/qq/qq.go index 112964143d..3cef13c041 100644 --- a/pkg/channels/qq/qq.go +++ b/pkg/channels/qq/qq.go @@ -3,6 +3,7 @@ package qq import ( "context" "fmt" + "os" "sync" "time" @@ -29,6 +30,7 @@ type QQChannel struct { cancel context.CancelFunc sessionManager botgo.SessionManager processedIDs map[string]bool + client *Client mu sync.RWMutex } @@ -69,6 +71,7 @@ func (c *QQChannel) Start(ctx context.Context) error { // initialize OpenAPI client c.api = botgo.NewOpenAPI(c.config.AppID, c.tokenSource).WithTimeout(5 * time.Second) + c.client = NewClient(c.api, c.tokenSource) // register event handlers intent := event.RegisterHandlers( @@ -124,6 +127,7 @@ func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { // construct message msgToCreate := &dto.MessageToCreate{ Content: msg.Content, + MsgType: dto.TextMsg, } // send C2C message @@ -138,6 +142,79 @@ func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { return nil } +func (c *QQChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { + if !c.IsRunning() { + return channels.ErrNotRunning + } + + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + store := c.GetMediaStore() + if store == nil { + return fmt.Errorf("no media store available: %w", channels.ErrSendFailed) + } + + type MediaInfo struct { + Path string + Type MediaFileType + Caption string + } + + mediaMap := make(map[string]MediaInfo) + + for _, part := range msg.Parts { + localPath, err := store.Resolve(part.Ref) + if err != nil { + logger.ErrorCF("qq", "Failed to resolve media ref", map[string]any{ + "ref": part.Ref, + "error": err.Error(), + }) + continue + } + var fileType MediaFileType + switch part.Type { + case "image": + fileType = MediaFileTypeImage + case "video": + fileType = MediaFileTypeVideo + case "audio": + fileType = MediaFileTypeVoice + default: + fileType = MediaFileTypeFile + } + mediaMap[localPath] = MediaInfo{ + Path: localPath, + Type: fileType, + Caption: part.Caption, + } + } + + for path, media := range mediaMap { + fileData, err := os.ReadFile(path) + if err != nil { + logger.ErrorCF("qq", "Failed to read media file", map[string]any{ + "path": path, + "error": err.Error(), + }) + continue + } + _, err = c.client.SendC2CMediaMessage(ctx, msg.ChatID, media.Caption, media.Type, fileData) + if err != nil { + logger.ErrorCF("qq", "Failed to send media message", map[string]any{ + "chat_id": msg.ChatID, + "error": err.Error(), + }) + return fmt.Errorf("qq send media message: %w", channels.ErrTemporary) + } + } + + return nil +} + // handleC2CMessage handles QQ private messages func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler { return func(event *dto.WSPayload, data *dto.WSC2CMessageData) error { From 2334ba5efa1dfb3a6c758c092f2f764f19027b3c Mon Sep 17 00:00:00 2001 From: boydzheng Date: Sun, 8 Mar 2026 17:42:31 +0800 Subject: [PATCH 2/2] =?UTF-8?q?chore:=20=E4=B8=8A=E4=BC=A0=E7=9B=B8?= =?UTF-8?q?=E5=85=B3=E6=97=A5=E5=BF=97=E6=94=B9=E7=94=A8=E7=BB=93=E6=9E=84?= =?UTF-8?q?=E5=8C=96=E8=BE=93=E5=87=BA=E5=B9=B6=E7=BB=9F=E4=B8=80=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E4=BF=A1=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pkg/channels/qq/media.go | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/pkg/channels/qq/media.go b/pkg/channels/qq/media.go index 68d726f8ce..2cd13dffe7 100644 --- a/pkg/channels/qq/media.go +++ b/pkg/channels/qq/media.go @@ -15,6 +15,7 @@ import ( "sync" "time" + "github.com/sipeed/picoclaw/pkg/logger" "github.com/tencent-connect/botgo/dto" "github.com/tencent-connect/botgo/openapi" "golang.org/x/oauth2" @@ -160,14 +161,18 @@ func (c *Client) apiRequestWithRetry(ctx context.Context, accessToken, method, p // Fast-fail on non-retriable errors if strings.Contains(errMsg, "400") || strings.Contains(errMsg, "401") || - strings.Contains(errMsg, "Invalid") || strings.Contains(errMsg, "上传超时") || + strings.Contains(errMsg, "Invalid") || strings.Contains(errMsg, "upload timeout") || strings.Contains(errMsg, "timeout") || strings.Contains(errMsg, "Timeout") { return nil, lastErr } if attempt < uploadMaxRetries { delay := time.Duration(uploadBaseDelayMs*pow(2, attempt)) * time.Millisecond - fmt.Printf("[qqbot-api] Upload attempt %d failed, retrying in %v: %s\n", attempt+1, delay, truncate(errMsg, 100)) + logger.WarnCF("qq", "Upload attempt failed, retrying", map[string]any{ + "attempt": attempt + 1, + "delay": delay.String(), + "error": truncate(errMsg, 100), + }) select { case <-time.After(delay): @@ -235,7 +240,7 @@ func (c *Client) uploadC2CMedia( if fileData != "" { contentHash := computeFileHash(fileData) if cached, ok := c.getCachedFileInfo(contentHash, "c2c", openid, fileType); ok { - fmt.Println("[qqbot-api] uploadC2CMedia: using cached file_info (skip upload)") + logger.InfoC("qq", "uploadC2CMedia: using cached file_info (skip upload)") return &UploadMediaResponse{FileInfo: cached}, nil } }