diff --git a/go.mod b/go.mod index 089a62465..23e035f54 100644 --- a/go.mod +++ b/go.mod @@ -7,13 +7,11 @@ require ( github.com/BurntSushi/toml v1.6.0 github.com/adhocore/gronx v1.19.6 github.com/anthropics/anthropic-sdk-go v1.26.0 - github.com/atotto/clipboard v0.1.4 - github.com/aws/aws-sdk-go-v2 v1.41.5 + github.com/aws/aws-sdk-go-v2 v1.41.4 github.com/aws/aws-sdk-go-v2/config v1.32.12 - github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.4 + github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.2 github.com/bwmarrin/discordgo v0.29.0 github.com/caarlos0/env/v11 v11.4.0 - github.com/creack/pty v1.1.24 github.com/ergochat/irc-go v0.6.0 github.com/ergochat/readline v0.1.3 github.com/gdamore/tcell/v2 v2.13.8 @@ -23,13 +21,10 @@ require ( github.com/h2non/filetype v1.1.3 github.com/larksuite/oapi-sdk-go/v3 v3.5.3 github.com/mdp/qrterminal/v3 v3.2.1 - github.com/minio/selfupdate v0.6.0 github.com/modelcontextprotocol/go-sdk v1.4.1 github.com/mymmrac/telego v1.7.0 github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 github.com/openai/openai-go/v3 v3.22.0 - github.com/pion/rtp v1.8.7 - github.com/pion/webrtc/v3 v3.3.6 github.com/rivo/tview v0.42.0 github.com/rs/zerolog v1.34.0 github.com/slack-go/slack v0.17.3 @@ -40,22 +35,21 @@ require ( go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4 golang.org/x/oauth2 v0.36.0 golang.org/x/term v0.41.0 - golang.org/x/time v0.15.0 + golang.org/x/time v0.14.0 google.golang.org/protobuf v1.36.11 gopkg.in/yaml.v3 v3.0.1 maunium.net/go/mautrix v0.26.4 - modernc.org/sqlite v1.47.0 + modernc.org/sqlite v1.46.1 rsc.io/qr v0.2.0 ) require ( - aead.dev/minisign v0.2.0 // indirect filippo.io/edwards25519 v1.2.0 // indirect - github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7 // indirect github.com/aws/aws-sdk-go-v2/credentials v1.19.12 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 // indirect github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 // indirect @@ -65,14 +59,11 @@ require ( github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 // indirect github.com/aws/smithy-go v1.24.2 // indirect github.com/beeper/argo-go v1.1.2 // indirect - github.com/cloudflare/circl v1.6.3 // indirect github.com/coder/websocket v1.8.14 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect github.com/gdamore/encoding v1.0.1 // indirect - github.com/go-logr/logr v1.4.3 // indirect - github.com/go-logr/stdr v1.2.2 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect @@ -81,7 +72,6 @@ require ( github.com/mattn/go-sqlite3 v1.14.34 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/petermattis/goid v0.0.0-20260226131333-17d1149c6ac6 // indirect - github.com/pion/randutil v0.1.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect @@ -90,13 +80,9 @@ require ( github.com/spf13/pflag v1.0.10 // indirect github.com/vektah/gqlparser/v2 v2.5.27 // indirect go.mau.fi/libsignal v0.2.1 // indirect - go.opentelemetry.io/auto/sdk v1.1.0 // indirect - go.opentelemetry.io/otel v1.35.0 // indirect - go.opentelemetry.io/otel/metric v1.35.0 // indirect - go.opentelemetry.io/otel/trace v1.35.0 // indirect golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect golang.org/x/text v0.35.0 // indirect - modernc.org/libc v1.70.0 // indirect + modernc.org/libc v1.67.6 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect ) @@ -107,7 +93,7 @@ require ( github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect - github.com/github/copilot-sdk/go v0.2.0 + github.com/github/copilot-sdk/go v0.1.32 github.com/go-resty/resty/v2 v2.17.1 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/google/jsonschema-go v0.4.2 // indirect @@ -129,5 +115,3 @@ require ( golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.42.0 ) - -replace github.com/bwmarrin/discordgo => github.com/yeongaori/discordgo-fork v0.0.0-20260319072544-e8e546f5d532 diff --git a/pkg/channels/qq/qq.go b/pkg/channels/qq/qq.go index 4d8cd7331..691535d7e 100644 --- a/pkg/channels/qq/qq.go +++ b/pkg/channels/qq/qq.go @@ -4,9 +4,17 @@ import ( "context" "encoding/base64" "encoding/json" + "errors" "fmt" + "github.com/sipeed/picoclaw/pkg/identity" + "github.com/sipeed/picoclaw/pkg/utils" + "github.com/tencent-connect/botgo/constant" + "github.com/tencent-connect/botgo/openapi/options" "math" + "net/http" + "net/url" "os" + "path" "path/filepath" "regexp" "strings" @@ -15,20 +23,17 @@ import ( "time" "github.com/sipeed/picoclaw/pkg/media" - "github.com/sipeed/picoclaw/pkg/utils" "github.com/tidwall/gjson" "github.com/tencent-connect/botgo" "github.com/tencent-connect/botgo/dto" "github.com/tencent-connect/botgo/event" - "github.com/tencent-connect/botgo/openapi" "github.com/tencent-connect/botgo/token" "golang.org/x/oauth2" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" - "github.com/sipeed/picoclaw/pkg/identity" "github.com/sipeed/picoclaw/pkg/logger" ) @@ -38,14 +43,11 @@ const ( dedupMaxSize = 10000 // hard cap on dedup map entries typingResend = 8 * time.Second typingSeconds = 10 + bytesPerMiB = 1024 * 1024 ) type kindType string -func (k kindType) String() string { - return string(k) -} - const ( kindDirect kindType = "direct" kindGroup kindType = "group" @@ -54,14 +56,27 @@ const ( var emojiRegexp = regexp.MustCompile(`<[^<]*?ext="([^"]+)"[^<]*?faceType=(\d+)[^<]*?>|<[^<]*?faceType=(\d+)[^<]*?ext="([^"]+)"[^<]*?>`) var extRegexp = regexp.MustCompile(`ext="([^"]+)"`) + +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 // kindType → "group" | "direct" @@ -99,11 +114,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(logger.NewLogger("botgo")) + botgo.SetLogger(newBotGoLogger("botgo")) logger.InfoC("qq", "Starting QQ bot (WebSocket mode)") // Reinitialize shutdown signal for clean restart. @@ -113,7 +128,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) @@ -163,7 +178,7 @@ func (c *QQChannel) Start(ctx context.Context) error { // Pre-register reasoning_channel_id as group chat if configured, // so outbound-only destinations are routed correctly. if c.config.ReasoningChannelID != "" { - c.chatType.Store(c.config.ReasoningChannelID, kindGroup.String()) + c.chatType.Store(c.config.ReasoningChannelID, kindGroup) } c.SetRunning(true) @@ -304,17 +319,35 @@ 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 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 } + chatKind := c.getChatKind(msg.ChatID) var err error for _, part := range msg.Parts { - if err = c.sendOneMedia(ctx, msg.ChatID, part); err != nil { + 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{ - "part": part, - "error": err.Error(), + "type": part.Type, + "chat_id": msg.ChatID, + "error": err.Error(), }) continue } @@ -322,6 +355,206 @@ func (c *QQChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) return err } +func (c *QQChannel) uploadMedia( ctx context.Context, + chatKind kindType, 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) (*RichMediaMessage, error) { + payload := &RichMediaMessage{} + + 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 + } + payload.FileType = qqFileType(c.outboundMediaType(part, resolved)) + payload.FileName = qqUploadFilename(part, resolved, payload.FileType) + 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 = 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" +} + + + +// Fix the sendUploadedMedia function syntax error +func (c *QQChannel) sendUploadedMedia(ctx context.Context, chatKind kindType, chatID string, part bus.MediaPart, + fileInfo []byte) error { + + msg := &dto.MessageToCreate{ + Content: part.Caption, + MsgType: dto.RichMediaMsg, + Media: &dto.MediaInfo{ + FileInfo: fileInfo, + }, + } + + msg.MsgID, msg.MsgSeq = c.getReplyExtInfo(ctx, chatID) + + if chatKind == kindGroup { + if msg.Content != "" { + msg.Content = sanitizeURLs(msg.Content) + } + _, err := c.api.PostGroupMessage(ctx, chatID, msg) + return err + } + _, err := c.api.PostC2CMessage(ctx, chatID, msg) + return err +} + +func (c *QQChannel) mediaUploadURL(chatKind kindType, chatID string) string { + base := constant.APIDomain + if chatKind == kindGroup { + 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 { @@ -343,7 +576,20 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler { Platform: "qq", PlatformID: data.Author.ID, CanonicalID: identity.BuildCanonicalID("qq", data.Author.ID), - Username: data.Author.Username, + } + + 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 content/attachments, ignoring") + return nil } if !c.IsAllowedSender(sender) { @@ -354,35 +600,38 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler { } scope := channels.BuildMediaScope("qq", senderID, data.ID) - content, mediaPaths := c.decodeMessage(context.Background(), event, (*dto.Message)(data), scope) + content, mediaPaths = c.decodeMessage(context.Background(), event, (*dto.Message)(data), scope) if content == "" { logger.DebugC("qq", "Received empty C2C message, ignoring") return nil } - logger.InfoCF("qq", "Received C2C message", map[string]any{ - "sender": senderID, - "length": len(content), + 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. c.saveChatKind(senderID, kindDirect) c.lastMsgID.Store(senderID, data.ID) - metadata := map[string]string{ - "account_id": senderID, - } + metadata := map[string]string{ + "account_id": senderID, + } - c.HandleMessage(c.ctx, - bus.Peer{Kind: kindDirect.String(), ID: senderID}, - data.ID, - senderID, - senderID, - content, - mediaPaths, - metadata, - sender, - ) + c.HandleMessage(c.ctx, + bus.Peer{Kind: string(kindDirect), ID: senderID}, + data.ID, + senderID, + senderID, + content, + mediaPaths, + metadata, + sender, + ) return nil } @@ -409,9 +658,19 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler { Platform: "qq", PlatformID: data.Author.ID, CanonicalID: identity.BuildCanonicalID("qq", data.Author.ID), - Username: data.Author.Username, } + 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) + } + + if !c.IsAllowedSender(sender) { logger.Infof("qq", "Received group message from unauthorized sender", map[string]any{ "sender": sender, @@ -421,7 +680,7 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler { scope := channels.BuildMediaScope("qq", data.GroupID, data.ID) - content, mediaPaths := c.decodeMessage(context.Background(), event, (*dto.Message)(data), scope) + content, mediaPaths = c.decodeMessage(context.Background(), event, (*dto.Message)(data), scope) if content == "" { logger.DebugC("qq", "Received empty group message, ignoring") return nil @@ -432,11 +691,16 @@ 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), + "sender": senderID, + "group": data.GroupID, + "length": len(content), + "media_count": len(mediaPaths), }) // Store chat routing context using GroupID as chatID. @@ -448,21 +712,172 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler { "group_id": data.GroupID, } - c.HandleMessage(c.ctx, - bus.Peer{Kind: kindGroup.String(), ID: data.GroupID}, - data.ID, - senderID, - data.GroupID, - content, - mediaPaths, - metadata, - sender, - ) + c.HandleMessage(c.ctx, + bus.Peer{Kind: string(kindGroup), ID: data.GroupID}, + data.ID, + senderID, + data.GroupID, + content, + mediaPaths, + metadata, + sender, + ) return nil } } +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 { @@ -542,7 +957,7 @@ func (c *QQChannel) genReplyMsg(ctx context.Context, msg bus.OutboundMessage) (d return mdMsg, textMsg } -func (c *QQChannel) getReplyExtInfo(_ context.Context, chatID string) (replyID string, seq uint32) { +func (c *QQChannel) getReplyExtInfo(ctx context.Context, chatID string) (replyID string, seq uint32) { // Attach passive reply msg_id and msg_seq if available. if v, ok := c.lastMsgID.Load(chatID); ok { if msgID, ok := v.(string); ok && msgID != "" { @@ -734,7 +1149,7 @@ func (c *QQChannel) processAttachments(ctx context.Context, attachments []Messag for _, attachment := range attachments { attachmentType := c.getAttachmentType(attachment) - localPath := c.downloadAttachment(ctx, attachment) + localPath := c.downloadAttachment(attachment.URL, attachment.FileName) if localPath == "" { mediaPaths = append(mediaPaths, attachment.URL) content = appendContent(content, fmt.Sprintf("[%v: %s]", attachment.ContentType, attachment.URL)) @@ -752,19 +1167,10 @@ func (c *QQChannel) processAttachments(ctx context.Context, attachments []Messag return mediaPaths, content } -// downloadAttachment downloads an attachment from QQ server -func (c *QQChannel) downloadAttachment(ctx context.Context, attachment MessageAttachment) string { - logger.InfoCF("qq", "Downloading attachment", map[string]any{ - "attachment": attachment, - }) - return utils.DownloadFile(attachment.URL, attachment.FileName, utils.DownloadOptions{ - LoggerPrefix: "qq", - }) -} + // getAttachmentType determines the type of attachment (image, audio, video, file) func (c *QQChannel) getAttachmentType(attachment MessageAttachment) string { - if strings.HasPrefix(attachment.ContentType, "image") { return "image" } @@ -782,6 +1188,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. @@ -821,13 +1237,7 @@ func sanitizeURLs(text string) string { }) } -// appendContent safely appends content to existing text -func appendContent(content, suffix string) string { - if content == "" { - return suffix - } - return content + "\n" + suffix -} + func getVoiceInfo(event *dto.WSPayload) (string, string) { _raw, err := json.Marshal(event.Data) @@ -887,4 +1297,4 @@ func processEmoji(match string) string { } } return "" -} +} \ No newline at end of file