From 7fa2766aea5d013f3f96a2352df56969d661ecc2 Mon Sep 17 00:00:00 2001 From: aishannon Date: Wed, 25 Mar 2026 00:10:22 +0800 Subject: [PATCH] =?UTF-8?q?feat(qq):=20=E6=94=AF=E6=8C=81=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E5=A4=84=E7=90=86=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pkg/channels/qq/qq.go | 492 +----------------------------------------- 1 file changed, 11 insertions(+), 481 deletions(-) diff --git a/pkg/channels/qq/qq.go b/pkg/channels/qq/qq.go index 8254a40e7..4d8cd7331 100644 --- a/pkg/channels/qq/qq.go +++ b/pkg/channels/qq/qq.go @@ -4,12 +4,6 @@ import ( "context" "encoding/base64" "encoding/json" - "errors" - "fmt" - "net/http" - "net/url" - "os" - "path" "fmt" "math" "os" @@ -25,10 +19,9 @@ import ( "github.com/tidwall/gjson" "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/options" + "github.com/tencent-connect/botgo/openapi" "github.com/tencent-connect/botgo/token" "golang.org/x/oauth2" @@ -37,8 +30,6 @@ 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 ( @@ -47,7 +38,6 @@ const ( dedupMaxSize = 10000 // hard cap on dedup map entries typingResend = 8 * time.Second typingSeconds = 10 - bytesPerMiB = 1024 * 1024 ) type kindType string @@ -67,12 +57,11 @@ var extRegexp = regexp.MustCompile(`ext="([^"]+)"`) type QQChannel struct { *channels.BaseChannel config config.QQConfig - api qqAPI + api openapi.OpenAPI 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 // kindType → "group" | "direct" @@ -110,11 +99,11 @@ func NewQQChannel(cfg config.QQConfig, messageBus *bus.MessageBus) (*QQChannel, } func (c *QQChannel) Start(ctx context.Context) error { - if c.config.AppID == "" || c.config.AppSecret() == "" { + if c.config.AppID == "" || c.config.AppSecret == "" { return fmt.Errorf("QQ app_id and app_secret not configured") } - botgo.SetLogger(newBotGoLogger("botgo")) + botgo.SetLogger(logger.NewLogger("botgo")) logger.InfoC("qq", "Starting QQ bot (WebSocket mode)") // Reinitialize shutdown signal for clean restart. @@ -124,7 +113,7 @@ func (c *QQChannel) Start(ctx context.Context) error { // create token source credentials := &token.QQBotCredentials{ AppID: c.config.AppID, - AppSecret: c.config.AppSecret(), + AppSecret: c.config.AppSecret, } c.tokenSource = token.NewQQBotTokenSource(credentials) @@ -229,21 +218,7 @@ func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { return channels.ErrNotRunning } - - - c.applyPassiveReplyMetadata(msg.ChatID, msgToCreate) - - // Sanitize URLs in group messages to avoid QQ's URL blacklist rejection. - if chatKind == "group" { - if msgToCreate.Content != "" { - msgToCreate.Content = sanitizeURLs(msgToCreate.Content) - } - if msgToCreate.Markdown != nil && msgToCreate.Markdown.Content != "" { - msgToCreate.Markdown.Content = sanitizeURLs(msgToCreate.Markdown.Content) - } - } - - // Route to group or C2C. + chatKind := c.getChatKind(msg.ChatID) mdMsg, textMsg := c.genReplyMsg(ctx, msg) var err error for _, replyMsg := range []dto.MessageToCreate{mdMsg, textMsg} { @@ -329,9 +304,6 @@ func (c *QQChannel) StartTyping(ctx context.Context, chatID string) (func(), err } // SendMedia implements the channels.MediaSender interface. -// 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() { @@ -339,24 +311,6 @@ func (c *QQChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) } var err error for _, part := range msg.Parts { - 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 - } - return fmt.Errorf("qq send media: %w", channels.ErrTemporary) - } - - 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": err.Error(), if err = c.sendOneMedia(ctx, msg.ChatID, part); err != nil { logger.ErrorCF("qq", "Failed to send media", map[string]any{ "part": part, @@ -368,236 +322,6 @@ func (c *QQChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) return err } -type qqMediaUpload struct { - FileType uint64 `json:"file_type"` - URL string `json:"url,omitempty"` - FileData string `json:"file_data,omitempty"` - FileName string `json:"file_name,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{} - - mediaRef := part.Ref - if isHTTPURL(mediaRef) { - payload.FileType = qqFileType(c.outboundMediaType(part, "")) - payload.URL = mediaRef - payload.FileName = qqUploadFilename(part, mediaRef, payload.FileType) - return payload, nil - } - - store := c.GetMediaStore() - if store == nil { - return nil, fmt.Errorf("no media store available: %w", channels.ErrSendFailed) - } - - resolved, meta, err := store.ResolveWithMeta(part.Ref) - if err != nil { - return nil, fmt.Errorf("qq resolve media ref %q: %v: %w", part.Ref, err, channels.ErrSendFailed) - } - if part.Filename == "" { - part.Filename = meta.Filename - } - if part.ContentType == "" { - part.ContentType = meta.ContentType - } - - if isHTTPURL(resolved) { - payload.FileType = qqFileType(c.outboundMediaType(part, "")) - payload.URL = resolved - payload.FileName = qqUploadFilename(part, resolved, payload.FileType) - return payload, nil - } - payload.FileType = qqFileType(c.outboundMediaType(part, resolved)) - payload.FileName = qqUploadFilename(part, resolved, payload.FileType) - - 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 qqUploadFilename(part bus.MediaPart, resolved string, fileType uint64) string { - if fileType != qqFileType("file") { - return "" - } - if part.Filename != "" { - return part.Filename - } - if isHTTPURL(resolved) { - if parsed, err := url.Parse(resolved); err == nil { - if base := path.Base(parsed.Path); base != "" && base != "." && base != "/" { - return base - } - } - return "" - } - - if base := filepath.Base(resolved); base != "" && base != "." { - return base - } - return "" -} - -func (c *QQChannel) outboundMediaType(part bus.MediaPart, localPath string) string { - if part.Type != "audio" { - return part.Type - } - - if localPath == "" { - logger.InfoCF("qq", "Sending audio as file because duration is unavailable", map[string]any{ - "ref": part.Ref, - "filename": part.Filename, - }) - return "file" - } - - duration, ok, err := qqAudioDuration(localPath, part.Filename, part.ContentType) - if err != nil { - logger.WarnCF("qq", "Failed to detect audio duration, sending as file", map[string]any{ - "ref": part.Ref, - "filename": part.Filename, - "error": err.Error(), - }) - return "file" - } - if !ok { - logger.InfoCF("qq", "Sending audio as file because duration is unavailable", map[string]any{ - "ref": part.Ref, - "filename": part.Filename, - }) - return "file" - } - if duration > qqVoiceMaxDuration { - logger.InfoCF("qq", "Sending audio as file because it exceeds QQ voice limit", map[string]any{ - "ref": part.Ref, - "filename": part.Filename, - "duration_seconds": duration.Seconds(), - "limit_seconds": qqVoiceMaxDuration.Seconds(), - }) - return "file" - } - - return "audio" -} - -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 { @@ -619,19 +343,6 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler { 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") Username: data.Author.Username, } @@ -650,9 +361,8 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler { } logger.InfoCF("qq", "Received C2C message", map[string]any{ - "sender": senderID, - "length": len(content), - "media_count": len(mediaPaths), + "sender": senderID, + "length": len(content), }) // Store chat routing context. @@ -699,19 +409,6 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler { 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(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. Username: data.Author.Username, } @@ -735,16 +432,11 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler { 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), - "media_count": len(mediaPaths), + "sender": senderID, + "group": data.GroupID, + "length": len(content), }) // Store chat routing context using GroupID as chatID. @@ -771,158 +463,6 @@ 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", - CleanupPolicy: media.CleanupPolicyDeleteOnCleanup, - }, 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 { @@ -1242,16 +782,6 @@ 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.