feat(qq): support parsing and replying to more attachment types

1.Support parsing QQ Channel emoji structures.
2.Support handling incoming voice, image, video, and file messages from QQ Channel.
3.Support replying with local voice, image, video, and file attachments (upload before sending).
4.Prioritize Markdown when replying, with a fallback to plain text if it fails.
This commit is contained in:
aishannon 2026-03-11 15:26:54 +08:00 committed by shannonchen
parent 96fd4e0519
commit 9a6315a9f8
2 changed files with 380 additions and 130 deletions

View file

@ -0,0 +1,30 @@
package qq
import "github.com/tencent-connect/botgo/dto"
// RichMediaMessage rich media message.
// It is recommended to upload first, then send using message type 7.
type RichMediaMessage struct {
FileType uint64 `json:"file_type,omitempty"` // file type: 1-image, 2-video, 3-voice (currently voice only supports silk format)
URL string `json:"url,omitempty"` // rich media file to send, HTTP or HTTPS link
FileName string `json:"file_name,omitempty"` // file name for files sent via FileData
FileData []byte `json:"file_data,omitempty"` // file binary data for files sent via FileData
}
// GetEventID event ID
func (msg RichMediaMessage) GetEventID() string {
return ""
}
// GetSendType message type
func (msg RichMediaMessage) GetSendType() dto.SendType {
return dto.RichMedia
}
// MessageAttachment attachment definition
type MessageAttachment struct {
URL string `json:"url,omitempty"`
FileName string `json:"filename,omitempty"`
ContentType string `json:"content_type,omitempty"` // voice: audio, image/xxx: image, video/xxx: video
AsrReferText string `json:"asr_refer_text,omitempty"` // ASR reference text
}

View file

@ -2,7 +2,15 @@ package qq
import ( import (
"context" "context"
"encoding/base64"
"encoding/json"
"fmt" "fmt"
"github.com/sipeed/picoclaw/pkg/media"
"github.com/sipeed/picoclaw/pkg/utils"
"github.com/tidwall/gjson"
"math/rand"
"os"
"path/filepath"
"regexp" "regexp"
"strings" "strings"
"sync" "sync"
@ -47,7 +55,7 @@ type QQChannel struct {
lastMsgID sync.Map // chatID → string lastMsgID sync.Map // chatID → string
// msg_seq: per-chat atomic counter for multi-part replies. // msg_seq: per-chat atomic counter for multi-part replies.
msgSeqCounters sync.Map // chatID → *atomic.Uint64 msgSeqCounters sync.Map // chatID → *atomic.Uint32
// Time-based dedup replacing the unbounded map. // Time-based dedup replacing the unbounded map.
dedup map[string]time.Time dedup map[string]time.Time
@ -101,7 +109,7 @@ func (c *QQChannel) Start(ctx context.Context) error {
} }
// initialize OpenAPI client // initialize OpenAPI client
c.api = botgo.NewOpenAPI(c.config.AppID, c.tokenSource).WithTimeout(5 * time.Second) c.api = botgo.NewOpenAPI(c.config.AppID, c.tokenSource).WithTimeout(20 * time.Second)
// register event handlers // register event handlers
intent := event.RegisterHandlers( intent := event.RegisterHandlers(
@ -176,72 +184,72 @@ func (c *QQChannel) getChatKind(chatID string) string {
return "group" return "group"
} }
// Send sends a message to the specified chatID.
// First attempt to send a Markdown message, fallback to plain text if failed.
func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
if !c.IsRunning() { if !c.IsRunning() {
return channels.ErrNotRunning return channels.ErrNotRunning
} }
chatKind := c.getChatKind(msg.ChatID) chatKind := c.getChatKind(msg.ChatID)
textMsg, mdMsg := c.genReplyMsg(ctx, msg, chatKind)
// Build message with content. for _, _v := range []dto.MessageToCreate{mdMsg, textMsg} {
msgToCreate := &dto.MessageToCreate{
Content: msg.Content,
MsgType: dto.TextMsg,
}
// Use Markdown message type if enabled in config.
if c.config.SendMarkdown {
msgToCreate.MsgType = dto.MarkdownMsg
msgToCreate.Markdown = &dto.Markdown{
Content: msg.Content,
}
// Clear plain content to avoid sending duplicate text.
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)
}
}
}
}
// 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.
var err error var err error
var replyMsgID *dto.Message
if chatKind == "group" { if chatKind == "group" {
_, err = c.api.PostGroupMessage(ctx, msg.ChatID, msgToCreate) replyMsgID, err = c.api.PostGroupMessage(ctx, msg.ChatID, _v)
} else { } else {
_, err = c.api.PostC2CMessage(ctx, msg.ChatID, msgToCreate) replyMsgID, err = c.api.PostC2CMessage(ctx, msg.ChatID, _v)
}
if err == nil {
logger.InfoCF("qq", "Sent message", map[string]any{"postrsp ": replyMsgID})
return nil
} }
if err != nil { if err != nil {
logger.ErrorCF("qq", "Failed to send message", map[string]any{ logger.ErrorCF("qq", "Failed to send message", map[string]any{
"chat_id": msg.ChatID, "chat_id": msg.ChatID,
"chat_kind": chatKind, "chat_kind": chatKind,
"error": err.Error(), "error": err.Error(),
}) })
return fmt.Errorf("qq send: %w", channels.ErrTemporary) }
}
return nil
} }
return nil func (c *QQChannel) genReplyMsg(ctx context.Context, msg bus.OutboundMessage, chatKind string) (dto.MessageToCreate,
dto.MessageToCreate) {
textMsg := dto.MessageToCreate{
Content: sanitizeURLs(msg.Content),
MsgType: dto.TextMsg,
}
mdMsg := dto.MessageToCreate{
MsgType: dto.MarkdownMsg,
Markdown: &dto.Markdown{
Content: msg.Content,
},
}
return textMsg, mdMsg
}
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 != "" {
replyID = msgID
}
}
// Increment msg_seq atomically for multi-part replies.
if counterVal, ok := c.msgSeqCounters.Load(chatID); ok {
if counter, ok := counterVal.(*atomic.Uint32); ok {
seq = counter.Add(1)
}
} else {
seq = rand.Uint32()
}
return replyID, seq
} }
// StartTyping implements channels.TypingCapable. // StartTyping implements channels.TypingCapable.
@ -305,47 +313,49 @@ func (c *QQChannel) StartTyping(ctx context.Context, chatID string) (func(), err
} }
// SendMedia implements the channels.MediaSender interface. // 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.
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() {
return channels.ErrNotRunning return channels.ErrNotRunning
} }
chatKind := c.getChatKind(msg.ChatID)
for _, part := range msg.Parts { for _, part := range msg.Parts {
// If the ref is already an HTTP(S) URL, use it directly. if err := c.sendOneMedia(ctx, msg.ChatID, part); err != nil {
mediaURL := part.Ref logger.ErrorCF("qq", "Failed to send media", map[string]any{
if !isHTTPURL(mediaURL) { "part": part,
// Try resolving through media store. "error": err.Error(),
})
continue
}
}
return nil
}
// Upload file and then send it via API
// QQ groups do not support file sending
// When sending local files via QQ, the file size cannot exceed 10M
func (c *QQChannel) sendOneMedia(ctx context.Context, chatID string, part bus.MediaPart) error {
chatKind := c.getChatKind(chatID)
mediaPath := part.Ref
var meta media.MediaMeta
if !isHTTPURL(mediaPath) {
store := c.GetMediaStore() store := c.GetMediaStore()
if store == nil { if store == nil {
logger.WarnCF("qq", "QQ media requires HTTP/HTTPS URL, no media store available", map[string]any{ logger.WarnCF("qq", "QQ media requires HTTP/HTTPS URL, no media store available", map[string]any{
"ref": part.Ref, "ref": part.Ref,
}) })
continue return fmt.Errorf("store not available")
} }
var resolved string
resolved, err := store.Resolve(part.Ref) var err error
resolved, meta, err = store.ResolveWithMeta(part.Ref)
if err != nil { if err != nil {
logger.ErrorCF("qq", "Failed to resolve media ref", map[string]any{ logger.ErrorCF("qq", "Failed to resolve media ref", map[string]any{
"ref": part.Ref, "ref": part.Ref,
"error": err.Error(), "error": err.Error(),
}) })
continue return fmt.Errorf("store resolve failed")
} }
mediaPath = resolved
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
} }
// Map part type to QQ file type: 1=image, 2=video, 3=audio, 4=file. // Map part type to QQ file type: 1=image, 2=video, 3=audio, 4=file.
@ -361,29 +371,62 @@ func (c *QQChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage)
fileType = 4 // file fileType = 4 // file
} }
richMedia := &dto.RichMediaMessage{ richMedia := &RichMediaMessage{FileType: fileType}
FileType: fileType, if isHTTPURL(mediaPath) {
URL: mediaURL, richMedia.URL = mediaPath
SrvSendMsg: true, } else {
fdata, err := os.ReadFile(mediaPath)
if err != nil {
logger.ErrorCF("qq", "Failed to read media file[%v]", map[string]any{"path": mediaPath,
"error": err.Error()})
return fmt.Errorf("read file failed")
}
richMedia.FileData = fdata
richMedia.FileName = meta.Filename
}
if (chatKind == "group" && fileType == 4) || len(richMedia.FileData) > 10*1024*1024 {
logger.WarnCF("qq", "File size exceeds 10M, skipping send", map[string]any{
"filename": richMedia.FileName, "size": len(richMedia.FileData)})
return nil
} }
var sendErr error var sendErr error
var result *dto.Message
if chatKind == "group" { if chatKind == "group" {
_, sendErr = c.api.PostGroupMessage(ctx, msg.ChatID, richMedia) result, sendErr = c.api.PostGroupMessage(ctx, chatID, richMedia)
} else { } else {
_, sendErr = c.api.PostC2CMessage(ctx, msg.ChatID, richMedia) result, sendErr = c.api.PostC2CMessage(ctx, chatID, richMedia)
} }
if sendErr != nil { if sendErr != nil {
logger.ErrorCF("qq", "Failed to send media", map[string]any{ logger.ErrorCF("qq", "Failed to send media", map[string]any{
"type": part.Type, "type": part.Type,
"chat_id": msg.ChatID, "chat_id": chatID,
"error": sendErr.Error(), "error": sendErr.Error(),
}) })
return fmt.Errorf("qq send media: %w", channels.ErrTemporary) return fmt.Errorf("qq send media: %w err:%v", channels.ErrTemporary, sendErr)
}
} }
msg := dto.MessageToCreate{
MsgType: dto.RichMediaMsg,
Media: &dto.MediaInfo{FileInfo: result.FileInfo},
}
msg.MsgID, msg.MsgSeq = c.getReplyExtInfo(ctx, chatID)
if chatKind == "group" {
result, sendErr = c.api.PostGroupMessage(ctx, chatID, msg)
} else {
result, sendErr = c.api.PostC2CMessage(ctx, chatID, msg)
}
if sendErr != nil {
logger.ErrorCF("qq", "Failed to send media", map[string]any{
"type": part.Type,
"chat_id": chatID,
"error": sendErr.Error(),
})
return fmt.Errorf("qq send media: %w err:%v", channels.ErrTemporary, sendErr)
}
return nil return nil
} }
@ -404,10 +447,11 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler {
return nil return nil
} }
// extract message content scope := channels.BuildMediaScope("qq", senderID, data.ID)
content := data.Content
content, mediaPaths := c.decodeMesasge(context.Background(), event, (*dto.Message)(data), scope)
if content == "" { if content == "" {
logger.DebugC("qq", "Received empty message, ignoring") logger.DebugC("qq", "Received empty C2C message, ignoring")
return nil return nil
} }
@ -421,7 +465,7 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler {
c.lastMsgID.Store(senderID, data.ID) c.lastMsgID.Store(senderID, data.ID)
// Reset msg_seq counter for new inbound message. // Reset msg_seq counter for new inbound message.
c.msgSeqCounters.Store(senderID, new(atomic.Uint64)) c.msgSeqCounters.Store(senderID, new(atomic.Uint32))
metadata := map[string]string{ metadata := map[string]string{
"account_id": senderID, "account_id": senderID,
@ -443,7 +487,7 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler {
senderID, senderID,
senderID, senderID,
content, content,
[]string{}, mediaPaths,
metadata, metadata,
sender, sender,
) )
@ -468,14 +512,13 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler {
logger.WarnC("qq", "Received group message with no sender ID") logger.WarnC("qq", "Received group message with no sender ID")
return nil return nil
} }
scope := channels.BuildMediaScope("qq", data.GroupID, data.ID)
// extract message content (remove @ bot part) content, mediaPaths := c.decodeMesasge(context.Background(), event, (*dto.Message)(data), scope)
content := data.Content
if content == "" { if content == "" {
logger.DebugC("qq", "Received empty group message, ignoring") logger.DebugC("qq", "Received empty group message, ignoring")
return nil return nil
} }
// GroupAT event means bot is always mentioned; apply group trigger filtering // GroupAT event means bot is always mentioned; apply group trigger filtering
respond, cleaned := c.ShouldRespondInGroup(true, content) respond, cleaned := c.ShouldRespondInGroup(true, content)
if !respond { if !respond {
@ -492,10 +535,6 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler {
// Store chat routing context using GroupID as chatID. // Store chat routing context using GroupID as chatID.
c.chatType.Store(data.GroupID, "group") c.chatType.Store(data.GroupID, "group")
c.lastMsgID.Store(data.GroupID, data.ID) c.lastMsgID.Store(data.GroupID, data.ID)
// Reset msg_seq counter for new inbound message.
c.msgSeqCounters.Store(data.GroupID, new(atomic.Uint64))
metadata := map[string]string{ metadata := map[string]string{
"account_id": senderID, "account_id": senderID,
"group_id": data.GroupID, "group_id": data.GroupID,
@ -517,7 +556,7 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler {
senderID, senderID,
data.GroupID, data.GroupID,
content, content,
[]string{}, mediaPaths,
metadata, metadata,
sender, sender,
) )
@ -582,6 +621,144 @@ func (c *QQChannel) dedupJanitor() {
} }
} }
func (c *QQChannel) decodeMesasge(ctx context.Context, event *dto.WSPayload, data *dto.Message, scope string) (content string, mediaPaths []string) {
content = parseEmojiText(data.Content)
wavURL, asrReferText := getVoiceInfo(event)
if data.Attachments != nil && len(data.Attachments) > 0 {
var attachments []MessageAttachment
for _, att := range data.Attachments {
if att.ContentType == "voice" && wavURL != "" {
attachments = append(attachments, MessageAttachment{
ContentType: "voice",
URL: wavURL,
FileName: filepath.Base(wavURL),
AsrReferText: asrReferText,
})
continue
} else {
attachments = append(attachments, MessageAttachment{
ContentType: att.ContentType,
URL: att.URL,
FileName: att.FileName,
})
}
}
processedPaths, attachmentContent := c.processAttachments(ctx, attachments, scope)
if asrReferText != "" {
attachmentContent = fmt.Sprintf("[audio: %v]", asrReferText)
}
mediaPaths = processedPaths
if content != "" {
content += "\n"
}
content += attachmentContent
}
return content, mediaPaths
}
// processAttachments processes all attachments in a message
func (c *QQChannel) processAttachments(ctx context.Context, attachments []MessageAttachment, scope string) ([]string, string) {
mediaPaths := []string{}
content := ""
// Helper to register a local file with the media store
storeMedia := func(localPath, filename string) string {
store := c.GetMediaStore()
if store == nil {
logger.ErrorCF("qq", "media store is nil", map[string]any{
"scope": scope,
})
return ""
}
ref, err := store.Store(localPath, media.MediaMeta{Filename: filename, Source: "qq"}, scope)
if err != nil {
logger.InfoCF("qq", "Stored media", map[string]any{
"scope": scope,
"localPath": localPath,
"filename": filename,
})
return ref
}
logger.ErrorCF("qq", "Stored media", map[string]any{
"scope": scope,
"localPath": localPath,
"ref": ref,
})
return localPath
}
for _, attachment := range attachments {
attachmentType := c.getAttachmentType(attachment)
localPath := c.downloadAttachment(ctx, attachment)
if localPath == "" {
mediaPaths = append(mediaPaths, attachment.URL)
content += appendContent(content, fmt.Sprintf("[%v: %s]", attachment.ContentType, attachment.URL))
continue
}
ref := storeMedia(localPath, attachment.FileName)
mediaPaths = append(mediaPaths, ref)
if attachmentType == "audio" && attachment.AsrReferText != "" {
content += appendContent(content, fmt.Sprintf("[audio: %s]", attachment.AsrReferText))
continue
}
content += appendContent(content, fmt.Sprintf("[%v: %s]", attachment.ContentType, ref))
}
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"
} else if strings.HasPrefix(attachment.ContentType, "video") {
return "video"
} else if strings.HasPrefix(attachment.ContentType, "voice") {
return "audio"
}
return "file"
}
// 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)
if err != nil {
logger.ErrorCF("qq", "Failed to marshal event data", map[string]any{
"error": err.Error(),
})
return "", ""
}
// 使用gjson提取voice_wav_url字段
rawJSON := string(_raw)
// 首先尝试从attachments数组的第一个元素中提取voice_wav_url
voiceWavURL := gjson.Get(rawJSON, "attachments.0.voice_wav_url").String()
asrReferText := gjson.Get(rawJSON, "attachments.0.asr_refer_text").String()
logger.DebugCF("qq", "Found voice_wav_url in attachments", map[string]any{
"url": voiceWavURL, "asr_refer_text": asrReferText,
})
return voiceWavURL, asrReferText
}
// isHTTPURL returns true if s starts with http:// or https://. // isHTTPURL returns true if s starts with http:// or https://.
func isHTTPURL(s string) bool { func isHTTPURL(s string) bool {
return strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://") return strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://")
@ -625,3 +802,46 @@ func sanitizeURLs(text string) string {
return scheme + domain + path return scheme + domain + path
}) })
} }
// parseEmojiText decodes emoji text
func parseEmojiText(content string) string {
content = strings.ReplaceAll(content, `\\`, `\`)
content = strings.ReplaceAll(content, "\\u003c", "<")
content = strings.ReplaceAll(content, "\\u003e", ">")
content = strings.ReplaceAll(content, `\"`, `"`)
combinedRegexp := regexp.MustCompile(`<[^<]*?ext="([^"]+)"[^<]*?faceType=(\d+)[^<]*?>|<[^<]*?faceType=(\d+)[^<]*?ext="([^"]+)"[^<]*?>`)
contentParts := combinedRegexp.Split(content, -1)
matches := combinedRegexp.FindAllString(content, -1)
var result strings.Builder
for i, part := range contentParts {
if strings.TrimSpace(part) != "" {
result.WriteString(part)
}
if i < len(matches) {
match := matches[i]
if strings.Contains(match, "faceType=") {
result.WriteString(processEmoji(match))
}
}
}
return result.String()
}
func processEmoji(match string) string {
extRegexp := regexp.MustCompile(`ext="([^"]+)"`)
extMatch := extRegexp.FindStringSubmatch(match)
if len(extMatch) > 1 {
ext, err := base64.StdEncoding.DecodeString(extMatch[1])
if err == nil {
var faceDesc map[string]string
json.Unmarshal(ext, &faceDesc)
return fmt.Sprintf("[表情 %v]", faceDesc["text"])
}
}
return ""
}