Feat/qq local file upload (#1722)

* feat(qq): support media uploads and inbound attachments

* docs(qq): document media size limit settings

* chore(web): add QQ media size limit hints

* fix(qq): demote botgo heartbeat logs

* style(qq): fix lint issues
This commit is contained in:
美電球
2026-03-19 16:27:34 +08:00
committed by GitHub
parent a8ce992429
commit 828971d549
9 changed files with 915 additions and 133 deletions
+9 -7
View File
@@ -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 直传 |
## 设置流程
+41
View File
@@ -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")
}
+403 -113
View File
@@ -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
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
}
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
}
mediaURL = resolved
return fmt.Errorf("qq send media: %w", channels.ErrTemporary)
}
// 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)
}
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.
+444
View File
@@ -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
}
+9 -8
View File
@@ -349,14 +349,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 {
+6 -5
View File
@@ -81,11 +81,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,
@@ -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] ??
+1
View File
@@ -327,6 +327,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}}."
}
},
+1
View File
@@ -327,6 +327,7 @@
"realName": "显示名称。",
"channels": "要加入的 IRC 频道列表。",
"requestCaps": "连接时请求的 IRC 扩展能力列表。",
"maxBase64FileSizeMiB": "本地文件转为 base64 上传的最大体积,单位 MiB;0 表示不限制,仅影响本地文件,不影响 URL 直传。",
"genericField": "用于配置{{field}}。"
}
},