feat(channel/qq): optimize message sending, seq generation, and message parsing

1. Define `kindType` as constants.
2. Set `seq` generation to global incremental, simplifying `seq` handling. (QQ platform validation rule is `chatID + replyMsgID + seq`, and `seq` does not affect display order.)
3. Increase API timeout from 5 seconds to 20 seconds to reduce send timeouts.
4. Prioritize sending messages using Markdown; fall back to plain text if Markdown sending fails. Also remove the `QQConfig.SendMarkdown` configuration option.
5. Restrict `InputNotify` to be sent only for direct messages.
6. Adjust message parsing to support emoji parsing and utilize the platform-provided ASR (Automatic Speech Recognition) content.
This commit is contained in:
aishannon 2026-03-29 16:54:59 +08:00
parent cd0d708ba0
commit 36991c5330
3 changed files with 19 additions and 87 deletions

View file

@ -328,7 +328,6 @@ func (c *QQChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage)
return channels.ErrNotRunning
}
chatKind := c.getChatKind(msg.ChatID)
var err error
for _, part := range msg.Parts {
fileInfo, err := c.uploadMedia(ctx, chatKind, msg.ChatID, part)
if err != nil {
@ -349,10 +348,10 @@ func (c *QQChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage)
"chat_id": msg.ChatID,
"error": err.Error(),
})
continue
return fmt.Errorf("qq send media: %w", channels.ErrTemporary)
}
}
return err
return nil
}
func (c *QQChannel) uploadMedia(ctx context.Context,
@ -582,8 +581,7 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler {
return nil
}
scope := channels.BuildMediaScope("qq", senderID, data.ID)
content, mediaPaths := c.decodeMessage(context.Background(), event, (*dto.Message)(data), scope)
content, mediaPaths := c.decodeMessage(context.Background(), senderID, event, (*dto.Message)(data))
if content == "" && len(mediaPaths) == 0 {
logger.DebugC("qq", "Received empty C2C message with no content/attachments, ignoring")
return nil
@ -648,8 +646,7 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler {
return nil
}
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(), senderID, event, (*dto.Message)(data))
if content == "" {
logger.DebugC("qq", "Received empty group message, ignoring")
return nil
@ -697,7 +694,7 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler {
}
func (c *QQChannel) extractInboundAttachments(chatID, messageID string,
attachments []*dto.MessageAttachment) ([]string, []string) {
attachments []MessageAttachment) ([]string, []string) {
if len(attachments) == 0 {
return nil, nil
@ -707,7 +704,7 @@ func (c *QQChannel) extractInboundAttachments(chatID, messageID string,
mediaPaths := make([]string, 0, len(attachments))
notes := make([]string, 0, len(attachments))
storeMedia := func(localPath string, attachment *dto.MessageAttachment) string {
storeMedia := func(localPath string, attachment MessageAttachment) string {
if store := c.GetMediaStore(); store != nil {
ref, err := store.Store(localPath, media.MediaMeta{
Filename: qqAttachmentFilename(attachment),
@ -723,17 +720,12 @@ func (c *QQChannel) extractInboundAttachments(chatID, messageID string,
}
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))
}
@ -776,10 +768,7 @@ func (c *QQChannel) downloadHeaders() map[string]string {
return headers
}
func qqAttachmentFilename(attachment *dto.MessageAttachment) string {
if attachment == nil {
return "attachment"
}
func qqAttachmentFilename(attachment MessageAttachment) string {
if attachment.FileName != "" {
return attachment.FileName
}
@ -803,11 +792,7 @@ func qqAttachmentFilename(attachment *dto.MessageAttachment) string {
}
}
func qqAttachmentKind(attachment *dto.MessageAttachment) string {
if attachment == nil {
return "file"
}
func qqAttachmentKind(attachment MessageAttachment) string {
contentType := strings.ToLower(attachment.ContentType)
filename := strings.ToLower(attachment.FileName)
@ -832,7 +817,7 @@ func qqAttachmentKind(attachment *dto.MessageAttachment) string {
}
}
func qqAttachmentNote(attachment *dto.MessageAttachment) string {
func qqAttachmentNote(attachment MessageAttachment) string {
filename := qqAttachmentFilename(attachment)
switch qqAttachmentKind(attachment) {
@ -841,6 +826,9 @@ func qqAttachmentNote(attachment *dto.MessageAttachment) string {
case "audio":
return fmt.Sprintf("[audio: %s]", filename)
case "video":
if attachment.AsrReferText != "" {
return fmt.Sprintf("[video: %s]", attachment.AsrReferText)
}
return fmt.Sprintf("[video: %s]", filename)
default:
return fmt.Sprintf("[file: %s]", filename)
@ -945,10 +933,9 @@ func (c *QQChannel) getReplyExtInfo(ctx context.Context, chatID string) (replyID
return replyID, seq
}
func (c *QQChannel) decodeMessage(ctx context.Context, event *dto.WSPayload, data *dto.Message,
scope string) (content string, mediaPaths []string) {
content = parseEmojiText(data.Content)
func (c *QQChannel) decodeMessage(ctx context.Context, chatID string, event *dto.WSPayload,
data *dto.Message) (string, []string) {
content := parseEmojiText(data.Content)
wavURL, asrReferText := getVoiceInfo(event)
var attachments []MessageAttachment
for _, att := range data.Attachments {
@ -967,63 +954,11 @@ func (c *QQChannel) decodeMessage(ctx context.Context, event *dto.WSPayload, dat
FileName: att.FileName,
})
}
processedPaths, attachmentContent := c.processAttachments(ctx, attachments, scope)
mediaPaths = processedPaths
if content != "" {
content += "\n"
processedPaths, attachmentContents := c.extractInboundAttachments(chatID, data.ID, attachments)
for _, note := range attachmentContents {
content = appendContent(content, note)
}
content += attachmentContent
return content, mediaPaths
}
// processAttachments processes all attachments in a message
func (c *QQChannel) processAttachments(ctx context.Context, attachments []MessageAttachment,
scope string) (mediaPaths []string, content string) {
// 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 err ", map[string]any{
"scope": scope,
"localPath": localPath,
"err": err.Error(),
})
return localPath
}
for _, attachment := range attachments {
attachmentType := c.getAttachmentType(attachment)
ref := attachment.URL
localPath := c.downloadAttachment(attachment.URL, attachment.FileName)
if localPath != "" {
if ref = storeMedia(localPath, attachment.FileName); ref == "" {
ref = localPath
}
}
if attachmentType == "audio" && attachment.AsrReferText != "" {
content = appendContent(content, fmt.Sprintf("[audio: %s]", attachment.AsrReferText))
} else {
content = appendContent(content, fmt.Sprintf("[%v: %s]", attachmentType, ref))
}
mediaPaths = append(mediaPaths, ref)
}
return mediaPaths, content
return content, processedPaths
}
// getAttachmentType determines the type of attachment (image, audio, video, file)

View file

@ -402,7 +402,6 @@ type QQConfig struct {
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty" yaml:"-"`
MaxMessageLength int `json:"max_message_length" yaml:"-" env:"PICOCLAW_CHANNELS_QQ_MAX_MESSAGE_LENGTH"`
MaxBase64FileSizeMiB int64 `json:"max_base64_file_size_mib" yaml:"-" env:"PICOCLAW_CHANNELS_QQ_MAX_BASE64_FILE_SIZE_MIB"`
SendMarkdown bool `json:"send_markdown" yaml:"-" env:"PICOCLAW_CHANNELS_QQ_SEND_MARKDOWN"`
ReasoningChannelID string `json:"reasoning_channel_id" yaml:"-" env:"PICOCLAW_CHANNELS_QQ_REASONING_CHANNEL_ID"`
}

View file

@ -145,7 +145,6 @@ type qqConfigV0 struct {
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"`
}
@ -157,7 +156,6 @@ func (v *qqConfigV0) ToQQConfig() QQConfig {
GroupTrigger: v.GroupTrigger,
MaxMessageLength: v.MaxMessageLength,
MaxBase64FileSizeMiB: v.MaxBase64FileSizeMiB,
SendMarkdown: v.SendMarkdown,
ReasoningChannelID: v.ReasoningChannelID,
AppSecret: *NewSecureString(v.AppSecret),
}