feat: Add QQ Bot multimedia message sending and upload functionality, supporting four types of media: image/file/video/audio
This commit is contained in:
parent
4768edc67b
commit
9d65af13e4
2 changed files with 421 additions and 0 deletions
344
pkg/channels/qq/media.go
Normal file
344
pkg/channels/qq/media.go
Normal file
|
|
@ -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]
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue