diff --git a/docs/channels/qq/README.zh.md b/docs/channels/qq/README.zh.md index bd774960f5..6211d2ec44 100644 --- a/docs/channels/qq/README.zh.md +++ b/docs/channels/qq/README.zh.md @@ -11,18 +11,20 @@ PicoClaw 通过 QQ 开放平台的官方机器人 API 提供对 QQ 的支持。 "enabled": true, "app_id": "YOUR_APP_ID", "app_secret": "YOUR_APP_SECRET", - "allow_from": [] + "allow_from": [], + "max_base64_file_size_mib": 0 } } } ``` -| 字段 | 类型 | 必填 | 描述 | -| ---------- | ------ | ---- | -------------------------------- | -| enabled | bool | 是 | 是否启用 QQ Channel | -| app_id | string | 是 | QQ 机器人应用的 App ID | -| app_secret | string | 是 | QQ 机器人应用的 App Secret | -| allow_from | array | 否 | 用户ID白名单,空表示允许所有用户 | +| 字段 | 类型 | 必填 | 描述 | +| -------------------- | ------ | ---- | ------------------------------------------------------------ | +| enabled | bool | 是 | 是否启用 QQ Channel | +| app_id | string | 是 | QQ 机器人应用的 App ID | +| app_secret | string | 是 | QQ 机器人应用的 App Secret | +| allow_from | array | 否 | 用户ID白名单,空表示允许所有用户 | +| max_base64_file_size_mib | int | 否 | 本地文件转 base64 上传的最大体积,单位 MiB;`0` 表示不限制。仅影响本地文件,不影响 URL 直传 | ## 设置流程 diff --git a/pkg/channels/qq/botgo_logger.go b/pkg/channels/qq/botgo_logger.go new file mode 100644 index 0000000000..e1d2462a35 --- /dev/null +++ b/pkg/channels/qq/botgo_logger.go @@ -0,0 +1,41 @@ +package qq + +import ( + "fmt" + "strings" + + "github.com/sipeed/picoclaw/pkg/logger" +) + +// botGoLogger preserves useful SDK info logs while demoting noisy heartbeat +// traffic to DEBUG so long-running QQ sessions do not spam the console. +type botGoLogger struct { + *logger.Logger +} + +func newBotGoLogger(component string) *botGoLogger { + return &botGoLogger{Logger: logger.NewLogger(component)} +} + +func (b *botGoLogger) Info(v ...any) { + message := fmt.Sprint(v...) + if shouldDemoteBotGoInfo(message) { + b.Logger.Debug(message) + return + } + b.Logger.Info(message) +} + +func (b *botGoLogger) Infof(format string, v ...any) { + message := fmt.Sprintf(format, v...) + if shouldDemoteBotGoInfo(message) { + b.Logger.Debug(message) + return + } + b.Logger.Info(message) +} + +func shouldDemoteBotGoInfo(message string) bool { + return strings.Contains(message, " write Heartbeat message") || + strings.Contains(message, " receive HeartbeatAck message") +} diff --git a/pkg/channels/qq/qq.go b/pkg/channels/qq/qq.go index 4cb4db3c62..1a48369f8f 100644 --- a/pkg/channels/qq/qq.go +++ b/pkg/channels/qq/qq.go @@ -2,7 +2,15 @@ package qq import ( "context" + "encoding/base64" + "encoding/json" + "errors" "fmt" + "net/http" + "net/url" + "os" + "path" + "path/filepath" "regexp" "strings" "sync" @@ -10,9 +18,10 @@ import ( "time" "github.com/tencent-connect/botgo" + "github.com/tencent-connect/botgo/constant" "github.com/tencent-connect/botgo/dto" "github.com/tencent-connect/botgo/event" - "github.com/tencent-connect/botgo/openapi" + "github.com/tencent-connect/botgo/openapi/options" "github.com/tencent-connect/botgo/token" "golang.org/x/oauth2" @@ -21,6 +30,8 @@ import ( "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/identity" "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/media" + "github.com/sipeed/picoclaw/pkg/utils" ) const ( @@ -29,16 +40,29 @@ const ( dedupMaxSize = 10000 // hard cap on dedup map entries typingResend = 8 * time.Second typingSeconds = 10 + bytesPerMiB = 1024 * 1024 ) +type qqAPI interface { + WS(ctx context.Context, params map[string]string, body string) (*dto.WebsocketAP, error) + PostGroupMessage( + ctx context.Context, groupID string, msg dto.APIMessage, opt ...options.Option, + ) (*dto.Message, error) + PostC2CMessage( + ctx context.Context, userID string, msg dto.APIMessage, opt ...options.Option, + ) (*dto.Message, error) + Transport(ctx context.Context, method, url string, body any) ([]byte, error) +} + type QQChannel struct { *channels.BaseChannel config config.QQConfig - api openapi.OpenAPI + api qqAPI tokenSource oauth2.TokenSource ctx context.Context cancel context.CancelFunc sessionManager botgo.SessionManager + downloadFn func(urlStr, filename string) string // Chat routing: track whether a chatID is group or direct. chatType sync.Map // chatID → "group" | "direct" @@ -78,7 +102,7 @@ func (c *QQChannel) Start(ctx context.Context) error { return fmt.Errorf("QQ app_id and app_secret not configured") } - botgo.SetLogger(logger.NewLogger("botgo")) + botgo.SetLogger(newBotGoLogger("botgo")) logger.InfoC("qq", "Starting QQ bot (WebSocket mode)") // Reinitialize shutdown signal for clean restart. @@ -199,20 +223,7 @@ func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { msgToCreate.Content = "" } - // Attach passive reply msg_id and msg_seq if available. - if v, ok := c.lastMsgID.Load(msg.ChatID); ok { - if msgID, ok := v.(string); ok && msgID != "" { - msgToCreate.MsgID = msgID - - // Increment msg_seq atomically for multi-part replies. - if counterVal, ok := c.msgSeqCounters.Load(msg.ChatID); ok { - if counter, ok := counterVal.(*atomic.Uint64); ok { - seq := counter.Add(1) - msgToCreate.MsgSeq = uint32(seq) - } - } - } - } + c.applyPassiveReplyMetadata(msg.ChatID, msgToCreate) // Sanitize URLs in group messages to avoid QQ's URL blacklist rejection. if chatKind == "group" { @@ -305,9 +316,9 @@ func (c *QQChannel) StartTyping(ctx context.Context, chatID string) (func(), err } // SendMedia implements the channels.MediaSender interface. -// QQ RichMediaMessage requires an HTTP/HTTPS URL — local file paths are not supported. -// If part.Ref is already an http(s) URL it is used directly; otherwise we try -// the media store, and skip with a warning if the resolved path is not an HTTP URL. +// QQ group/C2C media sending is a two-step flow: +// 1. Upload media to /files using a remote URL or base64-encoded local bytes. +// 2. Send a msg_type=7 message using the returned file_info. func (c *QQChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { if !c.IsRunning() { return channels.ErrNotRunning @@ -316,69 +327,24 @@ func (c *QQChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) chatKind := c.getChatKind(msg.ChatID) for _, part := range msg.Parts { - // If the ref is already an HTTP(S) URL, use it directly. - mediaURL := part.Ref - if !isHTTPURL(mediaURL) { - // Try resolving through media store. - store := c.GetMediaStore() - if store == nil { - logger.WarnCF("qq", "QQ media requires HTTP/HTTPS URL, no media store available", map[string]any{ - "ref": part.Ref, - }) - continue - } - - resolved, err := store.Resolve(part.Ref) - if err != nil { - logger.ErrorCF("qq", "Failed to resolve media ref", map[string]any{ - "ref": part.Ref, - "error": err.Error(), - }) - continue - } - - if !isHTTPURL(resolved) { - logger.WarnCF("qq", "QQ media requires HTTP/HTTPS URL, local files not supported", map[string]any{ - "ref": part.Ref, - "resolved": resolved, - }) - continue + fileInfo, err := c.uploadMedia(ctx, chatKind, msg.ChatID, part) + if err != nil { + logger.ErrorCF("qq", "Failed to upload media", map[string]any{ + "type": part.Type, + "chat_id": msg.ChatID, + "error": err.Error(), + }) + if errors.Is(err, channels.ErrSendFailed) { + return err } - - mediaURL = resolved - } - - // Map part type to QQ file type: 1=image, 2=video, 3=audio, 4=file. - var fileType uint64 - switch part.Type { - case "image": - fileType = 1 - case "video": - fileType = 2 - case "audio": - fileType = 3 - default: - fileType = 4 // file - } - - richMedia := &dto.RichMediaMessage{ - FileType: fileType, - URL: mediaURL, - SrvSendMsg: true, - } - - var sendErr error - if chatKind == "group" { - _, sendErr = c.api.PostGroupMessage(ctx, msg.ChatID, richMedia) - } else { - _, sendErr = c.api.PostC2CMessage(ctx, msg.ChatID, richMedia) + return fmt.Errorf("qq send media: %w", channels.ErrTemporary) } - if sendErr != nil { + if err := c.sendUploadedMedia(ctx, chatKind, msg.ChatID, part, fileInfo); err != nil { logger.ErrorCF("qq", "Failed to send media", map[string]any{ "type": part.Type, "chat_id": msg.ChatID, - "error": sendErr.Error(), + "error": err.Error(), }) return fmt.Errorf("qq send media: %w", channels.ErrTemporary) } @@ -387,6 +353,161 @@ func (c *QQChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) return nil } +type qqMediaUpload struct { + FileType uint64 `json:"file_type"` + URL string `json:"url,omitempty"` + FileData string `json:"file_data,omitempty"` + SrvSendMsg bool `json:"srv_send_msg,omitempty"` +} + +func (c *QQChannel) uploadMedia( + ctx context.Context, + chatKind, chatID string, + part bus.MediaPart, +) ([]byte, error) { + payload, err := c.buildMediaUpload(part) + if err != nil { + return nil, err + } + + body, err := c.api.Transport(ctx, http.MethodPost, c.mediaUploadURL(chatKind, chatID), payload) + if err != nil { + return nil, err + } + + var uploaded dto.Message + if err := json.Unmarshal(body, &uploaded); err != nil { + return nil, fmt.Errorf("qq decode media upload response: %w", err) + } + if len(uploaded.FileInfo) == 0 { + return nil, fmt.Errorf("qq upload media: missing file_info") + } + + return uploaded.FileInfo, nil +} + +func (c *QQChannel) buildMediaUpload(part bus.MediaPart) (*qqMediaUpload, error) { + payload := &qqMediaUpload{ + FileType: qqFileType(part.Type), + } + + mediaRef := part.Ref + if isHTTPURL(mediaRef) { + payload.URL = mediaRef + return payload, nil + } + + store := c.GetMediaStore() + if store == nil { + return nil, fmt.Errorf("no media store available: %w", channels.ErrSendFailed) + } + + resolved, err := store.Resolve(part.Ref) + if err != nil { + return nil, fmt.Errorf("qq resolve media ref %q: %v: %w", part.Ref, err, channels.ErrSendFailed) + } + + if isHTTPURL(resolved) { + payload.URL = resolved + return payload, nil + } + + if limitBytes := c.maxBase64FileSizeBytes(); limitBytes > 0 { + info, statErr := os.Stat(resolved) + if statErr != nil { + return nil, fmt.Errorf("qq stat local media %q: %v: %w", resolved, statErr, channels.ErrSendFailed) + } + if info.Size() > limitBytes { + return nil, fmt.Errorf( + "qq local media %q exceeds max_base64_file_size_mib (%d > %d bytes): %w", + resolved, + info.Size(), + limitBytes, + channels.ErrSendFailed, + ) + } + } + + data, err := os.ReadFile(resolved) + if err != nil { + return nil, fmt.Errorf("qq read local media %q: %v: %w", resolved, err, channels.ErrSendFailed) + } + + payload.FileData = base64.StdEncoding.EncodeToString(data) + return payload, nil +} + +func (c *QQChannel) sendUploadedMedia( + ctx context.Context, + chatKind, chatID string, + part bus.MediaPart, + fileInfo []byte, +) error { + msg := &dto.MessageToCreate{ + Content: part.Caption, + MsgType: dto.RichMediaMsg, + Media: &dto.MediaInfo{ + FileInfo: fileInfo, + }, + } + c.applyPassiveReplyMetadata(chatID, msg) + + if chatKind == "group" && msg.Content != "" { + msg.Content = sanitizeURLs(msg.Content) + } + + if chatKind == "group" { + _, err := c.api.PostGroupMessage(ctx, chatID, msg) + return err + } + _, err := c.api.PostC2CMessage(ctx, chatID, msg) + return err +} + +func (c *QQChannel) applyPassiveReplyMetadata(chatID string, msg *dto.MessageToCreate) { + if v, ok := c.lastMsgID.Load(chatID); ok { + if msgID, ok := v.(string); ok && msgID != "" { + msg.MsgID = msgID + + // Increment msg_seq atomically for multi-part replies. + if counterVal, ok := c.msgSeqCounters.Load(chatID); ok { + if counter, ok := counterVal.(*atomic.Uint64); ok { + seq := counter.Add(1) + msg.MsgSeq = uint32(seq) + } + } + } + } +} + +func (c *QQChannel) mediaUploadURL(chatKind, chatID string) string { + base := constant.APIDomain + if chatKind == "group" { + return fmt.Sprintf("%s/v2/groups/%s/files", base, chatID) + } + return fmt.Sprintf("%s/v2/users/%s/files", base, chatID) +} + +func qqFileType(partType string) uint64 { + switch partType { + case "image": + return 1 + case "video": + return 2 + case "audio": + return 3 + default: + return 4 + } +} + +func (c *QQChannel) maxBase64FileSizeBytes() int64 { + if c.config.MaxBase64FileSizeMiB <= 0 { + return 0 + } + return c.config.MaxBase64FileSizeMiB * bytesPerMiB +} + // handleC2CMessage handles QQ private messages. func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler { return func(event *dto.WSPayload, data *dto.WSC2CMessageData) error { @@ -404,16 +525,30 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler { return nil } - // extract message content - content := data.Content - if content == "" { - logger.DebugC("qq", "Received empty message, ignoring") + sender := bus.SenderInfo{ + Platform: "qq", + PlatformID: data.Author.ID, + CanonicalID: identity.BuildCanonicalID("qq", data.Author.ID), + } + + if !c.IsAllowedSender(sender) { + return nil + } + + content := strings.TrimSpace(data.Content) + mediaPaths, attachmentNotes := c.extractInboundAttachments(senderID, data.ID, data.Attachments) + for _, note := range attachmentNotes { + content = appendContent(content, note) + } + if content == "" && len(mediaPaths) == 0 { + logger.DebugC("qq", "Received empty C2C message with no attachments, ignoring") return nil } logger.InfoCF("qq", "Received C2C message", map[string]any{ - "sender": senderID, - "length": len(content), + "sender": senderID, + "length": len(content), + "media_count": len(mediaPaths), }) // Store chat routing context. @@ -427,23 +562,13 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler { "account_id": senderID, } - sender := bus.SenderInfo{ - Platform: "qq", - PlatformID: data.Author.ID, - CanonicalID: identity.BuildCanonicalID("qq", data.Author.ID), - } - - if !c.IsAllowedSender(sender) { - return nil - } - c.HandleMessage(c.ctx, bus.Peer{Kind: "direct", ID: senderID}, data.ID, senderID, senderID, content, - []string{}, + mediaPaths, metadata, sender, ) @@ -469,24 +594,38 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler { return nil } - // extract message content (remove @ bot part) - content := data.Content - if content == "" { - logger.DebugC("qq", "Received empty group message, ignoring") + sender := bus.SenderInfo{ + Platform: "qq", + PlatformID: data.Author.ID, + CanonicalID: identity.BuildCanonicalID("qq", data.Author.ID), + } + + if !c.IsAllowedSender(sender) { return nil } - // GroupAT event means bot is always mentioned; apply group trigger filtering + content := strings.TrimSpace(data.Content) + mediaPaths, attachmentNotes := c.extractInboundAttachments(data.GroupID, data.ID, data.Attachments) + for _, note := range attachmentNotes { + content = appendContent(content, note) + } + + // GroupAT event means bot is always mentioned; apply group trigger filtering. respond, cleaned := c.ShouldRespondInGroup(true, content) if !respond { return nil } content = cleaned + if content == "" && len(mediaPaths) == 0 { + logger.DebugC("qq", "Received empty group message with no attachments, ignoring") + return nil + } logger.InfoCF("qq", "Received group AT message", map[string]any{ - "sender": senderID, - "group": data.GroupID, - "length": len(content), + "sender": senderID, + "group": data.GroupID, + "length": len(content), + "media_count": len(mediaPaths), }) // Store chat routing context using GroupID as chatID. @@ -501,23 +640,13 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler { "group_id": data.GroupID, } - sender := bus.SenderInfo{ - Platform: "qq", - PlatformID: data.Author.ID, - CanonicalID: identity.BuildCanonicalID("qq", data.Author.ID), - } - - if !c.IsAllowedSender(sender) { - return nil - } - c.HandleMessage(c.ctx, bus.Peer{Kind: "group", ID: data.GroupID}, data.ID, senderID, data.GroupID, content, - []string{}, + mediaPaths, metadata, sender, ) @@ -526,6 +655,157 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler { } } +func (c *QQChannel) extractInboundAttachments( + chatID, messageID string, + attachments []*dto.MessageAttachment, +) ([]string, []string) { + if len(attachments) == 0 { + return nil, nil + } + + scope := channels.BuildMediaScope("qq", chatID, messageID) + mediaPaths := make([]string, 0, len(attachments)) + notes := make([]string, 0, len(attachments)) + + storeMedia := func(localPath string, attachment *dto.MessageAttachment) string { + if store := c.GetMediaStore(); store != nil { + ref, err := store.Store(localPath, media.MediaMeta{ + Filename: qqAttachmentFilename(attachment), + ContentType: attachment.ContentType, + Source: "qq", + }, scope) + if err == nil { + return ref + } + } + return localPath + } + + for _, attachment := range attachments { + if attachment == nil { + continue + } + + filename := qqAttachmentFilename(attachment) + if localPath := c.downloadAttachment(attachment.URL, filename); localPath != "" { + mediaPaths = append(mediaPaths, storeMedia(localPath, attachment)) + } else if attachment.URL != "" { + mediaPaths = append(mediaPaths, attachment.URL) + } + + notes = append(notes, qqAttachmentNote(attachment)) + } + + return mediaPaths, notes +} + +func (c *QQChannel) downloadAttachment(urlStr, filename string) string { + if urlStr == "" { + return "" + } + if c.downloadFn != nil { + return c.downloadFn(urlStr, filename) + } + + return utils.DownloadFile(urlStr, filename, utils.DownloadOptions{ + LoggerPrefix: "qq", + ExtraHeaders: c.downloadHeaders(), + }) +} + +func (c *QQChannel) downloadHeaders() map[string]string { + headers := map[string]string{} + + if c.config.AppID != "" { + headers["X-Union-Appid"] = c.config.AppID + } + + if c.tokenSource != nil { + if tk, err := c.tokenSource.Token(); err == nil && tk.AccessToken != "" { + auth := strings.TrimSpace(tk.TokenType + " " + tk.AccessToken) + if auth != "" { + headers["Authorization"] = auth + } + } + } + + if len(headers) == 0 { + return nil + } + return headers +} + +func qqAttachmentFilename(attachment *dto.MessageAttachment) string { + if attachment == nil { + return "attachment" + } + if attachment.FileName != "" { + return attachment.FileName + } + if attachment.URL != "" { + if parsed, err := url.Parse(attachment.URL); err == nil { + if base := path.Base(parsed.Path); base != "" && base != "." && base != "/" { + return base + } + } + } + + switch qqAttachmentKind(attachment) { + case "image": + return "image" + case "audio": + return "audio" + case "video": + return "video" + default: + return "attachment" + } +} + +func qqAttachmentKind(attachment *dto.MessageAttachment) string { + if attachment == nil { + return "file" + } + + contentType := strings.ToLower(attachment.ContentType) + filename := strings.ToLower(attachment.FileName) + + switch { + case strings.HasPrefix(contentType, "image/"): + return "image" + case strings.HasPrefix(contentType, "video/"): + return "video" + case strings.HasPrefix(contentType, "audio/"), contentType == "application/ogg", contentType == "application/x-ogg": + return "audio" + } + + switch filepath.Ext(filename) { + case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg": + return "image" + case ".mp4", ".avi", ".mov", ".webm", ".mkv": + return "video" + case ".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac", ".wma", ".opus", ".silk": + return "audio" + default: + return "file" + } +} + +func qqAttachmentNote(attachment *dto.MessageAttachment) string { + filename := qqAttachmentFilename(attachment) + + switch qqAttachmentKind(attachment) { + case "image": + return fmt.Sprintf("[image: %s]", filename) + case "audio": + return fmt.Sprintf("[audio: %s]", filename) + case "video": + return fmt.Sprintf("[video: %s]", filename) + default: + return fmt.Sprintf("[file: %s]", filename) + } +} + // isDuplicate checks whether a message has been seen within the TTL window. // It also enforces a hard cap on map size by evicting oldest entries. func (c *QQChannel) isDuplicate(messageID string) bool { @@ -587,6 +867,16 @@ func isHTTPURL(s string) bool { return strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://") } +func appendContent(content, suffix string) string { + if suffix == "" { + return content + } + if content == "" { + return suffix + } + return content + "\n" + suffix +} + // urlPattern matches URLs with explicit http(s):// scheme. // Only scheme-prefixed URLs are matched to avoid false positives on bare text // like version numbers (e.g., "1.2.3") or domain-like fragments. diff --git a/pkg/channels/qq/qq_test.go b/pkg/channels/qq/qq_test.go index b04cf5abd1..3cb3d39bd3 100644 --- a/pkg/channels/qq/qq_test.go +++ b/pkg/channels/qq/qq_test.go @@ -2,13 +2,22 @@ package qq import ( "context" + "encoding/base64" + "encoding/json" + "errors" + "os" + "strings" + "sync/atomic" "testing" "time" "github.com/tencent-connect/botgo/dto" + "github.com/tencent-connect/botgo/openapi/options" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/media" ) func TestHandleC2CMessage_IncludesAccountIDMetadata(t *testing.T) { @@ -50,3 +59,438 @@ func TestHandleC2CMessage_IncludesAccountIDMetadata(t *testing.T) { } } } + +func TestHandleC2CMessage_AttachmentOnlyPublishesMedia(t *testing.T) { + messageBus := bus.NewMessageBus() + store := media.NewFileMediaStore() + localPath := writeTempFile(t, t.TempDir(), "image.png", []byte("fake-image")) + + ch := &QQChannel{ + BaseChannel: channels.NewBaseChannel("qq", nil, messageBus, nil), + dedup: make(map[string]time.Time), + done: make(chan struct{}), + ctx: context.Background(), + downloadFn: func(urlStr, filename string) string { + if filename != "image.png" { + t.Fatalf("download filename = %q, want image.png", filename) + } + return localPath + }, + } + ch.SetMediaStore(store) + + err := ch.handleC2CMessage()(nil, &dto.WSC2CMessageData{ + ID: "msg-attachment", + Content: "", + Author: &dto.User{ + ID: "7750283E123456", + }, + Attachments: []*dto.MessageAttachment{{ + URL: "https://example.com/image.png", + FileName: "image.png", + ContentType: "image/png", + }}, + }) + if err != nil { + t.Fatalf("handleC2CMessage() error = %v", err) + } + + inbound := waitInboundMessage(t, messageBus) + if inbound.Content != "[image: image.png]" { + t.Fatalf("inbound.Content = %q", inbound.Content) + } + if len(inbound.Media) != 1 { + t.Fatalf("len(inbound.Media) = %d, want 1", len(inbound.Media)) + } + if !strings.HasPrefix(inbound.Media[0], "media://") { + t.Fatalf("inbound.Media[0] = %q, want media:// ref", inbound.Media[0]) + } + _, meta, err := store.ResolveWithMeta(inbound.Media[0]) + if err != nil { + t.Fatalf("ResolveWithMeta() error = %v", err) + } + if meta.Filename != "image.png" { + t.Fatalf("meta.Filename = %q, want image.png", meta.Filename) + } + if meta.ContentType != "image/png" { + t.Fatalf("meta.ContentType = %q, want image/png", meta.ContentType) + } +} + +func TestHandleGroupATMessage_AttachmentOnlyPublishesMedia(t *testing.T) { + messageBus := bus.NewMessageBus() + store := media.NewFileMediaStore() + localPath := writeTempFile(t, t.TempDir(), "report.pdf", []byte("fake-pdf")) + + ch := &QQChannel{ + BaseChannel: channels.NewBaseChannel("qq", nil, messageBus, nil), + dedup: make(map[string]time.Time), + done: make(chan struct{}), + ctx: context.Background(), + downloadFn: func(urlStr, filename string) string { + if filename != "report.pdf" { + t.Fatalf("download filename = %q, want report.pdf", filename) + } + return localPath + }, + } + ch.SetMediaStore(store) + + err := ch.handleGroupATMessage()(nil, &dto.WSGroupATMessageData{ + ID: "group-attachment", + GroupID: "group-1", + Content: "", + Author: &dto.User{ + ID: "7750283E123456", + }, + Attachments: []*dto.MessageAttachment{{ + URL: "https://example.com/report.pdf", + FileName: "report.pdf", + ContentType: "application/pdf", + }}, + }) + if err != nil { + t.Fatalf("handleGroupATMessage() error = %v", err) + } + + inbound := waitInboundMessage(t, messageBus) + if inbound.Content != "[file: report.pdf]" { + t.Fatalf("inbound.Content = %q", inbound.Content) + } + if len(inbound.Media) != 1 { + t.Fatalf("len(inbound.Media) = %d, want 1", len(inbound.Media)) + } + if !strings.HasPrefix(inbound.Media[0], "media://") { + t.Fatalf("inbound.Media[0] = %q, want media:// ref", inbound.Media[0]) + } + if inbound.Peer.Kind != "group" || inbound.Peer.ID != "group-1" { + t.Fatalf("inbound.Peer = %+v, want group/group-1", inbound.Peer) + } +} + +func TestSendMedia_UploadsLocalFileAsBase64(t *testing.T) { + messageBus := bus.NewMessageBus() + store := media.NewFileMediaStore() + + tmpFile, err := os.CreateTemp(t.TempDir(), "qq-media-*.png") + if err != nil { + t.Fatalf("CreateTemp() error = %v", err) + } + defer tmpFile.Close() + + content := []byte("local-image-data") + if _, writeErr := tmpFile.Write(content); writeErr != nil { + t.Fatalf("Write() error = %v", writeErr) + } + + ref, err := store.Store(tmpFile.Name(), media.MediaMeta{ + Filename: "reply.png", + ContentType: "image/png", + }, "qq:test") + if err != nil { + t.Fatalf("Store() error = %v", err) + } + + api := &fakeQQAPI{ + transportResp: mustJSON(t, dto.Message{FileInfo: []byte("uploaded-file-info")}), + } + ch := &QQChannel{ + BaseChannel: channels.NewBaseChannel("qq", nil, messageBus, nil), + api: api, + dedup: make(map[string]time.Time), + done: make(chan struct{}), + ctx: context.Background(), + } + ch.SetRunning(true) + ch.SetMediaStore(store) + ch.chatType.Store("group-1", "group") + ch.lastMsgID.Store("group-1", "msg-1") + ch.msgSeqCounters.Store("group-1", new(atomic.Uint64)) + + err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ + ChatID: "group-1", + Parts: []bus.MediaPart{{ + Type: "image", + Ref: ref, + Caption: "see https://example.com/image", + }}, + }) + if err != nil { + t.Fatalf("SendMedia() error = %v", err) + } + + if len(api.transportCalls) != 1 { + t.Fatalf("transportCalls = %d, want 1", len(api.transportCalls)) + } + upload := api.transportCalls[0] + if upload.method != "POST" { + t.Fatalf("upload method = %q, want POST", upload.method) + } + if upload.url != "https://api.sgroup.qq.com/v2/groups/group-1/files" { + t.Fatalf("upload url = %q", upload.url) + } + if upload.body.URL != "" { + t.Fatalf("upload URL = %q, want empty", upload.body.URL) + } + wantBase64 := base64.StdEncoding.EncodeToString(content) + if upload.body.FileData != wantBase64 { + t.Fatalf("upload file_data = %q, want %q", upload.body.FileData, wantBase64) + } + if upload.body.FileType != 1 { + t.Fatalf("upload file_type = %d, want 1", upload.body.FileType) + } + + if len(api.groupMessages) != 1 { + t.Fatalf("groupMessages = %d, want 1", len(api.groupMessages)) + } + msg, ok := api.groupMessages[0].(*dto.MessageToCreate) + if !ok { + t.Fatalf("groupMessages[0] type = %T, want *dto.MessageToCreate", api.groupMessages[0]) + } + if msg.MsgType != dto.RichMediaMsg { + t.Fatalf("msg.MsgType = %d, want %d", msg.MsgType, dto.RichMediaMsg) + } + if msg.MsgID != "msg-1" { + t.Fatalf("msg.MsgID = %q, want msg-1", msg.MsgID) + } + if msg.MsgSeq != 1 { + t.Fatalf("msg.MsgSeq = %d, want 1", msg.MsgSeq) + } + if msg.Content != "see https://example。com/image" { + t.Fatalf("msg.Content = %q", msg.Content) + } + if msg.Media == nil || string(msg.Media.FileInfo) != "uploaded-file-info" { + t.Fatalf("msg.Media.FileInfo = %q, want uploaded-file-info", string(msg.Media.FileInfo)) + } +} + +func TestSendMedia_UsesRemoteURLUploadForC2C(t *testing.T) { + messageBus := bus.NewMessageBus() + api := &fakeQQAPI{ + transportResp: mustJSON(t, dto.Message{FileInfo: []byte("remote-file-info")}), + } + ch := &QQChannel{ + BaseChannel: channels.NewBaseChannel("qq", nil, messageBus, nil), + api: api, + dedup: make(map[string]time.Time), + done: make(chan struct{}), + ctx: context.Background(), + } + ch.SetRunning(true) + ch.chatType.Store("user-1", "direct") + + err := ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ + ChatID: "user-1", + Parts: []bus.MediaPart{{ + Type: "file", + Ref: "https://cdn.example.com/report.pdf", + }}, + }) + if err != nil { + t.Fatalf("SendMedia() error = %v", err) + } + + if len(api.transportCalls) != 1 { + t.Fatalf("transportCalls = %d, want 1", len(api.transportCalls)) + } + upload := api.transportCalls[0] + if upload.url != "https://api.sgroup.qq.com/v2/users/user-1/files" { + t.Fatalf("upload url = %q", upload.url) + } + if upload.body.URL != "https://cdn.example.com/report.pdf" { + t.Fatalf("upload URL = %q", upload.body.URL) + } + if upload.body.FileData != "" { + t.Fatalf("upload file_data = %q, want empty", upload.body.FileData) + } + if upload.body.FileType != 4 { + t.Fatalf("upload file_type = %d, want 4", upload.body.FileType) + } + + if len(api.c2cMessages) != 1 { + t.Fatalf("c2cMessages = %d, want 1", len(api.c2cMessages)) + } + msg, ok := api.c2cMessages[0].(*dto.MessageToCreate) + if !ok { + t.Fatalf("c2cMessages[0] type = %T, want *dto.MessageToCreate", api.c2cMessages[0]) + } + if msg.MsgType != dto.RichMediaMsg { + t.Fatalf("msg.MsgType = %d, want %d", msg.MsgType, dto.RichMediaMsg) + } + if msg.Media == nil || string(msg.Media.FileInfo) != "remote-file-info" { + t.Fatalf("msg.Media.FileInfo = %q, want remote-file-info", string(msg.Media.FileInfo)) + } +} + +func TestSendMedia_ReturnsSendFailedWithoutMediaStore(t *testing.T) { + messageBus := bus.NewMessageBus() + ch := &QQChannel{ + BaseChannel: channels.NewBaseChannel("qq", nil, messageBus, nil), + api: &fakeQQAPI{}, + dedup: make(map[string]time.Time), + done: make(chan struct{}), + ctx: context.Background(), + } + ch.SetRunning(true) + ch.chatType.Store("group-1", "group") + + err := ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ + ChatID: "group-1", + Parts: []bus.MediaPart{{ + Type: "image", + Ref: "media://missing", + }}, + }) + if !errors.Is(err, channels.ErrSendFailed) { + t.Fatalf("SendMedia() error = %v, want ErrSendFailed", err) + } +} + +func TestSendMedia_ReturnsSendFailedWhenLocalFileExceedsBase64MiBLimit(t *testing.T) { + messageBus := bus.NewMessageBus() + store := media.NewFileMediaStore() + + tmpFile, err := os.CreateTemp(t.TempDir(), "qq-media-too-large-*.bin") + if err != nil { + t.Fatalf("CreateTemp() error = %v", err) + } + defer tmpFile.Close() + + content := make([]byte, bytesPerMiB+1) + if _, writeErr := tmpFile.Write(content); writeErr != nil { + t.Fatalf("Write() error = %v", writeErr) + } + + ref, err := store.Store(tmpFile.Name(), media.MediaMeta{ + Filename: "large.bin", + ContentType: "application/octet-stream", + }, "qq:test") + if err != nil { + t.Fatalf("Store() error = %v", err) + } + + api := &fakeQQAPI{} + ch := &QQChannel{ + BaseChannel: channels.NewBaseChannel("qq", nil, messageBus, nil), + config: config.QQConfig{ + MaxBase64FileSizeMiB: 1, + }, + api: api, + dedup: make(map[string]time.Time), + done: make(chan struct{}), + ctx: context.Background(), + } + ch.SetRunning(true) + ch.SetMediaStore(store) + ch.chatType.Store("group-1", "group") + + err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ + ChatID: "group-1", + Parts: []bus.MediaPart{{ + Type: "file", + Ref: ref, + }}, + }) + if !errors.Is(err, channels.ErrSendFailed) { + t.Fatalf("SendMedia() error = %v, want ErrSendFailed", err) + } + if len(api.transportCalls) != 0 { + t.Fatalf("transportCalls = %d, want 0", len(api.transportCalls)) + } +} + +type fakeQQAPI struct { + transportResp []byte + transportErr error + groupErr error + c2cErr error + transportCalls []fakeTransportCall + groupMessages []dto.APIMessage + c2cMessages []dto.APIMessage +} + +type fakeTransportCall struct { + method string + url string + body qqMediaUpload +} + +func (f *fakeQQAPI) WS( + context.Context, + map[string]string, + string, +) (*dto.WebsocketAP, error) { + return nil, nil +} + +func (f *fakeQQAPI) PostGroupMessage( + _ context.Context, + _ string, + msg dto.APIMessage, + _ ...options.Option, +) (*dto.Message, error) { + f.groupMessages = append(f.groupMessages, msg) + return &dto.Message{}, f.groupErr +} + +func (f *fakeQQAPI) PostC2CMessage( + _ context.Context, + _ string, + msg dto.APIMessage, + _ ...options.Option, +) (*dto.Message, error) { + f.c2cMessages = append(f.c2cMessages, msg) + return &dto.Message{}, f.c2cErr +} + +func (f *fakeQQAPI) Transport(_ context.Context, method, url string, body any) ([]byte, error) { + upload, ok := body.(*qqMediaUpload) + if !ok { + return nil, errors.New("unexpected transport body type") + } + f.transportCalls = append(f.transportCalls, fakeTransportCall{ + method: method, + url: url, + body: *upload, + }) + return f.transportResp, f.transportErr +} + +func mustJSON(t *testing.T, v any) []byte { + t.Helper() + + b, err := json.Marshal(v) + if err != nil { + t.Fatalf("json.Marshal() error = %v", err) + } + return b +} + +func waitInboundMessage(t *testing.T, messageBus *bus.MessageBus) bus.InboundMessage { + t.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + for { + select { + case <-ctx.Done(): + t.Fatal("timeout waiting for inbound message") + case inbound, ok := <-messageBus.InboundChan(): + if !ok { + t.Fatal("expected inbound message") + } + return inbound + } + } +} + +func writeTempFile(t *testing.T, dir, name string, content []byte) string { + t.Helper() + + path := dir + "/" + name + if err := os.WriteFile(path, content, 0o600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + return path +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 7a47fccae3..d11f20a08e 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -347,14 +347,15 @@ type MaixCamConfig struct { } type QQConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_QQ_ENABLED"` - AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_QQ_APP_ID"` - AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_QQ_APP_SECRET"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_QQ_ALLOW_FROM"` - GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` - MaxMessageLength int `json:"max_message_length" env:"PICOCLAW_CHANNELS_QQ_MAX_MESSAGE_LENGTH"` - SendMarkdown bool `json:"send_markdown" env:"PICOCLAW_CHANNELS_QQ_SEND_MARKDOWN"` - ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_QQ_REASONING_CHANNEL_ID"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_QQ_ENABLED"` + AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_QQ_APP_ID"` + AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_QQ_APP_SECRET"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_QQ_ALLOW_FROM"` + GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` + MaxMessageLength int `json:"max_message_length" env:"PICOCLAW_CHANNELS_QQ_MAX_MESSAGE_LENGTH"` + MaxBase64FileSizeMiB int64 `json:"max_base64_file_size_mib" env:"PICOCLAW_CHANNELS_QQ_MAX_BASE64_FILE_SIZE_MIB"` + SendMarkdown bool `json:"send_markdown" env:"PICOCLAW_CHANNELS_QQ_SEND_MARKDOWN"` + ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_QQ_REASONING_CHANNEL_ID"` } type DingTalkConfig struct { diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index eebb1dce3a..75ed0eb245 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -80,11 +80,12 @@ func DefaultConfig() *Config { AllowFrom: FlexibleStringSlice{}, }, QQ: QQConfig{ - Enabled: false, - AppID: "", - AppSecret: "", - AllowFrom: FlexibleStringSlice{}, - MaxMessageLength: 2000, + Enabled: false, + AppID: "", + AppSecret: "", + AllowFrom: FlexibleStringSlice{}, + MaxMessageLength: 2000, + MaxBase64FileSizeMiB: 0, }, DingTalk: DingTalkConfig{ Enabled: false, diff --git a/web/frontend/src/components/channels/channel-forms/generic-form.tsx b/web/frontend/src/components/channels/channel-forms/generic-form.tsx index fc5a0a7fde..db14fc2063 100644 --- a/web/frontend/src/components/channels/channel-forms/generic-form.tsx +++ b/web/frontend/src/components/channels/channel-forms/generic-form.tsx @@ -138,6 +138,7 @@ export function GenericForm({ real_name: t("channels.form.desc.realName"), channels: t("channels.form.desc.channels"), request_caps: t("channels.form.desc.requestCaps"), + max_base64_file_size_mib: t("channels.form.desc.maxBase64FileSizeMiB"), } return ( descriptions[key] ?? diff --git a/web/frontend/src/i18n/locales/en.json b/web/frontend/src/i18n/locales/en.json index 0b9d8c614e..c846199670 100644 --- a/web/frontend/src/i18n/locales/en.json +++ b/web/frontend/src/i18n/locales/en.json @@ -325,6 +325,7 @@ "realName": "Displayed real name.", "channels": "IRC channels to join.", "requestCaps": "IRC capability list requested on connect.", + "maxBase64FileSizeMiB": "Maximum size in MiB for converting local files to base64 before upload. 0 means unlimited. Applies only to local files, not URL uploads.", "genericField": "Used to configure {{field}}." } }, diff --git a/web/frontend/src/i18n/locales/zh.json b/web/frontend/src/i18n/locales/zh.json index c0aa158a21..29b9a3efb5 100644 --- a/web/frontend/src/i18n/locales/zh.json +++ b/web/frontend/src/i18n/locales/zh.json @@ -325,6 +325,7 @@ "realName": "显示名称。", "channels": "要加入的 IRC 频道列表。", "requestCaps": "连接时请求的 IRC 扩展能力列表。", + "maxBase64FileSizeMiB": "本地文件转为 base64 上传的最大体积,单位 MiB;0 表示不限制,仅影响本地文件,不影响 URL 直传。", "genericField": "用于配置{{field}}。" } },