From c88d46a9731787a726db6ca2ffca6809af862e55 Mon Sep 17 00:00:00 2001 From: chenhg5 Date: Mon, 2 Mar 2026 11:50:48 +0800 Subject: [PATCH] feat(qq): add QQ platform support via OneBot v11/NapCat generated by llmgit Co-Authored-By: Claude --- INSTALL.md | 26 ++- README.md | 12 +- README.zh-CN.md | 12 +- cmd/cc-connect/main.go | 1 + config.example.toml | 23 +++ docs/qq.md | 129 ++++++++++++ go.mod | 2 +- platform/qq/qq.go | 441 +++++++++++++++++++++++++++++++++++++++++ 8 files changed, 642 insertions(+), 4 deletions(-) create mode 100644 docs/qq.md create mode 100644 platform/qq/qq.go diff --git a/INSTALL.md b/INSTALL.md index 69bec80cb..7cbd1fb42 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -4,7 +4,7 @@ ## What is cc-connect? -cc-connect bridges local AI coding assistants to messaging platforms (Feishu, DingTalk, Telegram, Slack, Discord, LINE, WeChat Work), enabling bidirectional chat with your local AI agent from anywhere. +cc-connect bridges local AI coding assistants to messaging platforms (Feishu, DingTalk, Telegram, Slack, Discord, LINE, WeChat Work, QQ), enabling bidirectional chat with your local AI agent from anywhere. GitHub: https://github.com/chenhg5/cc-connect @@ -319,6 +319,30 @@ enable_markdown = false # true = Markdown messages (WeChat Work app only; perso **Detailed guide:** [docs/wecom.md](docs/wecom.md) +### QQ (via NapCat / OneBot v11) — No public IP needed `Beta` + +QQ integration requires a third-party OneBot v11 implementation (e.g., NapCat) as a bridge. + +1. Deploy NapCat (recommended via Docker): + ```bash + docker run -d --name napcat -e ACCOUNT= -p 3001:3001 -p 6099:6099 --restart unless-stopped mlikiowa/napcat-docker:latest + ``` +2. First launch: check `docker logs -f napcat` for a QR code, scan with QQ mobile app to log in +3. Open NapCat WebUI at `http://localhost:6099`, enable **Forward WebSocket** on port 3001 +4. Add to `config.toml`: + +```toml +[[projects.platforms]] +type = "qq" + +[projects.platforms.options] +ws_url = "ws://127.0.0.1:3001" # NapCat Forward WebSocket URL +token = "" # optional: access_token (must match NapCat config) +allow_from = "*" # allowed QQ user IDs: "12345,67890" or "*" for all +``` + +**Detailed guide:** [docs/qq.md](docs/qq.md) + --- ## Step 5: Run cc-connect diff --git a/README.md b/README.md index 6c09bad90..8735973b4 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ All components are decoupled via Go interfaces — fully pluggable and extensibl | Platform | Discord | ✅ Gateway — no public IP needed | | Platform | LINE | ✅ Webhook — public URL required | | Platform | WeChat Work (企业微信) | ✅ Webhook — public URL required | +| Platform | QQ (via NapCat/OneBot) | ✅ Beta — WebSocket, no public IP needed | | Platform | WhatsApp | 🔜 Planned (Business Cloud API) | | Platform | Microsoft Teams | 🔜 Planned (Bot Framework) | | Platform | Google Chat | 🔜 Planned (Chat API) | @@ -152,6 +153,7 @@ Each platform requires creating a bot/app on the platform's developer console. W | Discord | [docs/discord.md](docs/discord.md) | Gateway | No | | LINE | [INSTALL.md](./INSTALL.md#line--requires-public-url) | Webhook | Yes | | WeChat Work | [docs/wecom.md](docs/wecom.md) | Webhook | Yes | +| QQ (NapCat) | [docs/qq.md](docs/qq.md) | WebSocket (OneBot v11) | No | Quick config examples for each platform: @@ -208,6 +210,13 @@ callback_token = "xxx" callback_aes_key = "xxx" port = "8081" enable_markdown = false # true only if all users use WeChat Work app (not personal WeChat) + +# QQ (via NapCat/OneBot v11, no public IP needed) +[[projects.platforms]] +type = "qq" +[projects.platforms.options] +ws_url = "ws://127.0.0.1:3001" +allow_from = "*" # QQ user IDs, e.g. "12345,67890" or "*" for all ``` ## Permission Modes @@ -490,7 +499,8 @@ cc-connect/ │ ├── slack/ # Slack (Socket Mode) │ ├── discord/ # Discord (Gateway WebSocket) │ ├── line/ # LINE (HTTP Webhook) -│ └── wecom/ # WeChat Work (HTTP Webhook) +│ ├── wecom/ # WeChat Work (HTTP Webhook) +│ └── qq/ # QQ (NapCat / OneBot v11 WebSocket) ├── agent/ # Agent adapters │ ├── claudecode/ # Claude Code CLI (interactive sessions) │ ├── codex/ # OpenAI Codex CLI (exec --json) diff --git a/README.zh-CN.md b/README.zh-CN.md index 04074d2da..5e3552561 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -52,6 +52,7 @@ | Platform | Discord | ✅ Gateway — 无需公网 IP | | Platform | LINE | ✅ Webhook — 需要公网 URL | | Platform | 企业微信 (WeChat Work) | ✅ Webhook — 需要公网 URL | +| Platform | QQ (通过 NapCat/OneBot) | ✅ Beta — WebSocket,无需公网 IP | | Platform | WhatsApp | 🔜 计划中 (Business Cloud API) | | Platform | Microsoft Teams | 🔜 计划中 (Bot Framework) | | Platform | Google Chat | 🔜 计划中 (Chat API) | @@ -155,6 +156,7 @@ cc-connect update --pre # 内测版(含 pre-release) | Discord | [docs/discord.md](docs/discord.md) | Gateway | 不需要 | | LINE | [INSTALL.md](./INSTALL.md#line--requires-public-url) | Webhook | 需要 | | 企业微信 | [docs/wecom.md](docs/wecom.md) | Webhook | 需要 | +| QQ (NapCat) | [docs/qq.md](docs/qq.md) | WebSocket (OneBot v11) | 不需要 | 各平台快速配置示例: @@ -211,6 +213,13 @@ callback_token = "xxx" callback_aes_key = "xxx" port = "8081" enable_markdown = false # 设为 true 则发送 Markdown 消息(仅企业微信应用内可渲染,个人微信显示"暂不支持") + +# QQ(通过 NapCat/OneBot v11,无需公网 IP) +[[projects.platforms]] +type = "qq" +[projects.platforms.options] +ws_url = "ws://127.0.0.1:3001" +allow_from = "*" # 允许的 QQ 号,如 "12345,67890","*" 表示所有 ``` ## 权限模式 @@ -493,7 +502,8 @@ cc-connect/ │ ├── slack/ # Slack(Socket Mode) │ ├── discord/ # Discord(Gateway WebSocket) │ ├── line/ # LINE(HTTP Webhook) -│ └── wecom/ # 企业微信(HTTP Webhook) +│ ├── wecom/ # 企业微信(HTTP Webhook) +│ └── qq/ # QQ(NapCat / OneBot v11 WebSocket) ├── agent/ # AI 助手适配器 │ ├── claudecode/ # Claude Code CLI(交互式会话) │ ├── codex/ # OpenAI Codex CLI(exec --json) diff --git a/cmd/cc-connect/main.go b/cmd/cc-connect/main.go index d8d1d036b..a22a087a7 100644 --- a/cmd/cc-connect/main.go +++ b/cmd/cc-connect/main.go @@ -26,6 +26,7 @@ import ( _ "github.com/chenhg5/cc-connect/platform/line" _ "github.com/chenhg5/cc-connect/platform/slack" _ "github.com/chenhg5/cc-connect/platform/telegram" + _ "github.com/chenhg5/cc-connect/platform/qq" _ "github.com/chenhg5/cc-connect/platform/wecom" ) diff --git a/config.example.toml b/config.example.toml index 696b511ff..b006aa946 100644 --- a/config.example.toml +++ b/config.example.toml @@ -367,6 +367,29 @@ app_secret = "your-feishu-app-secret" # [projects.platforms.options] # token = "your-telegram-bot-token" +# QQ (via NapCat / OneBot v11) (uncomment to enable / 取消注释以启用) +# Requires a OneBot v11 implementation running alongside your QQ client: +# 需要一个 OneBot v11 实现配合 QQ 客户端运行: +# - NapCat (recommended / 推荐): https://github.com/NapNeko/NapCatQQ +# - Docker: docker run -d --name napcat -e ACCOUNT= -p 3001:3001 -p 6099:6099 mlikiowa/napcat-docker:latest +# - Enable Forward WebSocket in NapCat WebUI (http://localhost:6099), default port 3001 +# 在 NapCat WebUI 中启用正向 WebSocket,默认端口 3001 +# - LLOneBot: https://github.com/LLOneBot/LLOneBot (NTQQ plugin / NTQQ 插件) +# +# allow_from: comma-separated QQ user IDs that are allowed to interact, or "*" for all. +# 允许交互的 QQ 号列表(逗号分隔),设为 "*" 允许所有人。 +# token: optional access_token for WebSocket authentication (must match NapCat config). +# 可选的 WebSocket 鉴权 token(需与 NapCat 配置一致)。 + +# [[projects.platforms]] +# type = "qq" +# +# [projects.platforms.options] +# ws_url = "ws://127.0.0.1:3001" # NapCat Forward WebSocket URL +# token = "" # optional: access_token / 可选鉴权 token +# allow_from = "*" # allowed QQ user IDs, e.g. "12345,67890" or "*" for all +# # 允许的 QQ 号,如 "12345,67890","*" 表示所有 + # ============================================================================= # Project 5: Another example / 项目 5:更多示例 (uncomment to enable / 取消注释以启用) # ============================================================================= diff --git a/docs/qq.md b/docs/qq.md new file mode 100644 index 000000000..1436634d9 --- /dev/null +++ b/docs/qq.md @@ -0,0 +1,129 @@ +# QQ 平台接入指南 / QQ Platform Setup Guide + +cc-connect 通过 [OneBot v11](https://github.com/botuniverse/onebot-11) 协议连接 QQ,需要搭配一个 OneBot 实现(如 NapCat)使用。 + +cc-connect connects to QQ via the [OneBot v11](https://github.com/botuniverse/onebot-11) protocol. You need a OneBot implementation (e.g., NapCat) running alongside. + +## 架构 / Architecture + +``` +QQ Client ←→ NapCat (OneBot v11) ←WebSocket→ cc-connect ←→ Agent (Claude Code / etc.) +``` + +## 前置条件 / Prerequisites + +- 一个 QQ 账号用作机器人 / A QQ account to act as the bot +- [NapCat](https://github.com/NapNeko/NapCatQQ) 或其他 OneBot v11 实现 / NapCat or another OneBot v11 implementation + +## 步骤 / Steps + +### 1. 部署 NapCat / Deploy NapCat + +推荐使用 Docker(最简单)/ Docker is recommended (easiest): + +```bash +docker run -d \ + --name napcat \ + -e ACCOUNT=<你的QQ号> \ + -p 3001:3001 \ + -p 6099:6099 \ + mlikiowa/napcat-docker:latest +``` + +首次启动需要扫码登录 / First launch requires QR code login: + +```bash +docker logs -f napcat +``` + +在日志中找到二维码,用手机 QQ 扫码登录。 +Find the QR code in the logs and scan it with your QQ mobile app. + +### 2. 配置 NapCat 正向 WebSocket / Configure Forward WebSocket + +打开 NapCat WebUI / Open the NapCat WebUI: + +``` +http://localhost:6099 +``` + +在网络配置中:/ In network settings: +- 启用 **正向 WebSocket** (Forward WebSocket) / Enable **Forward WebSocket** +- 端口设为 `3001`(默认)/ Port: `3001` (default) +- 如果需要鉴权,设置 Access Token / Set Access Token if needed + +### 3. 配置 cc-connect / Configure cc-connect + +在 `config.toml` 中添加 QQ 平台 / Add QQ platform to `config.toml`: + +```toml +[[projects.platforms]] +type = "qq" + +[projects.platforms.options] +ws_url = "ws://127.0.0.1:3001" # NapCat 正向 WebSocket 地址 +token = "" # 可选:Access Token(需与 NapCat 一致) +allow_from = "*" # 允许交互的 QQ 号,"*" 表示所有人 +``` + +**`allow_from` 配置说明 / `allow_from` options:** +- `"*"` — 允许所有人 / Allow everyone +- `"12345"` — 仅允许 QQ 号 12345 / Only allow QQ user 12345 +- `"12345,67890"` — 允许多个 QQ 号 / Allow multiple QQ users + +### 4. 启动 / Start + +```bash +cc-connect +``` + +看到如下日志表示连接成功 / You should see: + +``` +qq: connected to OneBot url=ws://127.0.0.1:3001 +qq: logged in qq=123456789 nickname=MyBot +``` + +现在可以在 QQ 上私聊或群聊机器人了! +Now you can chat with the bot via QQ private or group messages! + +## 群聊使用 / Group Chat + +支持群聊消息。在群中发送消息时,机器人会以独立的会话(按用户区分)处理每个人的请求。 + +Group chat is supported. Each user gets their own independent session, even in group chats. + +## 支持的消息类型 / Supported Message Types + +| 类型 / Type | 接收 / Receive | 发送 / Send | +|------------|----------------|-------------| +| 文字 / Text | ✅ | ✅ | +| 图片 / Image | ✅ | ❌ (文本描述) | +| 语音 / Voice | ✅ (需配置 STT) | ❌ | +| @提及 / @mention | ✅ (忽略) | — | + +## 常见问题 / FAQ + +**Q: 连接失败?/ Connection failed?** +- 确认 NapCat 正在运行且端口正确 / Check that NapCat is running and port is correct +- 确认 NapCat 已启用正向 WebSocket / Verify Forward WebSocket is enabled in NapCat +- 如果设置了 Token,确保两边一致 / If using Token, ensure it matches on both sides + +**Q: 收不到消息?/ Not receiving messages?** +- 检查 `allow_from` 配置,确认你的 QQ 号在允许列表中 / Check `allow_from` includes your QQ ID +- 查看 NapCat 日志确认消息是否正确转发 / Check NapCat logs for message forwarding + +**Q: NapCat 掉线?/ NapCat disconnected?** +- NapCat 使用 NTQQ 协议,长时间挂机可能需要重新登录 / NapCat may need re-login after long periods +- 建议使用 Docker restart policy: `--restart unless-stopped` + +## 其他 OneBot 实现 / Other OneBot Implementations + +除了 NapCat,以下 OneBot v11 实现也应该兼容 / Besides NapCat, these should also work: + +- [LLOneBot](https://github.com/LLOneBot/LLOneBot) — NTQQ 插件 / NTQQ plugin +- [Lagrange.Core](https://github.com/LagrangeDev/Lagrange.Core) — 跨平台 / Cross-platform +- [OpenShamrock](https://github.com/whitechi73/OpenShamrock) — Xposed 模块 / Xposed module (Android) + +只要支持正向 WebSocket 的 OneBot v11 实现都可以使用。 +Any OneBot v11 implementation with Forward WebSocket support should work. diff --git a/go.mod b/go.mod index af7d83094..ca0522241 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/BurntSushi/toml v1.6.0 github.com/bwmarrin/discordgo v0.29.0 github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 + github.com/gorilla/websocket v1.5.0 github.com/larksuite/oapi-sdk-go/v3 v3.5.3 github.com/line/line-bot-sdk-go/v8 v8.19.0 github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 @@ -15,7 +16,6 @@ require ( require ( github.com/gogo/protobuf v1.3.2 // indirect github.com/google/uuid v1.3.0 // indirect - github.com/gorilla/websocket v1.5.0 // indirect golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b // indirect golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 // indirect ) diff --git a/platform/qq/qq.go b/platform/qq/qq.go new file mode 100644 index 000000000..611f0b397 --- /dev/null +++ b/platform/qq/qq.go @@ -0,0 +1,441 @@ +package qq + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/chenhg5/cc-connect/core" + "github.com/gorilla/websocket" +) + +func init() { + core.RegisterPlatform("qq", New) +} + +// Platform connects to a OneBot v11 implementation (NapCat, LLOneBot, etc.) +// via forward WebSocket. It receives message events and sends messages back +// through the same WS connection. +type Platform struct { + wsURL string // e.g. "ws://127.0.0.1:3001" + token string // optional access_token + allowFrom string // comma-separated user IDs or "*" + handler core.MessageHandler + conn *websocket.Conn + mu sync.Mutex + echoSeq atomic.Int64 + echoCh sync.Map // echo -> chan json.RawMessage + cancel context.CancelFunc + selfID int64 +} + +func New(opts map[string]any) (core.Platform, error) { + wsURL, _ := opts["ws_url"].(string) + if wsURL == "" { + wsURL = "ws://127.0.0.1:3001" + } + token, _ := opts["token"].(string) + allowFrom, _ := opts["allow_from"].(string) + + return &Platform{ + wsURL: wsURL, + token: token, + allowFrom: allowFrom, + }, nil +} + +func (p *Platform) Name() string { return "qq" } + +func (p *Platform) Start(handler core.MessageHandler) error { + p.handler = handler + + header := http.Header{} + if p.token != "" { + header.Set("Authorization", "Bearer "+p.token) + } + + conn, _, err := websocket.DefaultDialer.Dial(p.wsURL, header) + if err != nil { + return fmt.Errorf("qq: ws connect failed (%s): %w", p.wsURL, err) + } + p.conn = conn + + slog.Info("qq: connected to OneBot", "url", p.wsURL) + + // Get bot self info + if info, err := p.callAPI("get_login_info", nil); err == nil { + if uid, ok := info["user_id"].(float64); ok { + p.selfID = int64(uid) + } + nick, _ := info["nickname"].(string) + slog.Info("qq: logged in", "qq", p.selfID, "nickname", nick) + } + + ctx, cancel := context.WithCancel(context.Background()) + p.cancel = cancel + + go p.readLoop(ctx) + + return nil +} + +func (p *Platform) readLoop(ctx context.Context) { + for { + select { + case <-ctx.Done(): + return + default: + } + + _, raw, err := p.conn.ReadMessage() + if err != nil { + if ctx.Err() != nil { + return + } + slog.Error("qq: ws read error, reconnecting...", "error", err) + p.reconnect() + continue + } + + var payload map[string]any + if json.Unmarshal(raw, &payload) != nil { + continue + } + + // If this is an API response (has "echo" field), route to caller + if echo, ok := payload["echo"].(string); ok { + if ch, loaded := p.echoCh.LoadAndDelete(echo); loaded { + if dataCh, ok := ch.(chan json.RawMessage); ok { + dataCh <- raw + } + } + continue + } + + // Otherwise it's an event + postType, _ := payload["post_type"].(string) + if postType == "message" { + p.handleMessage(payload) + } + } +} + +func (p *Platform) reconnect() { + for i := 1; i <= 30; i++ { + time.Sleep(time.Duration(i) * 2 * time.Second) + header := http.Header{} + if p.token != "" { + header.Set("Authorization", "Bearer "+p.token) + } + conn, _, err := websocket.DefaultDialer.Dial(p.wsURL, header) + if err != nil { + slog.Warn("qq: reconnect attempt failed", "attempt", i, "error", err) + continue + } + p.mu.Lock() + p.conn = conn + p.mu.Unlock() + slog.Info("qq: reconnected") + return + } + slog.Error("qq: failed to reconnect after 30 attempts") +} + +func (p *Platform) handleMessage(payload map[string]any) { + msgType, _ := payload["message_type"].(string) + userID := jsonInt64(payload, "user_id") + groupID := jsonInt64(payload, "group_id") + messageID := jsonInt64(payload, "message_id") + + if userID == p.selfID { + return + } + + if !p.isAllowed(userID) { + return + } + + // Extract sender info + var userName string + if sender, ok := payload["sender"].(map[string]any); ok { + card, _ := sender["card"].(string) + nick, _ := sender["nickname"].(string) + if card != "" { + userName = card + } else { + userName = nick + } + } + + // Parse message content from CQ message array or raw_message + text, images, audio := p.parseMessage(payload) + if text == "" && len(images) == 0 && audio == nil { + return + } + + var sessionKey string + if msgType == "group" { + sessionKey = fmt.Sprintf("qq:%d:%d", groupID, userID) + } else { + sessionKey = fmt.Sprintf("qq:%d", userID) + } + + rctx := &replyContext{ + messageType: msgType, + userID: userID, + groupID: groupID, + messageID: int32(messageID), + } + + msg := &core.Message{ + SessionKey: sessionKey, + Platform: "qq", + UserID: strconv.FormatInt(userID, 10), + UserName: userName, + Content: text, + Images: images, + Audio: audio, + ReplyCtx: rctx, + } + + slog.Debug("qq: message received", "type", msgType, "user", userID, "text_len", len(text)) + p.handler(p, msg) +} + +func (p *Platform) parseMessage(payload map[string]any) (string, []core.ImageAttachment, *core.AudioAttachment) { + var textParts []string + var images []core.ImageAttachment + var audio *core.AudioAttachment + + // OneBot message can be array of segments or a string + switch msg := payload["message"].(type) { + case []any: + for _, seg := range msg { + s, ok := seg.(map[string]any) + if !ok { + continue + } + segType, _ := s["type"].(string) + data, _ := s["data"].(map[string]any) + if data == nil { + continue + } + + switch segType { + case "text": + if text, ok := data["text"].(string); ok { + textParts = append(textParts, text) + } + case "image": + if url, ok := data["url"].(string); ok && url != "" { + imgData, mime, err := downloadFile(url) + if err != nil { + slog.Warn("qq: download image failed", "error", err) + continue + } + images = append(images, core.ImageAttachment{ + MimeType: mime, + Data: imgData, + }) + } + case "record": + if url, ok := data["url"].(string); ok && url != "" { + audioData, _, err := downloadFile(url) + if err != nil { + slog.Warn("qq: download audio failed", "error", err) + continue + } + format := "silk" + if f, ok := data["file"].(string); ok { + if strings.HasSuffix(f, ".amr") { + format = "amr" + } else if strings.HasSuffix(f, ".mp3") { + format = "mp3" + } + } + audio = &core.AudioAttachment{ + Data: audioData, + Format: format, + } + } + case "at": + // Ignore @mentions in parsed text + } + } + default: + // raw_message fallback (string with CQ codes) + if raw, ok := payload["raw_message"].(string); ok { + textParts = append(textParts, stripCQCodes(raw)) + } + } + + return strings.TrimSpace(strings.Join(textParts, "")), images, audio +} + +// Reply sends a message as a reply to an incoming message. +func (p *Platform) Reply(ctx context.Context, replyCtx any, content string) error { + return p.Send(ctx, replyCtx, content) +} + +// Send sends a message to the conversation identified by replyCtx. +func (p *Platform) Send(ctx context.Context, replyCtx any, content string) error { + rctx, ok := replyCtx.(*replyContext) + if !ok { + return fmt.Errorf("qq: invalid reply context") + } + + params := map[string]any{ + "message": content, + } + + if rctx.messageType == "group" { + params["group_id"] = rctx.groupID + _, err := p.callAPI("send_group_msg", params) + return err + } + + params["user_id"] = rctx.userID + _, err := p.callAPI("send_private_msg", params) + return err +} + +func (p *Platform) Stop() error { + if p.cancel != nil { + p.cancel() + } + if p.conn != nil { + return p.conn.Close() + } + return nil +} + +// ── OneBot API call via WebSocket ─────────────────────────────── + +func (p *Platform) callAPI(action string, params map[string]any) (map[string]any, error) { + seq := p.echoSeq.Add(1) + echo := strconv.FormatInt(seq, 10) + + req := map[string]any{ + "action": action, + "echo": echo, + } + if params != nil { + req["params"] = params + } + + ch := make(chan json.RawMessage, 1) + p.echoCh.Store(echo, ch) + defer p.echoCh.Delete(echo) + + data, err := json.Marshal(req) + if err != nil { + return nil, err + } + + p.mu.Lock() + err = p.conn.WriteMessage(websocket.TextMessage, data) + p.mu.Unlock() + if err != nil { + return nil, fmt.Errorf("qq: ws write: %w", err) + } + + select { + case raw := <-ch: + var resp struct { + Status string `json:"status"` + RetCode int `json:"retcode"` + Data json.RawMessage `json:"data"` + } + if json.Unmarshal(raw, &resp) != nil { + return nil, fmt.Errorf("qq: invalid API response") + } + if resp.RetCode != 0 { + return nil, fmt.Errorf("qq: API %s failed (retcode=%d)", action, resp.RetCode) + } + var result map[string]any + json.Unmarshal(resp.Data, &result) + return result, nil + + case <-time.After(15 * time.Second): + return nil, fmt.Errorf("qq: API %s timeout", action) + } +} + +// ── Helpers ───────────────────────────────────────────────────── + +type replyContext struct { + messageType string // "private" or "group" + userID int64 + groupID int64 + messageID int32 +} + +func (p *Platform) isAllowed(userID int64) bool { + if p.allowFrom == "" || p.allowFrom == "*" { + return true + } + uid := strconv.FormatInt(userID, 10) + for _, allowed := range strings.Split(p.allowFrom, ",") { + if strings.TrimSpace(allowed) == uid { + return true + } + } + return false +} + +func jsonInt64(m map[string]any, key string) int64 { + switch v := m[key].(type) { + case float64: + return int64(v) + case json.Number: + n, _ := v.Int64() + return n + } + return 0 +} + +func stripCQCodes(s string) string { + var result strings.Builder + for len(s) > 0 { + idx := strings.Index(s, "[CQ:") + if idx < 0 { + result.WriteString(s) + break + } + result.WriteString(s[:idx]) + end := strings.Index(s[idx:], "]") + if end < 0 { + break + } + s = s[idx+end+1:] + } + return result.String() +} + +func downloadFile(url string) ([]byte, string, error) { + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Get(url) + if err != nil { + return nil, "", err + } + defer resp.Body.Close() + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, "", err + } + + mime := resp.Header.Get("Content-Type") + if mime == "" { + mime = http.DetectContentType(data) + } + return data, mime, nil +}