mirror of
https://github.com/chenhg5/cc-connect.git
synced 2026-10-25 17:36:43 +08:00
feat(qq): add QQ platform support via OneBot v11/NapCat
generated by llmgit Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
1 parent
7cb23971bf
commit
c88d46a973
8 files changed
+642
-4
No files matched your search
+25
-1
@@ -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=<QQ号> -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
+11
-1
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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=<QQ号> -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 / 取消注释以启用)
|
||||
# =============================================================================
|
||||
|
||||
+129
@@ -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.
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user