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 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 }