feat(qq): 支持文件处理。

This commit is contained in:
aishannon 2026-03-25 00:10:22 +08:00
parent afeafe43e2
commit 7fa2766aea

View file

@ -4,12 +4,6 @@ import (
"context" "context"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"os"
"path"
"fmt" "fmt"
"math" "math"
"os" "os"
@ -25,10 +19,9 @@ import (
"github.com/tidwall/gjson" "github.com/tidwall/gjson"
"github.com/tencent-connect/botgo" "github.com/tencent-connect/botgo"
"github.com/tencent-connect/botgo/constant"
"github.com/tencent-connect/botgo/dto" "github.com/tencent-connect/botgo/dto"
"github.com/tencent-connect/botgo/event" "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" "github.com/tencent-connect/botgo/token"
"golang.org/x/oauth2" "golang.org/x/oauth2"
@ -37,8 +30,6 @@ import (
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/identity" "github.com/sipeed/picoclaw/pkg/identity"
"github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/media"
"github.com/sipeed/picoclaw/pkg/utils"
) )
const ( const (
@ -47,7 +38,6 @@ const (
dedupMaxSize = 10000 // hard cap on dedup map entries dedupMaxSize = 10000 // hard cap on dedup map entries
typingResend = 8 * time.Second typingResend = 8 * time.Second
typingSeconds = 10 typingSeconds = 10
bytesPerMiB = 1024 * 1024
) )
type kindType string type kindType string
@ -67,12 +57,11 @@ var extRegexp = regexp.MustCompile(`ext="([^"]+)"`)
type QQChannel struct { type QQChannel struct {
*channels.BaseChannel *channels.BaseChannel
config config.QQConfig config config.QQConfig
api qqAPI api openapi.OpenAPI
tokenSource oauth2.TokenSource tokenSource oauth2.TokenSource
ctx context.Context ctx context.Context
cancel context.CancelFunc cancel context.CancelFunc
sessionManager botgo.SessionManager sessionManager botgo.SessionManager
downloadFn func(urlStr, filename string) string
// Chat routing: track whether a chatID is group or direct. // Chat routing: track whether a chatID is group or direct.
chatType sync.Map // kindType → "group" | "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 { 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") 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)") logger.InfoC("qq", "Starting QQ bot (WebSocket mode)")
// Reinitialize shutdown signal for clean restart. // Reinitialize shutdown signal for clean restart.
@ -124,7 +113,7 @@ func (c *QQChannel) Start(ctx context.Context) error {
// create token source // create token source
credentials := &token.QQBotCredentials{ credentials := &token.QQBotCredentials{
AppID: c.config.AppID, AppID: c.config.AppID,
AppSecret: c.config.AppSecret(), AppSecret: c.config.AppSecret,
} }
c.tokenSource = token.NewQQBotTokenSource(credentials) c.tokenSource = token.NewQQBotTokenSource(credentials)
@ -229,21 +218,7 @@ func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
return channels.ErrNotRunning return channels.ErrNotRunning
} }
chatKind := c.getChatKind(msg.ChatID)
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.
mdMsg, textMsg := c.genReplyMsg(ctx, msg) mdMsg, textMsg := c.genReplyMsg(ctx, msg)
var err error var err error
for _, replyMsg := range []dto.MessageToCreate{mdMsg, textMsg} { 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. // 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 { func (c *QQChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {
if !c.IsRunning() { if !c.IsRunning() {
@ -339,24 +311,6 @@ func (c *QQChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage)
} }
var err error var err error
for _, part := range msg.Parts { 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 { if err = c.sendOneMedia(ctx, msg.ChatID, part); err != nil {
logger.ErrorCF("qq", "Failed to send media", map[string]any{ logger.ErrorCF("qq", "Failed to send media", map[string]any{
"part": part, "part": part,
@ -368,236 +322,6 @@ func (c *QQChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage)
return err 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. // handleC2CMessage handles QQ private messages.
func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler { func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler {
return func(event *dto.WSPayload, data *dto.WSC2CMessageData) error { return func(event *dto.WSPayload, data *dto.WSC2CMessageData) error {
@ -619,19 +343,6 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler {
Platform: "qq", Platform: "qq",
PlatformID: data.Author.ID, PlatformID: data.Author.ID,
CanonicalID: identity.BuildCanonicalID("qq", 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, Username: data.Author.Username,
} }
@ -652,7 +363,6 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler {
logger.InfoCF("qq", "Received C2C message", map[string]any{ logger.InfoCF("qq", "Received C2C message", map[string]any{
"sender": senderID, "sender": senderID,
"length": len(content), "length": len(content),
"media_count": len(mediaPaths),
}) })
// Store chat routing context. // Store chat routing context.
@ -699,19 +409,6 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler {
Platform: "qq", Platform: "qq",
PlatformID: data.Author.ID, PlatformID: data.Author.ID,
CanonicalID: identity.BuildCanonicalID("qq", 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, Username: data.Author.Username,
} }
@ -735,16 +432,11 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler {
return nil return nil
} }
content = cleaned 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{ logger.InfoCF("qq", "Received group AT message", map[string]any{
"sender": senderID, "sender": senderID,
"group": data.GroupID, "group": data.GroupID,
"length": len(content), "length": len(content),
"media_count": len(mediaPaths),
}) })
// Store chat routing context using GroupID as chatID. // 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. // 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. // It also enforces a hard cap on map size by evicting oldest entries.
func (c *QQChannel) isDuplicate(messageID string) bool { 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://") 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. // urlPattern matches URLs with explicit http(s):// scheme.
// Only scheme-prefixed URLs are matched to avoid false positives on bare text // 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. // like version numbers (e.g., "1.2.3") or domain-like fragments.