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:
Goulingyun
2026-06-09 23:34:22 +08:00
committed by GitHub
parent a6f1d915fc
commit 63dbb7d27d
2 changed files with 253 additions and 4 deletions

View File

@@ -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 / 群聊共享会话

View File

@@ -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)