Files
Cyrene/backend/platform-bridge/internal/adapter/qq/adapter.go
T
AskaEth 4954c1e58b 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>
2026-06-06 09:08:03 +08:00

1049 lines
28 KiB
Go

package qq
import (
"context"
"encoding/json"
"fmt"
"html"
"log"
"net/http"
"regexp"
"strings"
"sync"
"time"
"github.com/gorilla/websocket"
"git.yeij.top/AskaEth/Cyrene/platform-bridge/internal/bridge"
)
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
}
// Adapter implements PlatformAdapter for QQ via OneBot v11 WebSocket.
// Supports two modes:
// - "server" (正向 WS): adapter starts a WS server, NapCat connects as client.
// - "client" (反向 WS): adapter connects to NapCat's WS server as a client.
type Adapter struct {
configName string // instance name, e.g. "qq-home"
mode string // "client" or "server"
port string
accessToken string
remoteURL string // NapCat OneBot WS server URL, used in client mode
sendIntervalMs int // minimum interval between consecutive messages
selfID string // bot's own QQ number, populated from incoming messages
conn *websocket.Conn
connMu sync.Mutex
connected bool
connectedAt time.Time // when the connection was established (for historical message detection)
srv *http.Server // HTTP server for WS upgrades (server mode only)
groupNames map[int64]string // group ID → group name cache
groupNamesMu sync.RWMutex
pendingResponses map[string]chan *OBv11APIResponse
respMu sync.Mutex
}
func NewAdapter(configName, mode, port, accessToken, remoteURL string, sendIntervalMs int) *Adapter {
if mode == "" {
mode = "server"
}
return &Adapter{
configName: configName,
mode: mode,
port: port,
accessToken: accessToken,
remoteURL: remoteURL,
sendIntervalMs: sendIntervalMs,
pendingResponses: make(map[string]chan *OBv11APIResponse),
groupNames: make(map[int64]string),
}
}
func (a *Adapter) PlatformName() string { return "qq" }
func (a *Adapter) ConfigName() string { return a.configName }
func (a *Adapter) SendIntervalMs() int { return a.sendIntervalMs }
func (a *Adapter) SelfID() string { return a.selfID }
// ConnectedAt returns the time the connection was established, for historical message detection.
func (a *Adapter) ConnectedAt() time.Time {
a.connMu.Lock()
defer a.connMu.Unlock()
return a.connectedAt
}
// GroupName resolves a group ID to its name. Returns empty string if unknown.
func (a *Adapter) GroupName(groupID int64) string {
a.groupNamesMu.RLock()
n, ok := a.groupNames[groupID]
a.groupNamesMu.RUnlock()
if ok {
return n
}
// Try to resolve via API.
a.fetchGroupName(groupID)
return ""
}
// SetGroupName caches a group name for a group ID.
func (a *Adapter) SetGroupName(groupID int64, name string) {
a.groupNamesMu.Lock()
a.groupNames[groupID] = name
a.groupNamesMu.Unlock()
}
// fetchGroupName tries to resolve a group name via NapCat HTTP API (client mode).
func (a *Adapter) fetchGroupName(groupID int64) {
if a.mode != "client" || a.remoteURL == "" {
return
}
// Derive HTTP base from WS URL: ws://host:port → http://host:port
httpBase := strings.Replace(a.remoteURL, "ws://", "http://", 1)
httpBase = strings.Replace(httpBase, "wss://", "https://", 1)
// Strip path suffix if present
if idx := strings.LastIndex(httpBase, "/"); idx > 8 {
httpBase = httpBase[:idx]
}
go func() {
url := fmt.Sprintf("%s/get_group_info?group_id=%d", httpBase, groupID)
if a.accessToken != "" {
url += "&access_token=" + a.accessToken
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return
}
defer resp.Body.Close()
var result struct {
Data struct {
GroupName string `json:"group_name"`
} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return
}
if result.Data.GroupName != "" {
a.SetGroupName(groupID, result.Data.GroupName)
}
}()
}
func (a *Adapter) Capabilities() bridge.PlatformCapabilities {
return bridge.PlatformCapabilities{
MaxMessageLength: 200,
SupportsMarkdown: true,
SupportsImage: true,
SupportsVoice: false,
SupportsEmoji: true,
SupportsReaction: false,
SupportsTypingHint: false,
RecommendBurstMax: 4,
}
}
// checkAuth 验证 WebSocket 升级请求的 access_token。
// NapCat 通过两种方式传递: query 参数 ?access_token=xxx 或 Authorization: Bearer xxx 头.
func (a *Adapter) checkAuth(r *http.Request) bool {
if a.accessToken == "" {
return true // 未配置 token,允许所有连接
}
// 1) query 参数
if r.URL.Query().Get("access_token") == a.accessToken {
return true
}
// 2) Authorization: Bearer <token>
auth := r.Header.Get("Authorization")
if strings.HasPrefix(auth, "Bearer ") && strings.TrimPrefix(auth, "Bearer ") == a.accessToken {
return true
}
return false
}
// wsHandler 统一的 WebSocket 连接处理 — 单连接承载 API 调用 + 事件推送 (OneBot 正向 WS).
func (a *Adapter) wsHandler(w http.ResponseWriter, r *http.Request) {
if !a.checkAuth(r) {
http.Error(w, "Forbidden: invalid access_token", http.StatusForbidden)
return
}
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
fmt.Printf("[qq] upgrade error: %v\n", err)
return
}
a.connMu.Lock()
// 关闭旧连接 (NapCat 重连)
if a.conn != nil {
a.conn.Close()
}
a.conn = conn
a.connected = true
a.connectedAt = time.Now()
a.connMu.Unlock()
fmt.Println("[qq] NapCat/OneBot connected (正向WS)")
}
// legacyHandler 兼容旧的路径 /ws/qq 和 /ws/qq/event.
func (a *Adapter) legacyHandler(w http.ResponseWriter, r *http.Request) {
fmt.Printf("[qq] legacy WS path %s connected (consider changing NapCat URL to root /)\n", r.URL.Path)
a.wsHandler(w, r)
}
func (a *Adapter) Connect(ctx context.Context) error {
if a.mode == "client" {
return a.connectClient(ctx)
}
return a.connectServer()
}
func (a *Adapter) connectClient(ctx context.Context) error {
url := a.remoteURL
if a.accessToken != "" {
sep := "?"
if strings.Contains(url, "?") {
sep = "&"
}
url += sep + "access_token=" + a.accessToken
}
dialer := websocket.DefaultDialer
conn, _, err := dialer.DialContext(ctx, url, nil)
if err != nil {
return fmt.Errorf("dial NapCat WS %s: %w", url, err)
}
a.connMu.Lock()
if a.conn != nil {
a.conn.Close()
}
a.conn = conn
a.connected = true
a.connectedAt = time.Now()
a.connMu.Unlock()
fmt.Printf("[qq] connected to NapCat OneBot WS (client mode): %s\n", a.remoteURL)
return nil
}
func (a *Adapter) connectServer() error {
mux := http.NewServeMux()
mux.HandleFunc("/", a.wsHandler) // NapCat 正向 WS 标准路径
mux.HandleFunc("/ws/qq", a.legacyHandler) // 向下兼容旧配置
mux.HandleFunc("/ws/qq/event", a.legacyHandler) // 向下兼容旧配置
addr := ":" + a.port
a.srv = &http.Server{Addr: addr, Handler: mux}
go func() {
fmt.Printf("[qq] WebSocket server on %s (waiting for NapCat forward WS connection)\n", addr)
if a.accessToken != "" {
fmt.Println("[qq] access_token 已配置,将验证连接请求")
}
if err := a.srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
fmt.Printf("[qq] server error: %v\n", err)
}
}()
return nil
}
func (a *Adapter) Disconnect(ctx context.Context) error {
a.connMu.Lock()
if a.conn != nil {
a.conn.Close()
a.conn = nil
}
a.connected = false
srv := a.srv
a.srv = nil
a.connMu.Unlock()
if srv != nil {
shutdownCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
return fmt.Errorf("qq server shutdown: %w", err)
}
}
return nil
}
func (a *Adapter) IsConnected() bool {
a.connMu.Lock()
defer a.connMu.Unlock()
return a.connected
}
func (a *Adapter) HealthCheck() error {
if !a.IsConnected() {
return fmt.Errorf("QQ bot not connected")
}
return nil
}
// ToUnified converts an OBv11 message to UnifiedMessage.
func (a *Adapter) ToUnified(rawMessage interface{}) (*bridge.UnifiedMessage, error) {
msg, ok := rawMessage.(*OBv11Message)
if !ok {
return nil, fmt.Errorf("expected *OBv11Message, got %T", rawMessage)
}
content := extractText(msg)
senderID := ""
senderName := "unknown"
channelType := "direct"
channelID := ""
switch msg.MessageType {
case "private":
senderID = fmt.Sprintf("%d", msg.UserID)
channelType = "direct"
channelID = fmt.Sprintf("private_%d", msg.UserID)
if msg.Sender != nil {
senderName = msg.Sender.Nickname
}
case "group":
senderID = fmt.Sprintf("%d", msg.UserID)
channelType = "group"
channelID = fmt.Sprintf("%d", msg.GroupID)
if msg.Sender != nil {
if msg.Sender.Card != "" {
senderName = msg.Sender.Card
} else {
senderName = msg.Sender.Nickname
}
}
}
var mentions []string
var replyToText string
if segments, ok := msg.Message.([]interface{}); ok {
for _, s := range segments {
if seg, ok := s.(map[string]interface{}); ok {
if seg["type"] == "at" {
if data, ok := seg["data"].(map[string]interface{}); ok {
if qq, ok := data["qq"].(string); ok {
mentions = append(mentions, qq)
}
}
}
if seg["type"] == "reply" {
if data, ok := seg["data"].(map[string]interface{}); ok {
if t, ok := data["text"].(string); ok && t != "" {
replyToText = t
}
if id, ok := data["id"]; ok {
_ = id // message ID of the replied-to message
}
}
}
}
}
}
// Prepend reply context for the AI.
if replyToText != "" {
content = "【回复】" + truncateForReply(replyToText, 100) + "\n" + content
}
// Fallback: parse CQ at codes from string format (e.g. [CQ:at,qq=2254389756]).
if len(mentions) == 0 {
raw := msg.RawMessage
if raw == "" {
if s, ok := msg.Message.(string); ok {
raw = s
}
}
for _, m := range cqAtRegex.FindAllStringSubmatch(raw, -1) {
if len(m) >= 2 {
mentions = append(mentions, m[1])
}
}
}
// Resolve group name for group messages.
groupName := ""
if msg.MessageType == "group" {
groupName = a.GroupName(msg.GroupID)
}
attachments := extractAttachments(msg)
attachments = append(attachments, extractCardImageURLs(msg)...)
return &bridge.UnifiedMessage{
SenderID: senderID,
SenderName: senderName,
Platform: "qq",
ChannelID: channelID,
ChannelType: channelType,
Content: content,
ContentType: "text",
MessageID: fmt.Sprintf("%d", msg.MessageID),
Mentions: mentions,
Attachments: attachments,
RawData: rawMessage,
Timestamp: time.Unix(msg.Time, 0),
GroupName: groupName,
}, nil
}
// FromUnified converts a unified response to QQ platform messages.
func (a *Adapter) FromUnified(response *bridge.UnifiedResponse) ([]bridge.PlatformMessage, error) {
var msgs []bridge.PlatformMessage
for _, rm := range response.Messages {
content := ConvertMarkdownToQQ(rm.Content)
runes := []rune(content)
if len(runes) > 200 {
content = string(runes[:200])
}
msgs = append(msgs, bridge.PlatformMessage{
Content: content,
ReplyTo: response.ReplyTo,
FormatMode: "text",
})
}
return msgs, nil
}
// SendMessage sends a message through the QQ WebSocket connection.
func (a *Adapter) SendMessage(msgType string, userID, groupID int64, content string) error {
a.connMu.Lock()
conn := a.conn
a.connMu.Unlock()
if conn == nil {
return fmt.Errorf("QQ bot not connected")
}
req := OBv11SendMsg{
Action: "send_msg",
Params: OBv11Params{
MessageType: msgType,
UserID: userID,
GroupID: groupID,
Message: content,
},
Echo: fmt.Sprintf("echo_%d", time.Now().UnixNano()),
}
data, _ := json.Marshal(req)
return conn.WriteMessage(websocket.TextMessage, data)
}
// ReadMessages reads OBv11 messages from the WebSocket connection.
func (a *Adapter) ReadMessages(ctx context.Context, msgCh chan<- *OBv11Message) {
for {
select {
case <-ctx.Done():
return
default:
}
a.connMu.Lock()
conn := a.conn
a.connMu.Unlock()
if conn == nil {
// Client mode: auto-reconnect when connection is lost.
if a.mode == "client" {
if err := a.connectClient(ctx); err != nil {
fmt.Printf("[qq] reconnect failed: %v, retrying in 3s...\n", err)
time.Sleep(3 * time.Second)
}
continue
}
time.Sleep(time.Second)
continue
}
_, raw, err := conn.ReadMessage()
if err != nil {
fmt.Printf("[qq] read error: %v\n", err)
a.connMu.Lock()
if a.conn != nil {
a.conn.Close()
a.conn = nil
}
a.connected = false
a.connMu.Unlock()
time.Sleep(time.Second)
continue
}
// Try to parse as OBv11 message (event from QQ).
var msg OBv11Message
if err := json.Unmarshal(raw, &msg); err != nil {
var resp OBv11APIResponse
if err := json.Unmarshal(raw, &resp); err != nil {
fmt.Printf("[qq] unknown message: %s\n", string(raw))
continue
}
if resp.Echo != "" {
a.respMu.Lock()
if ch, ok := a.pendingResponses[resp.Echo]; ok {
ch <- &resp
delete(a.pendingResponses, resp.Echo)
}
a.respMu.Unlock()
}
continue
}
// Capture bot's own QQ number from incoming messages.
if msg.SelfID != 0 && a.selfID == "" {
a.selfID = fmt.Sprintf("%d", msg.SelfID)
fmt.Printf("[qq:%s] self ID captured: %s\n", a.configName, a.selfID)
}
if msg.PostType == "message" {
select {
case msgCh <- &msg:
case <-ctx.Done():
return
}
}
}
}
// parseJSONCardTitle extracts human-readable content from a QQ JSON card.
func parseJSONCardTitle(data string) string {
var card struct {
App string `json:"app"`
Prompt string `json:"prompt"`
Title string `json:"title"`
Desc string `json:"desc"`
Meta struct {
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"`
Tag string `json:"tag"`
Preview string `json:"preview"`
} `json:"news"`
Music struct {
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 "[卡片消息]"
}
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 s, _ := raw["prompt"].(string); s != "" {
push(label + " " + s)
} else if s, _ := raw["title"].(string); s != "" {
push(label + " " + s)
}
// 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 s, _ := raw["desc"].(string); s != "" && len(parts) < 2 {
push("简介:" + s)
}
if len(parts) == 0 {
log.Printf("[qq] 卡片解析失败,原始JSON: %s", data)
return "[卡片消息]"
}
return strings.Join(parts, "\n")
}
// cqSimplifyMap maps CQ code types to simplified Chinese labels.
var cqSimplifyMap = map[string]string{
"image": "[图片]",
"reply": "[回复]",
"face": "[表情]",
"record": "[语音]",
"video": "[视频]",
"file": "[文件]",
}
// simplifyCQCodes replaces [CQ:type,...] codes with human-readable labels.
func simplifyCQCodes(s string) string {
return cqAllRegex.ReplaceAllStringFunc(s, func(match string) string {
// match looks like "[CQ:image,file=xxx,url=xxx]"
// Extract the type (text between "CQ:" and the first "," or "]").
typ := match[4:] // strip "[CQ:"
for i, c := range typ {
if c == ',' || c == ']' {
typ = typ[:i]
break
}
}
if typ == "json" {
// Parse data= field from [CQ:json,data=HTML_ENCODED_JSON]
if dataVal := extractCQParam(match, "data"); dataVal != "" {
decoded := html.UnescapeString(dataVal)
return parseJSONCardTitle(decoded)
}
return "[卡片消息]"
}
if label, ok := cqSimplifyMap[typ]; ok {
return label
}
return "[" + typ + "]"
})
}
// extractCQParam extracts a named parameter value from a CQ code string.
// e.g. extractCQParam("[CQ:json,data=hello%20world]", "data") → "hello%20world"
func extractCQParam(cqCode, paramName string) string {
prefix := paramName + "="
idx := strings.Index(cqCode, prefix)
if idx < 0 {
return ""
}
val := cqCode[idx+len(prefix):]
// Value ends at "," or "]"
for i, c := range val {
if c == ',' || c == ']' {
return val[:i]
}
}
return val
}
// extractText retrieves plain text from an OBv11 message.
// CQ codes are converted to human-readable form where applicable (e.g. [CQ:at,qq=xxx] → @xxx).
func extractText(msg *OBv11Message) string {
if msg.RawMessage != "" {
s := cqAtRegex.ReplaceAllString(msg.RawMessage, "@$1")
return simplifyCQCodes(s)
}
switch m := msg.Message.(type) {
case string:
s := cqAtRegex.ReplaceAllString(m, "@$1")
return simplifyCQCodes(s)
case []interface{}:
var text string
for _, seg := range m {
if s, ok := seg.(map[string]interface{}); ok {
switch s["type"] {
case "text":
if data, ok := s["data"].(map[string]interface{}); ok {
if t, ok := data["text"].(string); ok {
text += t
}
}
case "at":
if data, ok := s["data"].(map[string]interface{}); ok {
if qq, ok := data["qq"].(string); ok {
text += "@" + qq
}
}
case "image":
text += "[图片]"
case "face":
text += "[表情]"
case "record":
text += "[语音]"
case "video":
text += "[视频]"
case "file":
text += "[文件]"
case "reply":
// Reply is handled separately in ToUnified with reply text.
text += "[回复]"
case "json", "card":
if data, ok := s["data"].(map[string]interface{}); ok {
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 {
text += "[卡片消息]"
}
}
}
}
return text
}
return ""
}
var cqAtRegex = regexp.MustCompile(`\[CQ:at,qq=(\d+)\]`)
var cqImageRegex = regexp.MustCompile(`\[CQ:image,[^\]]*\]`)
var cqVideoRegex = regexp.MustCompile(`\[CQ:video,[^\]]*\]`)
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(`~~(.+?)~~`)
func parseIntOr(s string, defaultVal int) int {
if s == "" {
return defaultVal
}
n := 0
for _, c := range s {
if c >= '0' && c <= '9' {
n = n*10 + int(c-'0')
} else {
return defaultVal
}
}
return n
}
// truncateForReply truncates reply preview text to keep messages readable.
func truncateForReply(s string, maxLen int) string {
runes := []rune(s)
if len(runes) <= maxLen {
return s
}
return string(runes[:maxLen]) + "…"
}
// extractAttachments extracts image/video URLs from OBv11Message.
// Handles both string format (CQ codes in raw_message) and array format (parsed segments).
func extractAttachments(msg *OBv11Message) []bridge.Attachment {
var attachments []bridge.Attachment
// Array format: iterate segments looking for image and video.
if segments, ok := msg.Message.([]interface{}); ok {
for _, s := range segments {
if seg, ok := s.(map[string]interface{}); ok {
segType, _ := seg["type"].(string)
if segType != "image" && segType != "video" && segType != "record" {
continue
}
data, _ := seg["data"].(map[string]interface{})
if data == nil {
continue
}
url, _ := data["url"].(string)
file, _ := data["file"].(string)
if url == "" {
continue
}
att := bridge.Attachment{
Type: segType,
URL: url,
FileName: file,
}
if segType == "video" {
if d, ok := data["duration"].(float64); ok {
att.Duration = int(d)
}
}
attachments = append(attachments, att)
}
}
return attachments
}
// String format: parse CQ codes from RawMessage or string Message.
raw := msg.RawMessage
if raw == "" {
if s, ok := msg.Message.(string); ok {
raw = s
}
}
// Images.
for _, m := range cqImageRegex.FindAllString(raw, -1) {
urlMatch := cqURLRegex.FindStringSubmatch(m)
if len(urlMatch) >= 2 {
attachments = append(attachments, bridge.Attachment{Type: "image", URL: urlMatch[1]})
}
}
// Videos.
for _, m := range cqVideoRegex.FindAllString(raw, -1) {
urlMatch := cqURLRegex.FindStringSubmatch(m)
if len(urlMatch) >= 2 {
dur := 0
if dm := cqDurationRegex.FindStringSubmatch(m); len(dm) >= 2 {
dur = parseIntOr(dm[1], 0)
}
attachments = append(attachments, bridge.Attachment{Type: "video", URL: urlMatch[1], Duration: dur})
}
}
// Records (voice messages).
for _, m := range cqRecordRegex.FindAllString(raw, -1) {
urlMatch := cqURLRegex.FindStringSubmatch(m)
if len(urlMatch) >= 2 {
attachments = append(attachments, bridge.Attachment{Type: "voice", URL: urlMatch[1]})
}
}
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")
md = italicRegex.ReplaceAllString(md, "$1")
md = strikethroughRegex.ReplaceAllString(md, "$1")
md = removeHeadings(md)
md = removeCodeBlocks(md)
return md
}
func removeHeadings(s string) string {
for _, line := range splitLines(s) {
if len(line) > 0 && line[0] == '#' {
s = replaceLine(s, line, stripPrefix(line, "# "))
}
}
return s
}
func removeCodeBlocks(s string) string {
var kept []string
inCode := false
for _, line := range splitLines(s) {
if hasPrefix(line, "```") {
inCode = !inCode
continue
}
kept = append(kept, line)
}
_ = inCode
return strings.Join(kept, "\n")
}
func splitLines(s string) []string {
var lines []string
start := 0
for i, c := range s {
if c == '\n' {
lines = append(lines, s[start:i])
start = i + 1
}
}
if start < len(s) {
lines = append(lines, s[start:])
}
return lines
}
func hasPrefix(s, prefix string) bool {
return len(s) >= len(prefix) && s[:len(prefix)] == prefix
}
func stripPrefix(s, prefix string) string {
if len(s) >= len(prefix) && s[:len(prefix)] == prefix {
return s[len(prefix):]
}
return s
}
func replaceLine(s, old, new string) string {
idx := indexOf(s, old)
if idx < 0 {
return s
}
return s[:idx] + new + s[idx+len(old):]
}
func indexOf(s, sub string) int {
for i := 0; i <= len(s)-len(sub); i++ {
if s[i:i+len(sub)] == sub {
return i
}
}
return -1
}