feat: 消息并行处理 + QQ卡片完整解析 + 视觉OCR融合格式修复

- platform-bridge: 8-worker per-session 并行分发,同会话保序跨会话并行
- platform-bridge: 静默消息 fire-and-forget,不阻塞同用户后续消息
- QQ卡片: html.UnescapeString 解码 NapCat HTML实体,正确解析卡片JSON
- QQ卡片: 输出含应用名/简介/来源/封面URL,封面注入图片管线走视觉
- ai-core: 视觉+OCR结果融合为单句,单图不编号,避免LLM误解为多张图

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-06 09:08:03 +08:00
parent 67b204b23c
commit 4954c1e58b
3 changed files with 354 additions and 105 deletions
@@ -4,8 +4,9 @@ import (
"context"
"encoding/json"
"fmt"
"html"
"log"
"net/http"
"net/url"
"regexp"
"strings"
"sync"
@@ -373,6 +374,7 @@ func (a *Adapter) ToUnified(rawMessage interface{}) (*bridge.UnifiedMessage, err
}
attachments := extractAttachments(msg)
attachments = append(attachments, extractCardImageURLs(msg)...)
return &bridge.UnifiedMessage{
SenderID: senderID,
@@ -507,8 +509,7 @@ func (a *Adapter) ReadMessages(ctx context.Context, msgCh chan<- *OBv11Message)
}
}
// parseJSONCardTitle extracts a human-readable title from a QQ JSON card.
// The data is the raw JSON string from the "data" field of a json-type message segment.
// parseJSONCardTitle extracts human-readable content from a QQ JSON card.
func parseJSONCardTitle(data string) string {
var card struct {
App string `json:"app"`
@@ -519,41 +520,155 @@ func parseJSONCardTitle(data string) string {
Detail1 struct {
Title string `json:"title"`
Desc string `json:"desc"`
Tag string `json:"tag"`
} `json:"detail_1"`
News struct {
Title string `json:"title"`
Desc string `json:"desc"`
Title string `json:"title"`
Desc string `json:"desc"`
Tag string `json:"tag"`
Preview string `json:"preview"`
} `json:"news"`
Music struct {
Title string `json:"title"`
Desc string `json:"desc"`
Title string `json:"title"`
Desc string `json:"desc"`
Tag string `json:"tag"`
Preview string `json:"preview"`
} `json:"music"`
} `json:"meta"`
}
if err := json.Unmarshal([]byte(data), &card); err != nil {
return parseJSONCardFallback(data)
}
var parts []string
push := func(s string) { if s != "" { parts = append(parts, s) } }
// Build header: [音乐分享] or [卡片]
label := cardAppLabel(card.App)
if card.Prompt != "" {
// Strip redundant QQ-specific prefix from prompt (e.g. "[QQ小程序]" when we already say "[小程序]").
prompt := card.Prompt
for _, pfx := range qqPromptPrefixes {
if strings.HasPrefix(prompt, pfx) {
prompt = strings.TrimPrefix(prompt, pfx)
break
}
}
push(label + " " + prompt)
} else if t := firstNonEmpty(card.Meta.Detail1.Title, card.Meta.News.Title, card.Meta.Music.Title, card.Title); t != "" {
push(label + " " + t)
}
// Append description and source tag if available.
desc := firstNonEmpty(card.Meta.Detail1.Desc, card.Meta.Music.Desc, card.Meta.News.Desc, card.Desc)
if desc != "" && desc != card.Prompt && desc != card.Title &&
(len(parts) == 0 || !strings.Contains(parts[0], desc)) {
push("简介:" + desc)
}
tag := firstNonEmpty(card.Meta.Music.Tag, card.Meta.News.Tag, card.Meta.Detail1.Tag)
if tag != "" && (len(parts) == 0 || !strings.Contains(parts[len(parts)-1], tag)) {
push("来源:" + tag)
}
preview := firstNonEmpty(card.Meta.Music.Preview, card.Meta.News.Preview)
if preview != "" {
push("封面:" + preview)
}
if len(parts) == 0 {
return parseJSONCardFallback(data)
}
return strings.Join(parts, "\n")
}
func firstNonEmpty(ss ...string) string {
for _, s := range ss {
if s != "" { return s }
}
return ""
}
// cardAppLabel maps QQ card app IDs to human-readable Chinese labels.
func cardAppLabel(app string) string {
if label, ok := cardAppMap[app]; ok {
return "[" + label + "]"
}
return "[卡片]"
}
var cardAppMap = map[string]string{
"com.tencent.music.lua": "音乐分享",
"com.tencent.structmsg": "结构化消息",
"com.tencent.qun.pro": "群Pro卡片",
"com.tencent.miniapp_01": "小程序",
"com.tencent.contact.lua": "联系人分享",
"com.tencent.groupphoto": "群相册",
"com.tencent.qqfiles": "文件分享",
"com.tencent.announcement": "群公告",
"com.tencent.mobileqq.ark": "ARK消息",
"com.tencent.tuwen.lua": "图文消息",
}
// QQ card prompts often include a self-describing prefix that overlaps with our label.
var qqPromptPrefixes = []string{
"[QQ小程序] ", "[QQ小程序]",
"[QQ音乐] ", "[QQ音乐]",
}
// parseJSONCardFallback tries a generic approach for unknown card structures.
func parseJSONCardFallback(data string) string {
var raw map[string]interface{}
if err := json.Unmarshal([]byte(data), &raw); err != nil {
return "[卡片消息]"
}
// Prefer prompt (e.g. "[分享]标题"), then meta titles, then top-level title.
if card.Prompt != "" {
return "[卡片] " + card.Prompt
var parts []string
push := func(s string) { if s != "" { parts = append(parts, s) } }
label := "[卡片]"
if app, ok := raw["app"].(string); ok {
label = cardAppLabel(app)
}
if card.Meta.Detail1.Title != "" {
return "[卡片] " + card.Meta.Detail1.Title
if s, _ := raw["prompt"].(string); s != "" {
push(label + " " + s)
} else if s, _ := raw["title"].(string); s != "" {
push(label + " " + s)
}
if card.Meta.News.Title != "" {
return "[卡片] " + card.Meta.News.Title
// Search meta for title and desc.
if meta, ok := raw["meta"].(map[string]interface{}); ok {
for _, v := range meta {
if sub, ok := v.(map[string]interface{}); ok {
if s, _ := sub["title"].(string); s != "" && len(parts) == 0 {
push(label + " " + s)
}
}
}
for _, v := range meta {
if sub, ok := v.(map[string]interface{}); ok {
if s, _ := sub["desc"].(string); s != "" {
push("简介:" + s)
break
}
}
}
for _, v := range meta {
if sub, ok := v.(map[string]interface{}); ok {
if s, _ := sub["tag"].(string); s != "" {
push("来源:" + s)
break
}
}
}
}
if card.Meta.Music.Title != "" {
return "[卡片] " + card.Meta.Music.Title
if s, _ := raw["desc"].(string); s != "" && len(parts) < 2 {
push("简介:" + s)
}
if card.Title != "" {
return "[卡片] " + card.Title
if len(parts) == 0 {
log.Printf("[qq] 卡片解析失败,原始JSON: %s", data)
return "[卡片消息]"
}
if card.Desc != "" {
return "[卡片] " + card.Desc
}
return "[卡片消息]"
return strings.Join(parts, "\n")
}
// cqSimplifyMap maps CQ code types to simplified Chinese labels.
@@ -579,11 +694,10 @@ func simplifyCQCodes(s string) string {
}
}
if typ == "json" {
// Parse data= field from [CQ:json,data=URL_ENCODED_JSON]
// Parse data= field from [CQ:json,data=HTML_ENCODED_JSON]
if dataVal := extractCQParam(match, "data"); dataVal != "" {
if decoded, err := url.QueryUnescape(dataVal); err == nil {
return parseJSONCardTitle(decoded)
}
decoded := html.UnescapeString(dataVal)
return parseJSONCardTitle(decoded)
}
return "[卡片消息]"
}
@@ -653,11 +767,24 @@ func extractText(msg *OBv11Message) string {
case "reply":
// Reply is handled separately in ToUnified with reply text.
text += "[回复]"
case "json":
case "json", "card":
if data, ok := s["data"].(map[string]interface{}); ok {
if inner, ok := data["data"].(string); ok && inner != "" {
text += parseJSONCardTitle(inner)
} else {
inner := data["data"]
switch v := inner.(type) {
case string:
if v != "" {
text += parseJSONCardTitle(v)
} else {
text += "[卡片消息]"
}
case map[string]interface{}:
// Already parsed — re-marshal to JSON string for parsing.
if b, err := json.Marshal(v); err == nil {
text += parseJSONCardTitle(string(b))
} else {
text += "[卡片消息]"
}
default:
text += "[卡片消息]"
}
} else {
@@ -678,6 +805,7 @@ var cqRecordRegex = regexp.MustCompile(`\[CQ:record,[^\]]*\]`)
var cqURLRegex = regexp.MustCompile(`\burl=([^,\]]+)`)
var cqDurationRegex = regexp.MustCompile(`\bduration=(\d+)`)
var cqAllRegex = regexp.MustCompile(`\[CQ:[^\]]+\]`)
var cqJSONRegex = regexp.MustCompile(`\[CQ:(?:json|card),[^\]]*data=[^\]]*\]`)
var boldRegex = regexp.MustCompile(`\*\*(.+?)\*\*`)
var italicRegex = regexp.MustCompile(`\*(.+?)\*`)
var strikethroughRegex = regexp.MustCompile(`~~(.+?)~~`)
@@ -779,6 +907,70 @@ func extractAttachments(msg *OBv11Message) []bridge.Attachment {
return attachments
}
// extractCardImageURLs finds json/card CQ codes in the raw message and extracts
// preview/cover image URLs so they can be fed to the vision pipeline.
func extractCardImageURLs(msg *OBv11Message) []bridge.Attachment {
raw := msg.RawMessage
if raw == "" {
if s, ok := msg.Message.(string); ok {
raw = s
}
}
// Match [CQ:json,...] or [CQ:card,...]
matches := cqJSONRegex.FindAllString(raw, -1)
if len(matches) == 0 {
return nil
}
var attachments []bridge.Attachment
seen := map[string]bool{}
for _, m := range matches {
dataVal := extractCQParam(m, "data")
if dataVal == "" {
continue
}
decoded := html.UnescapeString(dataVal)
urls := extractPreviewURLs(decoded)
for _, u := range urls {
if !seen[u] {
seen[u] = true
attachments = append(attachments, bridge.Attachment{Type: "image", URL: u})
}
}
}
return attachments
}
// extractPreviewURLs parses a QQ card JSON and returns any preview/cover image URLs.
func extractPreviewURLs(data string) []string {
var card struct {
Meta struct {
Music struct {
Preview string `json:"preview"`
} `json:"music"`
News struct {
Preview string `json:"preview"`
} `json:"news"`
} `json:"meta"`
// Some card types have preview at top level or in other locations.
Preview string `json:"preview"`
}
if err := json.Unmarshal([]byte(data), &card); err != nil {
return nil
}
var urls []string
if card.Meta.Music.Preview != "" {
urls = append(urls, card.Meta.Music.Preview)
}
if card.Meta.News.Preview != "" {
urls = append(urls, card.Meta.News.Preview)
}
if card.Preview != "" {
urls = append(urls, card.Preview)
}
return urls
}
// ConvertMarkdownToQQ converts markdown to QQ plain-text format.
func ConvertMarkdownToQQ(md string) string {
md = boldRegex.ReplaceAllString(md, "$1")