Skip to main content
Glama
README.md
# qzone-mcp —— QQ 工具箱(空间 + 资料),我们的一个 MCP 插件

把上游两个 AstrBot 插件的能力合成**一个**插件,改成我们这套栈能直接调的形式:

| 上游 | 贡献的能力 |
|---|---|
| [Zhalslar/astrbot_plugin_qzone](https://github.com/Zhalslar/astrbot_plugin_qzone)(MIT) | 发/看/删/赞/评说说、好友动态、访客、可见范围、表白墙投稿审核、定时说说 |
| [Zhalslar/astrbot_plugin_qqprofile](https://github.com/Zhalslar/astrbot_plugin_qqprofile)(MIT) | 改头像 / 昵称 / 签名 / 在线状态、自定义状态、查资料、名片赞、好友分组 |

为什么不原样装:这两份都是 AstrBot 插件(`star.Star` + `event.bot.*`),AstrBot 已经不在我们的栈里了。
所以保留它们的**接口语义**(端点、参数、可见范围、审核流程、状态码表),换成本机现有认证与调用方式。

## 从零安装(换台机器 / 换个容器)

三步:**放代码 → 配环境变量 → 让 DSH 拉起 MCP**。

1. 代码放到 `/opt/qzone-mcp`(`server.py` 是入口,`wire.py` 负责接线)。依赖:

```bash
pip3 install --break-system-packages aiohttp==3.14.5 edge-tts==7.3.1
```

2. 环境变量(compose 的 `environment:` 或 systemd `Environment=`):

| 变量 | 必填 | 说明 |
|---|---|---|
| `QZONE_ONEBOT_URL` | ✅ | OneBot HTTP,例如 `http://snowluma:3000` |
| `QZONE_ONEBOT_TOKEN` | ✅ | **SnowLuma 的 HTTP token**(不是 WS token,两者不同) |
| `QZONE_DATA` | 建议 | 数据目录(`wall.db` 等),例如 `/opt/qzone-data` |
| `QZONE_SNOWLUMA_SHARE` | 发图/头像需要 | core 侧看到的共用目录,例如 `/opt/snowluma-data/stickers` |
| `QZONE_SNOWLUMA_PREFIX` | 发图/头像需要 | 同一目录在 SnowLuma 容器里的路径,例如 `/app/data/stickers` |
| `QZONE_IMG_PORT` | 可选 | 临时图 HTTP 端口,默认 `8799` |
| `QZONE_IMG_URL_BASE` | 可选 | 临时图对外基址,默认容器 IP `http://<容器IP>:8799` |
| `QZONE_TTS_VOICE` | 可选 | 默认音色,默认 `zh-CN-XiaoyiNeural` |
| `QZONE_TTS_MAX_CHARS` | 可选 | 一次合成最大字数,默认 `400` |

`config.example.json` 是可选覆盖(放 `QZONE_DATA` 或 `/opt/qzone-mcp/config.json`),只管 `qzone.timeout / cookieTtl / defaultUgcRight`、表白墙、定时说说这些。

3. 接线(让 DSH 把它当 MCP 服务器拉起来):

```bash
python3 /opt/qzone-mcp/wire.py     # 幂等;写 $DSH_HOME/profiles/<profile>/cordis.patch.yml 的 mcp-qzone 段
docker restart core                # profile 层要重启才重读
```

`wire.py` 写进去的行长这样(`serverName: qzone` → 工具名就是 `mcp__qzone__*`):

```yaml
- insert:
    - id: mcp-qzone
      name: '@deepseek-ai/dsh-mcp-client'
      config:
        serverName: qzone
        command: /usr/bin/python3
        args: ['/opt/qzone-mcp/server.py']
        toolCallTimeoutMs: 120000
```

4. QQ 会话里要能用,还得**放行工具前缀**:`qq-tool-restrict` 的 `SAFE_PREFIXES` 里要有 `'mcp__qzone__'`;`mcp__qzone__*` 默认**只给管理员**(按 qq-bridge 的 owner 会话判定)。

自检:

```bash
printf '%s\n%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' \
  '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' \
  | python3 /opt/qzone-mcp/server.py | head -c 400
```

## 它长在哪

```
QQ ──► SnowLuma(注入 QQ 的 OneBot 运行时)
          │  HTTP 3000
          │    · 空间:get_cookies → p_skey → 自算 g_tk → 空间网页版 CGI
          │    · 资料:set_qq_avatar / set_qq_profile / set_self_longnick /
          │            set_online_status / set_diy_online_status / get_stranger_info …
          ▼
   core 容器里的 qzone-mcp(stdio MCP server,DSH 拉起)
          │  工具调用
          ▼
      DSH agent(qq-bridge 转来的 QQ 消息 → 挑工具 → 回消息)
```

- 运行:`/opt/qzone-mcp/server.py`,MCP over stdio(JSON-RPC 2.0,按行),只依赖标准库 + aiohttp。
- 注册:`/opt/qzone-mcp/wire.py` 往 `/opt/dsh-home/profiles/web/cordis.patch.yml` 幂等追加一条 `mcp-qzone`,entrypoint 每次启动跑一遍。
- 文件:`server.py`(协议 + 工具注册)、`qzone_api.py`(空间 CGI)、`profile_api.py`(资料动作 + 状态码表)、`msg_api.py`(往 QQ 发图片消息)、`img_bridge.py`(取图 + 临时 HTTP + core↔SnowLuma 共用目录)、`bridge_admin.py`(改 qq-bridge 白名单)、`wall.py`(表白墙 sqlite)、`scheduler.py`(定时说说)、`wire.py`(profile 接线)。

## 工具(38 个)

### 空间

| 工具 | 作用 |
|---|---|
| `qzone_publish` | 发说说(`ugc_right` 1 公开 / 4 好友 / 64 仅自己,`images` 支持 URL 或本地路径) |
| `qzone_list` | 看说说列表,`target_uin` 看别人的,序号 0 = 最新 |
| `qzone_detail` | 单条说说完整内容(评论、图片、转发) |
| `qzone_delete` | 删自己的说说(走 SnowLuma 原生 `delete_qzone_msg`,CGI 那个端点会返回 HTML 跳转页) |
| `qzone_like` / `qzone_unlike` | 点赞 / 取消赞 |
| `qzone_comment` / `qzone_reply` | 评论 / 楼中楼回复 |
| `qzone_feeds` | 好友动态流 |
| `qzone_visitors` | 最近访客 |
| `qzone_profile` | 空间名 / 简介 |
| `qzone_set_right` | 改已发说说的可见范围 |
| `wall_submit` / `wall_list` / `wall_show` | 表白墙投稿、看稿、看详情 |
| `wall_approve` / `wall_reject` / `wall_withdraw` | 过稿(发到空间)、拒稿、撤稿 |
| `qzone_schedule_get` / `qzone_schedule_set` | 定时说说(时间点 + ±随机浮动 + 候选文案) |

### 资料

| 工具 | 作用 |
|---|---|
| `profile_get` | 机器人自己的资料:QQ、昵称、连接状态、性别、年龄、等级 |
| `profile_set_nickname` | 改昵称(可选 `personal_note`) |
| `profile_set_signature` | 改个性签名(`set_self_longnick`) |
| `profile_set_avatar` | 改头像(图片 URL 或容器内文件路径) |
| `profile_set_status` | 改在线状态,认官方名也认白话(见下) |
| `profile_set_diy_status` | 自定义状态(`face_id` + 文字) |
| `profile_list_statuses` | 列出全部支持的官方状态名 |
| `profile_stranger` | 查任意 QQ 的资料(昵称/性别/年龄/等级/签名) |
| `profile_likes` | 名片赞:谁赞过、总次数、今日新增 |
| `profile_friends` | 好友分组列表(含备注) |

**状态白话归一**:`profile_set_status` 先精确匹配 `status.py` 的官方名(43 个),再走别名表,
最后做包含匹配。举例:`忙`→忙碌、`挂机`→离开、`睡了`→睡觉中、`在摸鱼`→摸鱼中、
`coding`→搬砖中、`好累`→被掏空、`开心`→元气满满、`emo`→emo中、`没电`→我的电量。
也接受纯数字(`1300` → 摸鱼中)。

**没搬的东西**:上游的「切换人格 / 人格列表」依赖 AstrBot 的 `conversation_manager` +
`persona_manager`,我们这套栈里没有对应概念(人设由 DSH 的 agent preset + feeling 插件承担),所以不做。

### 发图

| 工具 | 作用 |
|---|---|
| `qq_send_image` | 把一张图当**图片消息**发到 QQ(私聊或群)。`image` 支持公网 URL、`base64://`、`data:image/…`、**本容器内文件路径**;`target` 填 QQ 号或群号;`kind` 默认 `private` |

这条是 v1.5.0 加的,解决的是「本地生成的图发不进 QQ」:收藏表情只能走 `qq_send_sticker`
(发的是表情包收藏里的东西),而 `meme_render` / 自己用 PIL 画的图原先只落在容器里、
控制台能看、QQ 发不出去。

```jsonc
// 发 meme_render 刚搓出来的图
{"name": "qq_send_image", "arguments": {
  "image": "/opt/dsh-home/nekowhale-stickers/renders/a0985f7f45247229.gif",
  "target": "123456789", "kind": "private", "text": "喏,本鱼自己搓的"
}}
// → 已把图片发到私聊 123456789:发送成功(797.1 KB image/gif,走共用目录,message_id=2105297674,前面带了文字,回读确认有图片段)
```

实现要点:

1. `img_bridge.load(image)` 统一把 URL / base64 / data URL / 容器内路径读成字节;
2. 优先 `img_bridge.to_snowluma()` 写到 **core↔SnowLuma 共用目录**(宿主 `/opt/snowluma/data/stickers`,
   core 里 `/opt/snowluma-data/stickers`,snowluma 里 `/app/data/stickers`),把 **snowluma 侧路径**
   交给 `send_private_msg` / `send_group_msg` 的 `image` 段;失败退 `base64://`;
3. 发完用 `get_msg` 回读确认图片段真的在(返回里那句「回读确认有图片段」就是它)。
4. **v1.5.1 加的重试自愈**:`call_action` 遇到 `unauthorized` / `retcode 1401` 会从
   `/opt/snowluma-data/config/onebot_*.json` 重挑 HTTP token 再试一次;`send_image` 每条通道
   各试 2 次(鉴权失败换 token 后 0.6s 重试,其它失败退避 0.8s)。失败信息会把三条尝试的
   原话拼出来,不再是光秃秃一句 `unauthorized`。

**入参可以是 dsh web 的路由地址**:`meme_render` / `sticker_send` 返回的是
`![…](http://127.0.0.1:8080/x-nekowhale-stickers/r/<id>.gif)` 这种 markdown(web profile 下
`origin()` 有值,插件就不给文件路径了)。这种地址可以**原样**填进 `image` —— `img_bridge.load`
会自己去 `127.0.0.1:8080` 把图取下来(实测 `/r/` 814 KB、`/s/` 1.90 MB 都能发)。

**为什么不用别的路子**:`set_qq_avatar` 对 `base64://` 直接报 `avatar file is empty`;QQ 消息里自带的
图片地址带 rkey,SnowLuma 自己去拉会 401。发消息这条对三种写法都放行(实测 A 容器内路径、B `base64://`、
C `file:///…` 全部 `retcode 0`,回读都能看到 `multimedia.nt.qq.com.cn` 的下载地址)。

`meme_render` 的产物落在 `/opt/dsh-home/nekowhale-stickers/renders/<hash>.gif`,它返回的
Markdown 行里就带这个路径——直接把同一个路径喂给 `qq_send_image` 即可。

### 语音(让机器人开口说话,v1.6.0 起)

| 工具 | 作用 |
|---|---|
| `qq_speak` | 把文字**念成 QQ 语音条**发出去。`text` 要念的话(≤400 字)、`target` QQ 号/群号、`kind` private/group、`voice` 音色名、`rate`/`volume` 形如 `+10%` |
| `qq_voice_list` | 列出常用中文音色名,不发消息 |

```jsonc
{"name": "qq_speak", "arguments": {
  "text": "我是大肥鱼,这条语音是本鱼自己说的,听到没。",
  "target": "123456789", "kind": "private"
}}
// → 已经用语音说了(28.3 KB,音色 zh-CN-XiaoyiNeural,message_id=1771999584,回读确认是语音条)
```

链路(和发图完全同构):

```
text → edge-tts(微软 Edge 朗读接口,免密钥,装 pip 包即可)合成 mp3
     → 写进 core↔SnowLuma 共用目录(img_bridge.to_snowluma(..., "audio/mpeg", ext=".mp3"))
     → OneBot send_private_msg / send_group_msg 的 {"type":"record","data":{"file":"/app/data/stickers/xxx.mp3"}}
     → SnowLuma 自己把 mp3 转 SILK 发出去 → get_msg 回读确认 record 段
```

**为什么不直接用 qq-bridge 的 `qq_send_voice`**:它只认 `/opt/qq-bridge/audio/` 语音库里的现成文件
(`allowAbsolutePath:false`),而且 `cfg.socialV2.tools.sendVoice` 默认 `false` → 工具根本不注册,
还要 core 里有 `ffmpeg`/`ffprobe` 才能过音量归一化。我们要的是「现说现发」,所以绕开它。
`snowluma` 容器里有 `ffmpeg 5.1.9`,转码那一步由它做,core 不必装 ffmpeg(省 ~250 MB)。

音色默认 `zh-CN-XiaoyiNeural`(少女感女声,最像「大肥鱼」);东北话 `zh-CN-liaoning-XiaobeiNeural`、
陕西话 `zh-CN-shaanxi-XiaoniNeural`、粤语 `zh-HK-HiuMaanNeural`、台腔 `zh-TW-HsiaoChenNeural` 都有。
换默认音色:compose 里给 core 加 `QZONE_TTS_VOICE=zh-CN-XiaoxiaoNeural`。

已知边界:`edge-tts` 要出网访问 `speech.platform.bing.com`(本机已验证通,4.8 秒 mp3 用了约 1 秒);
合成失败会明确区分「出网不通/音色名不对」;单条上限 400 字(`QZONE_TTS_MAX_CHARS` 可调)。

## 配置

`/opt/qzone-data/config.json`(容器内),宿主上是 `/opt/core/qzone-data/config.json`:

```json
{
  "onebot": { "httpUrl": "http://snowluma:3000", "accessToken": "<SnowLuma HTTP token>" },
  "qzone": { "timeout": 30, "cookieTtl": 1800, "defaultUgcRight": 1 },
  "wall": { "enabled": true, "name": "表白墙", "template": "【表白墙】\n{text}",
            "anonymousSuffix": "\n—— 匿名", "maxLength": 800 },
  "schedule": { "enabled": false, "times": ["09:30","14:30","21:00"],
                "offsetMinutes": 20, "ugcRight": 1, "texts": [] }
}
```

- `QZONE_ONEBOT_URL` / `QZONE_ONEBOT_TOKEN` 覆盖 `onebot` 段。**不要**用 `ONEBOT_TOKEN`(那是 qqd 的 WS token)。
- SnowLuma 的 HTTP(3000) 与 WS(3001) token 是**两个不同的值**,这里要 HTTP 那个。
- 表白墙数据:`/opt/qzone-data/wall.db`(sqlite)。
- 定时说说只在 MCP 进程活着时生效;`qzone_schedule_set` 会即时重载,不用重启。

## 运维

```bash
# 改了插件代码必须重建(COPY 进镜像的,compose up -d 不重建)
cd /opt/core && docker compose up -d --build

# 看 MCP 是否被 DSH 拉起来
docker logs core 2>&1 | grep -i qzone

# 协议自检
printf '%s\n' \
 '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}' \
 '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \
 | docker exec -i core python3 /opt/qzone-mcp/server.py

# 直接调一个工具
printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' \
 '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"profile_get","arguments":{}}}' \
 | docker exec -i core python3 /opt/qzone-mcp/server.py
```

## 已知边界

- **`get_status` 只回 `{online, good, time}`**,拿不到当前 `status/ext_status`,所以 `profile_get` 只报「在线/离线」。
  改完状态要确认效果,看 QQ 客户端上的机器人名片。
- **`get_unidirectional_friend_list` 在 SnowLuma 这版会抛 `protobuf invalid wire type 6`**(SnowLuma 侧 bug),
  所以这个工具没做,别去加。
- `profile_likes` 里 QQ 常常不下发昵称(`nick` 为空),只能显示 QQ 号与次数。
- `profile_set_avatar` 已实现但未实机验证(换头像对用户可见,等你自己点头再动)。
- 点赞接口上游注明「接口显示成功但实测点赞无效」;这里沿用同一端点(`http://` 的 unikey 才记赞)。
- 图片上传(`up.qzone.qq.com`)上游标为「较脆弱」,失败会报错而不是静默。
- 访客接口只返回 `items`,字段随 QQ 版本变化时可能少名字;动态流深翻页需时间游标,这里只取首页。

## 白名单管理(admin_* 五个工具,v1.2.0 起)

**为什么要这组工具**:qq-bridge 的白名单闸门在「消息进 agent 之前」,所以以前想加人只能登录服务器改
`config.json` 再重启容器 —— 你在 QQ 里跟机器人说「把谁加白」是没人接的。

**现在怎么做**:直接说人话,agent 会调工具。

| 你说 | agent 调 | 效果 |
|---|---|---|
| 「看下白名单」 | `admin_whitelist_show` | 列出模式 / 管理员 / 私聊白名单 / 群白名单 |
| 「把 123456 加进白名单」 | `admin_whitelist_add {kind:private, ids:["123456"]}` | 那个人能私聊机器人 |
| 「把群 987654321 加白」 | `admin_whitelist_add {kind:group, ids:["987654321"]}` | 那个群能 @ 机器人 |
| 「把 123456 删掉」 | `admin_whitelist_remove` | 立即不再响应 |
| 「白名单只留 123456789」 | `admin_whitelist_set` | 整份替换 |
| 「管理员改成 xxx」 | `admin_set_owner` | 换 ownerQQ |

**实现路径**(为什么不用重启):

```
qzone-mcp (MCP server)
  └─ POST http://127.0.0.1:3100/api/whitelist   ← qq-bridge 自带控制台 API
       header: x-console-token: $(cat /opt/qq-bridge/state/console-token)
  └─ qq-bridge: atomicWriteJson(config.json) + cfg.allow 热更新 + reconcileSessionPolicies()
```

**踩过的坑(重要)**:`/opt/qq-bridge/config.json` 是**单文件 bind mount**(`./qbridge/config.json:/opt/qq-bridge/config.json`),
容器内对它 `rename(2)` 恒返回 `EBUSY` → qq-bridge 的 `atomicWriteJson` 抛错 → 控制台所有「改配置」接口 500,
工具报 `OSError(16, 'Device or resource busy')`。修法:构建期用 `core/patch-qqbridge.js` 给 `renameWithRetry`
打一条退化路径(rename 反复 EBUSY 就原地覆盖写),`core/Dockerfile` 里 `RUN node /tmp/patch-qqbridge.js`。
本插件自己的 `patch_file()` 也有同样的 EBUSY 退化,作为控制台彻底不通时的保底(那种情况要
`docker restart core` 才生效,返回值里会写明)。

**验证过**:加群 → `config.json` 里 `groups: [987654321]` 落盘 → qq-bridge 日志
`控制台:白名单已更新(群: 987654321,私聊: 123456789,管理员: 123456789)` → 移除后回到 `groups: []`,全程没重启容器。