feat(qqbot): add file send/receive with robustness checks (#685)

* chore(config): 添加 demo 目录到 git 忽略列表

- 防止 demo 相关文件被意外提交到仓库
- 保持仓库整洁,避免临时演示文件污染版本历史

* feat(qqbot): add file attachment send and receive support

- Add downloadAttachmentFiles() to download non-image attachments
- Populate core.Message.Files in group and C2C message handlers
- Implement core.FileSender via SendFile() using rich media API (file_type=4)

* ci: add test-build workflow for tag-triggered linux/amd64 builds

* fix(ci): add contents:write permission for release creation

* fix(ci): separate build and release jobs with artifact passing

* fix(qqbot): pass file_name in rich media upload for correct filename display

* chore: remove test-build workflow and restore gitignore

* fix(qqbot): add HTTP status check, LimitReader, and tests for file download

- Check resp.StatusCode in downloadAttachmentImages and downloadAttachmentFiles
- Use io.LimitReader with 20MB cap to prevent unbounded memory reads
- Add unit tests for download functions and uploadRichMedia file_name branch
- Restore test-build.yml workflow

* chore: remove test-build workflow

* test(qqbot): use httptest in SkipsImages test instead of example.com

* fix(qqbot): extract quoted message content from msg_elements for quote replies

When a user quotes/replies to a message, QQ Bot API sends message_type=103
with the referenced content in msg_elements[0]. Previously only message_reference
inline fields and cache were checked, which often came up empty.

* chore: restore test-build workflow for tag-based CI

* chore: remove test-build workflow

---------

Co-authored-by: ferock <ferock@example.com>
This commit is contained in:
ferocknewandferock authored and GitHub committed 2026-04-22 11:12:51 +08:00
1 parent 04b7a483ac
commit cf9798c9d7
2 files changed
+509 -16

No files matched your search

+147 -16
View File
@@ -26,10 +26,6 @@ func init() {
}
const (
apiBaseProduction = "https://api.sgroup.qq.com"
apiBaseSandbox = "https://sandbox.api.sgroup.qq.com"
tokenURL = "https://bots.qq.com/app/getAppAccessToken"
// Default intent: GROUP_AND_C2C_EVENT (1 << 25)
defaultIntents = 1 << 25
@@ -41,6 +37,15 @@ const (
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.
@@ -127,6 +132,16 @@ type messageReference struct {
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)
@@ -219,7 +234,7 @@ func (p *Platform) SendImage(ctx context.Context, replyCtx any, img core.ImageAt
return fmt.Errorf("qqbot: SendImage: invalid reply context type %T", replyCtx)
}
fileInfo, err := p.uploadRichMedia(rctx, 1, img.Data)
fileInfo, err := p.uploadRichMedia(rctx, 1, img.Data, "")
if err != nil {
return fmt.Errorf("qqbot: upload image: %w", err)
}
@@ -248,7 +263,7 @@ func (p *Platform) SendImage(ctx context.Context, replyCtx any, img core.ImageAt
// 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) (string, error) {
func (p *Platform) uploadRichMedia(rctx *replyContext, fileType int, data []byte, fileName string) (string, error) {
var url string
switch rctx.messageType {
case "group":
@@ -265,6 +280,9 @@ func (p *Platform) uploadRichMedia(rctx *replyContext, fileType int, data []byte
"file_data": b64,
"srv_send_msg": false,
}
if fileType == 4 && fileName != "" {
reqBody["file_name"] = fileName
}
var result struct {
FileInfo string `json:"file_info"`
@@ -357,6 +375,43 @@ func (p *Platform) apiRequestJSON(method, url string, body any, result any) erro
var _ core.ImageSender = (*Platform)(nil)
// 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)
// Stop shuts down the platform.
func (p *Platform) Stop() error {
if p.cancel != nil {
@@ -864,6 +919,8 @@ func (p *Platform) handleGroupMessage(data json.RawMessage) {
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"`
@@ -896,13 +953,16 @@ func (p *Platform) handleGroupMessage(data json.RawMessage) {
// Strip leading @bot mention (the official API includes it as content prefix)
content := stripAtMention(d.Content)
content = prependQuotedMessage(p.resolveQuotedText(d.MessageReference), content)
// Download image attachments
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 {
if content == "" && len(images) == 0 && len(files) == 0 {
return
}
@@ -929,10 +989,11 @@ func (p *Platform) handleGroupMessage(data json.RawMessage) {
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))
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)
}
@@ -943,6 +1004,8 @@ func (p *Platform) handleC2CMessage(data json.RawMessage) {
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"`
@@ -974,13 +1037,18 @@ func (p *Platform) handleC2CMessage(data json.RawMessage) {
}
content := strings.TrimSpace(d.Content)
content = prependQuotedMessage(p.resolveQuotedText(d.MessageReference), content)
quotedText := p.resolveQuotedText(d.MessageReference)
if quotedText == "" && d.MessageType != nil && *d.MessageType == msgTypeQuote {
quotedText = quotedTextFromElements(d.MsgElements)
}
content = prependQuotedMessage(quotedText, content)
// Download image attachments
// 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 {
if content == "" && len(images) == 0 && len(files) == 0 {
return
}
@@ -1000,10 +1068,11 @@ func (p *Platform) handleC2CMessage(data json.RawMessage) {
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))
slog.Debug("qqbot: c2c message received", "user", d.Author.UserOpenID, "len", len(content), "images", len(images), "files", len(files))
p.handler(p, msg)
}
@@ -1193,7 +1262,12 @@ func downloadAttachmentImages(attachments []attachment) []core.ImageAttachment {
slog.Warn("qqbot: download image failed", "url", url, "error", err)
continue
}
data, err := io.ReadAll(resp.Body)
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)
@@ -1212,6 +1286,49 @@ func downloadAttachmentImages(attachments []attachment) []core.ImageAttachment {
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.
@@ -1374,6 +1491,20 @@ func quotedMessageText(msg *quotedMessage) string {
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)
+362
View File
@@ -2,6 +2,9 @@ package qqbot
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
@@ -225,3 +228,362 @@ func TestHandleC2CMessage_WithMessageReference(t *testing.T) {
// verify Platform implements core.Platform
var _ core.Platform = (*Platform)(nil)
func TestDownloadAttachmentImages_ChecksStatusCode(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
fmt.Fprint(w, "not found")
}))
defer server.Close()
origClient := core.HTTPClient
t.Cleanup(func() { core.HTTPClient = origClient })
core.HTTPClient = server.Client()
attachments := []attachment{
{ContentType: "image/png", URL: server.URL + "/image.png"},
}
images := downloadAttachmentImages(attachments)
if len(images) != 0 {
t.Fatalf("expected 0 images on non-200 status, got %d", len(images))
}
}
func TestDownloadAttachmentImages_Success(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/png")
w.Write([]byte("fake-png-data"))
}))
defer server.Close()
origClient := core.HTTPClient
t.Cleanup(func() { core.HTTPClient = origClient })
core.HTTPClient = server.Client()
attachments := []attachment{
{ContentType: "image/png", URL: server.URL + "/image.png", Filename: "test.png"},
}
images := downloadAttachmentImages(attachments)
if len(images) != 1 {
t.Fatalf("expected 1 image, got %d", len(images))
}
if string(images[0].Data) != "fake-png-data" {
t.Fatalf("image data = %q, want %q", string(images[0].Data), "fake-png-data")
}
if images[0].FileName != "test.png" {
t.Fatalf("filename = %q, want %q", images[0].FileName, "test.png")
}
}
func TestDownloadAttachmentFiles_ChecksStatusCode(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprint(w, "internal error")
}))
defer server.Close()
origClient := core.HTTPClient
t.Cleanup(func() { core.HTTPClient = origClient })
core.HTTPClient = server.Client()
attachments := []attachment{
{ContentType: "application/pdf", URL: server.URL + "/file.pdf"},
}
files := downloadAttachmentFiles(attachments)
if len(files) != 0 {
t.Fatalf("expected 0 files on non-200 status, got %d", len(files))
}
}
func TestDownloadAttachmentFiles_Success(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/pdf")
w.Write([]byte("fake-pdf-data"))
}))
defer server.Close()
origClient := core.HTTPClient
t.Cleanup(func() { core.HTTPClient = origClient })
core.HTTPClient = server.Client()
attachments := []attachment{
{ContentType: "application/pdf", URL: server.URL + "/file.pdf", Filename: "doc.pdf"},
}
files := downloadAttachmentFiles(attachments)
if len(files) != 1 {
t.Fatalf("expected 1 file, got %d", len(files))
}
if string(files[0].Data) != "fake-pdf-data" {
t.Fatalf("file data = %q, want %q", string(files[0].Data), "fake-pdf-data")
}
if files[0].FileName != "doc.pdf" {
t.Fatalf("filename = %q, want %q", files[0].FileName, "doc.pdf")
}
}
func TestDownloadAttachmentFiles_SkipsImages(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("data"))
}))
defer server.Close()
origClient := core.HTTPClient
t.Cleanup(func() { core.HTTPClient = origClient })
core.HTTPClient = server.Client()
attachments := []attachment{
{ContentType: "image/png", URL: server.URL + "/image.png"},
{ContentType: "application/pdf", URL: server.URL + "/file.pdf"},
}
// Verify that downloadAttachmentFiles skips image content types
files := downloadAttachmentFiles(attachments)
for _, f := range files {
if f.MimeType == "image/png" {
t.Fatal("expected no image files in downloadAttachmentFiles result")
}
}
}
func TestDownloadAttachmentFiles_SkipsEmptyURL(t *testing.T) {
attachments := []attachment{
{ContentType: "application/pdf", URL: ""},
}
files := downloadAttachmentFiles(attachments)
if len(files) != 0 {
t.Fatalf("expected 0 files for empty URL, got %d", len(files))
}
}
func TestUploadRichMedia_IncludesFileNameForFileType4(t *testing.T) {
var receivedBody map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Handle token request
if r.URL.Path == "/app/getAppAccessToken" {
fmt.Fprint(w, `{"access_token":"test-token","expires_in":"7200"}`)
return
}
// Handle file upload request
json.NewDecoder(r.Body).Decode(&receivedBody)
fmt.Fprint(w, `{"file_info":"test-file-info"}`)
}))
defer server.Close()
origClient := core.HTTPClient
t.Cleanup(func() { core.HTTPClient = origClient })
core.HTTPClient = server.Client()
origTokenURL := tokenURL
origApiBaseProduction := apiBaseProduction
tokenURL = server.URL + "/app/getAppAccessToken"
apiBaseProduction = server.URL
t.Cleanup(func() {
tokenURL = origTokenURL
apiBaseProduction = origApiBaseProduction
})
p := &Platform{
sandbox: false,
token: "test-token",
tokenExpiry: time.Now().Add(time.Hour),
}
rctx := &replyContext{
messageType: "c2c",
userOpenID: "user-123",
}
fileInfo, err := p.uploadRichMedia(rctx, 4, []byte("file-data"), "document.pdf")
if err != nil {
t.Fatalf("uploadRichMedia returned error: %v", err)
}
if fileInfo != "test-file-info" {
t.Fatalf("fileInfo = %q, want %q", fileInfo, "test-file-info")
}
if receivedBody["file_name"] != "document.pdf" {
t.Fatalf("file_name = %v, want %q", receivedBody["file_name"], "document.pdf")
}
if receivedBody["file_type"].(float64) != 4 {
t.Fatalf("file_type = %v, want 4", receivedBody["file_type"])
}
}
func TestUploadRichMedia_NoFileNameForOtherFileTypes(t *testing.T) {
var receivedBody map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Handle token request
if r.URL.Path == "/app/getAppAccessToken" {
fmt.Fprint(w, `{"access_token":"test-token","expires_in":"7200"}`)
return
}
// Handle file upload request
json.NewDecoder(r.Body).Decode(&receivedBody)
fmt.Fprint(w, `{"file_info":"test-file-info"}`)
}))
defer server.Close()
origClient := core.HTTPClient
t.Cleanup(func() { core.HTTPClient = origClient })
core.HTTPClient = server.Client()
origTokenURL := tokenURL
origApiBaseProduction := apiBaseProduction
tokenURL = server.URL + "/app/getAppAccessToken"
apiBaseProduction = server.URL
t.Cleanup(func() {
tokenURL = origTokenURL
apiBaseProduction = origApiBaseProduction
})
p := &Platform{
sandbox: false,
token: "test-token",
tokenExpiry: time.Now().Add(time.Hour),
}
rctx := &replyContext{
messageType: "c2c",
userOpenID: "user-123",
}
// fileType 1 (image) should NOT include file_name
fileInfo, err := p.uploadRichMedia(rctx, 1, []byte("image-data"), "")
if err != nil {
t.Fatalf("uploadRichMedia returned error: %v", err)
}
if fileInfo != "test-file-info" {
t.Fatalf("fileInfo = %q, want %q", fileInfo, "test-file-info")
}
if _, hasFileName := receivedBody["file_name"]; hasFileName {
t.Fatalf("expected no file_name for fileType 1, got %v", receivedBody["file_name"])
}
}
func TestQuotedTextFromElements(t *testing.T) {
tests := []struct {
name string
elements []msgElement
want string
}{
{
name: "empty elements",
elements: nil,
want: "",
},
{
name: "element with content",
elements: []msgElement{{Content: "被引用的消息"}},
want: "被引用的消息",
},
{
name: "element with whitespace content",
elements: []msgElement{{Content: " "}},
want: "",
},
{
name: "element with only attachments",
elements: []msgElement{
{Attachments: []attachment{{ContentType: "image/png", URL: "https://example.com/img.png"}}},
},
want: "[图片]",
},
{
name: "content takes priority over attachments",
elements: []msgElement{
{
Content: "有内容的消息",
Attachments: []attachment{{ContentType: "image/png", URL: "https://example.com/img.png"}},
},
},
want: "有内容的消息",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := quotedTextFromElements(tt.elements)
if got != tt.want {
t.Errorf("quotedTextFromElements() = %q, want %q", got, tt.want)
}
})
}
}
func TestHandleC2CMessage_QuoteFromMsgElements(t *testing.T) {
p := &Platform{
allowFrom: "*",
messageCache: map[string]cachedMessage{},
}
var got *core.Message
p.handler = func(_ core.Platform, msg *core.Message) {
got = msg
}
// Simulate a quote message (message_type=103) with msg_elements[0] containing the quoted content
msgType := 103
payload := map[string]any{
"id": "msg-new",
"content": "我的回复",
"timestamp": time.Now().Format(time.RFC3339),
"author": map[string]any{
"user_openid": "user-1",
},
"message_type": msgType,
"msg_elements": []map[string]any{
{"content": "这是被引用的消息内容"},
},
}
data, err := json.Marshal(payload)
if err != nil {
t.Fatal(err)
}
p.handleC2CMessage(data)
if got == nil {
t.Fatal("expected message")
}
want := "[引用消息]\n这是被引用的消息内容\n\n我的回复"
if got.Content != want {
t.Fatalf("content = %q, want %q", got.Content, want)
}
}
func TestHandleGroupMessage_QuoteFromMsgElements(t *testing.T) {
p := &Platform{
allowFrom: "*",
messageCache: map[string]cachedMessage{},
}
var got *core.Message
p.handler = func(_ core.Platform, msg *core.Message) {
got = msg
}
// Simulate a group quote message (message_type=103) with msg_elements[0]
msgType := 103
payload := map[string]any{
"id": "msg-new",
"group_openid": "group-1",
"content": "<@!bot123> 看看这个",
"timestamp": time.Now().Format(time.RFC3339),
"message_type": msgType,
"msg_elements": []map[string]any{
{"content": "之前的讨论内容"},
},
"author": map[string]any{
"member_openid": "user-1",
},
}
data, err := json.Marshal(payload)
if err != nil {
t.Fatal(err)
}
p.handleGroupMessage(data)
if got == nil {
t.Fatal("expected message")
}
want := "[引用消息]\n之前的讨论内容\n\n看看这个"
if got.Content != want {
t.Fatalf("content = %q, want %q", got.Content, want)
}
}