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:
1 parent
04b7a483ac
commit
cf9798c9d7
2 files changed
+509
-16
No files matched your search
+147
-16
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user