mirror of
https://github.com/chenhg5/cc-connect.git
synced 2026-07-03 12:28:10 +08:00
feat(qq): implement file send & receive via OneBot HTTP API (#323)
* feat(qq): implement FileSender via OneBot HTTP API Add file sending support (core.FileSender) to the QQ platform. Files are base64-encoded to avoid local-path issues across Windows/WSL/Docker boundaries. - SendFile: group via upload_group_file, private via send_private_msg with file message segment - callHTTPAPI: new method that calls OneBot v11 HTTP endpoints, preferred over WebSocket for large file payloads - New config option `http_url`: points to the OneBot HTTP API (e.g. NapCat/Lagrange HTTP server). When set, file operations route through HTTP; otherwise falls back to WebSocket. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(qq): implement file receiving with NapCat best-practice fallback chain Add file attachment receiving support to the QQ platform. When a user sends a file (PDF, docx, etc.), parseMessage now extracts it as a core.FileAttachment so the agent can read it. Download strategy follows NapCat recommended best practices: 1. Direct download from segment URL (120s timeout for large files) 2. get_group_file_url / get_private_file_url for fresh CDN links (original URLs expire quickly and have download count limits) 3. get_file as last resort (NapCat local download or base64) Key details: - get_group_file_url requires param "group" (string), not "group_id" - downloadLargeFile with 120s timeout (vs 30s for images) - HTTP status code validation to fail fast on 404 expired URLs Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Gou Lingyun <62201655+ganansuan647@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1477,6 +1477,9 @@ app_secret = "your-feishu-app-secret"
|
||||
# [projects.platforms.options]
|
||||
# ws_url = "ws://127.0.0.1:3001" # NapCat Forward WebSocket URL
|
||||
# token = "" # optional: access_token / 可选鉴权 token
|
||||
# http_url = "" # optional: OneBot HTTP API URL for file operations, e.g. "http://127.0.0.1:3000"
|
||||
# # Required for SendFile. Useful when OneBot runs in WSL/Docker.
|
||||
# # 可选:OneBot HTTP API 地址,用于文件发送。OneBot 在 WSL/Docker 中时必须配置。
|
||||
# allow_from = "*" # allowed QQ user IDs, e.g. "12345,67890" or "*" for all
|
||||
# # 允许的 QQ 号,如 "12345,67890","*" 表示所有
|
||||
# share_session_in_channel = false # If true, all users in a group share one agent session / 群聊共享会话
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package qq
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
@@ -39,6 +40,7 @@ type Platform struct {
|
||||
selfID int64
|
||||
dedup core.MessageDedup
|
||||
groupNameCache sync.Map // groupID -> group name
|
||||
httpURL string // OneBot HTTP API URL, e.g. "http://127.0.0.1:3000"
|
||||
}
|
||||
|
||||
func New(opts map[string]any) (core.Platform, error) {
|
||||
@@ -51,11 +53,16 @@ func New(opts map[string]any) (core.Platform, error) {
|
||||
shareSessionInChannel, _ := opts["share_session_in_channel"].(bool)
|
||||
|
||||
core.CheckAllowFrom("qq", allowFrom)
|
||||
|
||||
httpURL, _ := opts["http_url"].(string)
|
||||
httpURL = strings.TrimRight(httpURL, "/")
|
||||
|
||||
return &Platform{
|
||||
wsURL: wsURL,
|
||||
token: token,
|
||||
allowFrom: allowFrom,
|
||||
shareSessionInChannel: shareSessionInChannel,
|
||||
httpURL: httpURL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -202,8 +209,8 @@ func (p *Platform) handleMessage(payload map[string]any) {
|
||||
}
|
||||
|
||||
// Parse message content from CQ message array or raw_message
|
||||
text, images, audio := p.parseMessage(payload)
|
||||
if text == "" && len(images) == 0 && audio == nil {
|
||||
text, images, files, audio := p.parseMessage(payload, msgType, groupID)
|
||||
if text == "" && len(images) == 0 && len(files) == 0 && audio == nil {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -239,6 +246,7 @@ func (p *Platform) handleMessage(payload map[string]any) {
|
||||
ChatName: chatName,
|
||||
Content: text,
|
||||
Images: images,
|
||||
Files: files,
|
||||
Audio: audio,
|
||||
ReplyCtx: rctx,
|
||||
}
|
||||
@@ -247,9 +255,10 @@ func (p *Platform) handleMessage(payload map[string]any) {
|
||||
p.handler(p, msg)
|
||||
}
|
||||
|
||||
func (p *Platform) parseMessage(payload map[string]any) (string, []core.ImageAttachment, *core.AudioAttachment) {
|
||||
func (p *Platform) parseMessage(payload map[string]any, msgType string, groupID int64) (string, []core.ImageAttachment, []core.FileAttachment, *core.AudioAttachment) {
|
||||
var textParts []string
|
||||
var images []core.ImageAttachment
|
||||
var files []core.FileAttachment
|
||||
var audio *core.AudioAttachment
|
||||
|
||||
// OneBot message can be array of segments or a string
|
||||
@@ -303,6 +312,117 @@ func (p *Platform) parseMessage(payload map[string]any) (string, []core.ImageAtt
|
||||
Format: format,
|
||||
}
|
||||
}
|
||||
case "file":
|
||||
name, _ := data["name"].(string)
|
||||
if name == "" {
|
||||
name, _ = data["file"].(string)
|
||||
}
|
||||
fileID, _ := data["file_id"].(string)
|
||||
if fileID == "" {
|
||||
fileID, _ = data["file"].(string)
|
||||
}
|
||||
|
||||
slog.Info("qq: file segment received", "name", name, "file_id", fileID,
|
||||
"has_url", data["url"] != nil, "msg_type", msgType, "group_id", groupID)
|
||||
|
||||
var downloaded bool
|
||||
|
||||
// Step 1: Try direct URL from message segment (with longer timeout for large files)
|
||||
if url, ok := data["url"].(string); ok && url != "" {
|
||||
fileData, mime, err := downloadLargeFile(url)
|
||||
if err != nil {
|
||||
slog.Warn("qq: [step1] download file via segment URL failed", "error", err)
|
||||
} else {
|
||||
files = append(files, core.FileAttachment{
|
||||
MimeType: mime,
|
||||
Data: fileData,
|
||||
FileName: name,
|
||||
})
|
||||
downloaded = true
|
||||
slog.Info("qq: [step1] file downloaded via segment URL", "name", name, "size", len(fileData))
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Get fresh direct link via NapCat API (CDN URLs expire / have download limits)
|
||||
// NapCat docs: params are STRINGS — group (not group_id), file_id
|
||||
if !downloaded && p.httpURL != "" && fileID != "" {
|
||||
var freshURL string
|
||||
if msgType == "group" && groupID != 0 {
|
||||
groupStr := strconv.FormatInt(groupID, 10)
|
||||
slog.Info("qq: [step2] trying get_group_file_url", "file_id", fileID, "group", groupStr)
|
||||
result, err := p.callHTTPAPI("get_group_file_url", map[string]any{
|
||||
"file_id": fileID,
|
||||
"group": groupStr,
|
||||
})
|
||||
if err == nil {
|
||||
freshURL, _ = result["url"].(string)
|
||||
slog.Info("qq: [step2] get_group_file_url returned", "has_url", freshURL != "")
|
||||
} else {
|
||||
slog.Warn("qq: [step2] get_group_file_url failed", "file_id", fileID, "error", err)
|
||||
}
|
||||
} else {
|
||||
slog.Info("qq: [step2] trying get_private_file_url", "file_id", fileID)
|
||||
result, err := p.callHTTPAPI("get_private_file_url", map[string]any{
|
||||
"file_id": fileID,
|
||||
})
|
||||
if err == nil {
|
||||
freshURL, _ = result["url"].(string)
|
||||
} else {
|
||||
slog.Warn("qq: [step2] get_private_file_url failed", "file_id", fileID, "error", err)
|
||||
}
|
||||
}
|
||||
if freshURL != "" {
|
||||
fileData, mime, err := downloadLargeFile(freshURL)
|
||||
if err == nil {
|
||||
files = append(files, core.FileAttachment{
|
||||
MimeType: mime,
|
||||
Data: fileData,
|
||||
FileName: name,
|
||||
})
|
||||
downloaded = true
|
||||
slog.Info("qq: [step2] file downloaded via fresh URL", "name", name, "size", len(fileData))
|
||||
} else {
|
||||
slog.Warn("qq: [step2] download file via fresh URL failed", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Last resort — get_file (downloads to NapCat local or returns base64)
|
||||
if !downloaded && p.httpURL != "" && fileID != "" {
|
||||
slog.Info("qq: [step3] trying get_file", "file_id", fileID)
|
||||
result, err := p.callHTTPAPI("get_file", map[string]any{"file_id": fileID})
|
||||
if err == nil {
|
||||
if fileURL, ok := result["url"].(string); ok && fileURL != "" {
|
||||
fileData, mime, err := downloadLargeFile(fileURL)
|
||||
if err == nil {
|
||||
files = append(files, core.FileAttachment{
|
||||
MimeType: mime,
|
||||
Data: fileData,
|
||||
FileName: name,
|
||||
})
|
||||
downloaded = true
|
||||
}
|
||||
}
|
||||
if !downloaded {
|
||||
if b64Str, ok := result["base64"].(string); ok && b64Str != "" {
|
||||
if decoded, err := base64.StdEncoding.DecodeString(b64Str); err == nil {
|
||||
files = append(files, core.FileAttachment{
|
||||
MimeType: http.DetectContentType(decoded),
|
||||
Data: decoded,
|
||||
FileName: name,
|
||||
})
|
||||
downloaded = true
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
slog.Warn("qq: get_file API failed", "file_id", fileID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
if !downloaded {
|
||||
slog.Warn("qq: file segment could not be downloaded", "name", name)
|
||||
}
|
||||
case "at":
|
||||
// Ignore @mentions in parsed text
|
||||
}
|
||||
@@ -314,7 +434,7 @@ func (p *Platform) parseMessage(payload map[string]any) (string, []core.ImageAtt
|
||||
}
|
||||
}
|
||||
|
||||
return strings.TrimSpace(strings.Join(textParts, "")), images, audio
|
||||
return strings.TrimSpace(strings.Join(textParts, "")), images, files, audio
|
||||
}
|
||||
|
||||
// Reply sends a message as a reply to an incoming message.
|
||||
@@ -463,6 +583,56 @@ func (p *Platform) callAPI(action string, params map[string]any) (map[string]any
|
||||
}
|
||||
}
|
||||
|
||||
// callHTTPAPI calls a OneBot v11 HTTP endpoint (e.g. /upload_group_file).
|
||||
// Used for file operations — avoids WebSocket message size limits and
|
||||
// file-path issues across Windows/WSL/Docker boundaries.
|
||||
// Requires http_url to be configured.
|
||||
func (p *Platform) callHTTPAPI(action string, params map[string]any) (map[string]any, error) {
|
||||
if p.httpURL == "" {
|
||||
return nil, fmt.Errorf("qq: http_url not configured")
|
||||
}
|
||||
body, err := json.Marshal(params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
url := p.httpURL + "/" + action
|
||||
req, err := http.NewRequest("POST", url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if p.token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+p.token)
|
||||
}
|
||||
client := &http.Client{Timeout: 120 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("qq: HTTP %s failed: %w", action, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("qq: HTTP %s read body: %w", action, err)
|
||||
}
|
||||
|
||||
var apiResp struct {
|
||||
Status string `json:"status"`
|
||||
RetCode int `json:"retcode"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if json.Unmarshal(raw, &apiResp) != nil {
|
||||
return nil, fmt.Errorf("qq: HTTP %s invalid response", action)
|
||||
}
|
||||
if apiResp.RetCode != 0 {
|
||||
return nil, fmt.Errorf("qq: HTTP %s failed (retcode=%d, msg=%s)", action, apiResp.RetCode, apiResp.Message)
|
||||
}
|
||||
var result map[string]any
|
||||
_ = json.Unmarshal(apiResp.Data, &result)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────
|
||||
|
||||
type replyContext struct {
|
||||
@@ -533,6 +703,30 @@ func stripCQCodes(s string) string {
|
||||
return result.String()
|
||||
}
|
||||
|
||||
func downloadLargeFile(url string) ([]byte, string, error) {
|
||||
client := &http.Client{Timeout: 120 * time.Second}
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, "", fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
mime := resp.Header.Get("Content-Type")
|
||||
if mime == "" {
|
||||
mime = http.DetectContentType(data)
|
||||
}
|
||||
return data, mime, nil
|
||||
}
|
||||
|
||||
func downloadFile(url string) ([]byte, string, error) {
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Get(url)
|
||||
@@ -552,3 +746,55 @@ func downloadFile(url string) ([]byte, string, error) {
|
||||
}
|
||||
return data, mime, nil
|
||||
}
|
||||
|
||||
// SendFile sends a file to the conversation.
|
||||
// Implements core.FileSender.
|
||||
//
|
||||
// Uses base64-encoded file data to avoid file-path issues across
|
||||
// Windows/WSL/Docker. Routes through NapCat HTTP API when configured
|
||||
// (better for large files), falls back to WebSocket.
|
||||
func (p *Platform) SendFile(ctx context.Context, replyCtx any, file core.FileAttachment) error {
|
||||
rctx, ok := replyCtx.(*replyContext)
|
||||
if !ok {
|
||||
return fmt.Errorf("qq: SendFile: invalid reply context type %T", replyCtx)
|
||||
}
|
||||
|
||||
name := file.FileName
|
||||
if name == "" {
|
||||
name = "attachment"
|
||||
}
|
||||
|
||||
b64data := "base64://" + base64.StdEncoding.EncodeToString(file.Data)
|
||||
|
||||
// Pick API caller: prefer HTTP for large payloads, fall back to WebSocket.
|
||||
call := p.callAPI
|
||||
if p.httpURL != "" {
|
||||
call = p.callHTTPAPI
|
||||
}
|
||||
|
||||
if rctx.messageType == "group" {
|
||||
_, err := call("upload_group_file", map[string]any{
|
||||
"group_id": rctx.groupID,
|
||||
"file": b64data,
|
||||
"name": name,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("qq: SendFile group: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Private: use send_private_msg with file segment
|
||||
_, err := call("send_private_msg", map[string]any{
|
||||
"user_id": rctx.userID,
|
||||
"message": []map[string]any{
|
||||
{"type": "file", "data": map[string]any{"file": b64data, "name": name}},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("qq: SendFile private: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var _ core.FileSender = (*Platform)(nil)
|
||||
|
||||
Reference in New Issue
Block a user