diff --git a/platform/qqbot/qqbot.go b/platform/qqbot/qqbot.go index 7e538af..54675d0 100644 --- a/platform/qqbot/qqbot.go +++ b/platform/qqbot/qqbot.go @@ -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) diff --git a/platform/qqbot/qqbot_test.go b/platform/qqbot/qqbot_test.go index 363d557..b2cca48 100644 --- a/platform/qqbot/qqbot_test.go +++ b/platform/qqbot/qqbot_test.go @@ -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) + } +}