QQ 群值守机器人搭建记录
2026-11-08 07:00:41

这个想法一开始很简单:让一个 QQ 小号替我盯着几个群,把消息收下来,免得每天爬楼。

真正动手之后才发现,从扫码登录到 WebSocket 握手,坑一个接一个,即便是AI辅助,前后还是折腾了很久。

现在链路终于跑通:NapCat 负责登录 QQ,采集器把群消息写进 SQLite,旁边还接了 AstrBot 做交互试验。把这套流程整理出来,方便以后重装,也给同样想折腾的人一个参考。

为安全起见,文中的服务器 IP、QQ 号和 Token 都做了脱敏。

正文

需求分析

需求本身并不复杂:一个真实 QQ 小号,7×24 在线,替我收集几个群的消息,最好还能在以后接上大模型做日报和简单交互。

限制条件也不少:

  1. 必须是真实 QQ,官方机器人的权限和场景都不太够用。
  2. 服务器是 4C 3.4G 的大陆机,已经装了 1Panel,尽量用 Docker 部署,别把系统环境搞乱。
  3. 管理面板和协议端口不能直接暴露公网。
  4. 采集要保持独立,后面接不接 AI、接哪个 AI,都不能影响消息落库。
  5. 以后可能扩展到多平台、多账号,结构上要留出余地。

选型上,NapCat 负责登录 QQ 并提供 OneBot v11 接口;采集器用 Python 写,直接连 NapCat 的 WebSocket;AstrBot 作为旁路,用来验证以后做交互机器人的可能性。三者互相独立,坏一个不至于全挂。

配置方案

整体架构

1
2
3
4
5
6
7
8
9
10
11
12
QQ 群 / 好友
│
▼
QQ 小号
│ NTQQ 协议
▼
NapCat 容器
├── WebUI 127.0.0.1:6099(SSH 隧道访问)
├── OneBot v11 WS 服务端 :3001(宿主 127.0.0.1:13001)
│ └── qq-collector(systemd)──► SQLite
└── OneBot v11 WS 客户端 ──► ws://astrbot:6199/ws(qq-net 内网)
└── AstrBot(旁路试验)

这里其实有两条并行链路:

  • 采集器连接 NapCat 提供的 WebSocket 服务端,负责把消息写进数据库。
  • AstrBot 反过来,由 NapCat 主动连出去,走反向 WebSocket。
  • 停掉 AstrBot 不影响采集,停掉采集器也不影响 AstrBot。

环境与端口

项 值
服务器 1Panel / Ubuntu / 4C 3.4G
公网 IP 已脱敏
NapCat 容器 napcat,镜像 mlikiowa/napcat-docker:latest
NapCat 配置目录 /opt/napcat/config
QQ 登录态 /opt/napcat/QQ
采集器目录 /opt/qq-collector
数据库 /opt/qq-collector/data/messages.db
systemd 服务 qq-collector.service
AstrBot 容器 astrbot(1Panel 应用商店安装)
Docker 网络 qq-net

宿主机的 3001 端口已经被 1Panel 的 node 进程占用,所以 NapCat 容器端口改映射到 13001,容器内部仍然是 3001。这个坑后面还会反复出现,记住不要杀那个 node 进程。

Token 的存放位置:

  • NapCat WS Token:/opt/napcat/config/onebot11_<QQ号>.json 的 network.websocketServers[].token
  • 采集器 Token:/opt/qq-collector/.env 的 ONEBOT_TOKEN
  • AstrBot Token:AstrBot 平台配置中的 token,要和 NapCat websocketClients[].token 一致

部署过程

NapCat

先建目录。容器内以 root 运行,所以这两个目录保持 root 所有即可:

1
sudo mkdir -p /opt/napcat/config /opt/napcat/QQ

Compose 文件如下,WebUI 和 WebSocket 都只绑定本机:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
services:
napcat:
image: mlikiowa/napcat-docker:latest
container_name: napcat
restart: unless-stopped
environment:
- TZ=Asia/Shanghai
- NAPCAT_UID=0
- NAPCAT_GID=0
ports:
- "127.0.0.1:6099:6099" # WebUI 仅本机
- "127.0.0.1:13001:3001" # OneBot WS,宿主 13001 → 容器 3001
volumes:
- /opt/napcat/config:/app/napcat/config
- /opt/napcat/QQ:/app/.config/QQ
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"

启动后看一眼状态和日志,日志里会打印 WebUI Token:

1
2
sudo docker ps --filter name=napcat
sudo docker logs napcat --tail 50

WebUI 只监听本机,访问时走 SSH 隧道:

1
2
ssh -N -o ServerAliveInterval=30 -o ServerAliveCountMax=3 \
-L 16099:127.0.0.1:6099 ubuntu@你的服务器IP

浏览器打开 http://127.0.0.1:16099,输入日志里的 Token,然后用专用小号扫码登录。

登录态保存在 /opt/napcat/QQ,容器重建后一般不需要重新扫码,这个目录别删。

接下来在 WebUI 的「网络配置」里建一个 WebSocket 服务器,给采集器用:

项 值
名称 collector
Host 0.0.0.0
Port 3001
Token 自行生成,16 位以上
消息格式 Array
心跳间隔 30000
上报自身消息 关
强制推送事件 开

配置完可以先验一下握手:

1
2
3
4
5
6
7
8
9
KEY=$(openssl rand -base64 16)
TOKEN=<上一步设置的 token>
curl -i --http1.1 --max-time 3 \
-H "Connection: Upgrade" \
-H "Upgrade: websocket" \
-H "Sec-WebSocket-Version: 13" \
-H "Sec-WebSocket-Key: $KEY" \
-G --data-urlencode "access_token=$TOKEN" \
"http://127.0.0.1:13001/"

期望看到 101 Switching Protocols,以及一条 lifecycle/connect 事件。

这里踩过一个很隐蔽的坑:Sec-WebSocket-Key 必须是合法值,手抄一个不合法的值会得到 400,响应里常见 Content-Length: 43。用 openssl rand -base64 16 生成就好。

采集器

先准备 Python 环境和依赖:

1
2
3
4
5
6
7
sudo mkdir -p /opt/qq-collector/data
sudo chown -R ubuntu:ubuntu /opt/qq-collector
sudo apt update
sudo apt install -y python3 python3-venv python3-pip
cd /opt/qq-collector
python3 -m venv venv
./venv/bin/pip install websockets==16.1.1

安装时如果弹出 needrestart 的「Daemons using outdated libraries」,选 <Cancel>,不要顺手重启 docker.service 或 containerd,否则 1Panel 和所有容器都会一起重启。

采集器的主程序是 /opt/qq-collector/collector.py:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
#!/usr/bin/env python3
"""连接 NapCat 的 OneBot WebSocket,把群消息写入 SQLite。"""

import asyncio
import json
import os
import sqlite3
from pathlib import Path
from urllib.parse import quote

import websockets

WS_BASE = os.environ.get("WS_URL", "ws://127.0.0.1:13001")
TOKEN = os.environ.get("ONEBOT_TOKEN", "")
DB_PATH = Path(os.environ.get("DB_PATH", "/opt/qq-collector/data/messages.db"))
GROUPS = {int(g) for g in os.environ.get("GROUPS", "").replace(" ", "").split(",") if g.isdigit()}


def init_db(conn):
conn.executescript(
"""
CREATE TABLE IF NOT EXISTS group_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
msg_id INTEGER UNIQUE,
group_id INTEGER NOT NULL,
user_id INTEGER,
nickname TEXT,
sent_at INTEGER,
text TEXT,
raw_json TEXT
);
CREATE INDEX IF NOT EXISTS idx_group_time ON group_messages(group_id, sent_at);
"""
)
conn.commit()


def extract_text(message):
"""把消息段数组拼成纯文本,非文字段保留类型占位。"""
if isinstance(message, str):
return message
parts = []
for seg in message or []:
if seg.get("type") == "text":
parts.append(seg.get("data", {}).get("text", ""))
else:
parts.append(f"[{seg.get('type')}]")
return "".join(parts)


def save_group_message(conn, event):
group_id = event.get("group_id")
if GROUPS and group_id not in GROUPS:
return
sender = event.get("sender") or {}
nickname = sender.get("card") or sender.get("nickname") or ""
text = extract_text(event.get("message"))
conn.execute(
"INSERT OR IGNORE INTO group_messages"
" (msg_id, group_id, user_id, nickname, sent_at, text, raw_json)"
" VALUES (?, ?, ?, ?, ?, ?, ?)",
(
event.get("message_id"),
group_id,
event.get("user_id"),
nickname,
event.get("time"),
text,
json.dumps(event, ensure_ascii=False),
),
)
conn.commit()
print(f"[群 {group_id}] {nickname}: {text}", flush=True)


async def main():
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(DB_PATH)
init_db(conn)
url = f"{WS_BASE}/?access_token={quote(TOKEN, safe='')}"
while True:
try:
async with websockets.connect(url, ping_interval=30, ping_timeout=10) as ws:
print("已连接 NapCat", flush=True)
async for raw in ws:
try:
event = json.loads(raw)
if event.get("post_type") != "message":
continue
if event.get("message_type") == "group":
save_group_message(conn, event)
else:
print(f"[私聊] {event.get('user_id')}: {extract_text(event.get('message'))}", flush=True)
except Exception as exc:
print(f"处理事件失败: {exc}", flush=True)
except Exception as exc:
print(f"连接断开: {exc},5 秒后重连", flush=True)
await asyncio.sleep(5)


if __name__ == "__main__":
asyncio.run(main())

环境变量放在 /opt/qq-collector/.env:

1
2
3
4
WS_URL=ws://127.0.0.1:13001
ONEBOT_TOKEN=<与 NapCat 中一致>
DB_PATH=/opt/qq-collector/data/messages.db
GROUPS=

GROUPS 留空表示采集所有群,需要白名单时填逗号分隔的群号。文件权限记得收紧:

1
chmod 600 /opt/qq-collector/.env

用 systemd 常驻,文件是 /etc/systemd/system/qq-collector.service:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[Unit]
Description=QQ group message collector
After=network-online.target docker.service
Wants=network-online.target

[Service]
Type=simple
User=ubuntu
WorkingDirectory=/opt/qq-collector
EnvironmentFile=/opt/qq-collector/.env
ExecStart=/opt/qq-collector/venv/bin/python /opt/qq-collector/collector.py
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target

启用并观察:

1
2
3
4
sudo systemctl daemon-reload
sudo systemctl enable --now qq-collector
sudo systemctl status qq-collector --no-pager
sudo journalctl -u qq-collector -n 30 --no-pager

验证分三步:日志出现 已连接 NapCat;主号私聊小号能看到 [私聊];群里发消息能看到 [群 <群号>] 昵称: 内容。最后查一下数据库:

1
2
3
4
5
6
python3 - <<'PY'
import sqlite3
conn = sqlite3.connect('/opt/qq-collector/data/messages.db')
for row in conn.execute('select group_id, count(*) from group_messages group by group_id'):
print(row)
PY

AstrBot(可选)

AstrBot 这一步只是验证以后能不能做交互机器人,不参与采集。

先把两个容器放进同一张 Docker 网络:

1
2
3
4
sudo docker network create qq-net 2>/dev/null || true
sudo docker network connect qq-net napcat 2>/dev/null || true
sudo docker network connect qq-net astrbot 2>/dev/null || true
sudo docker network inspect qq-net --format '{{range .Containers}}{{.Name}} {{end}}'

输出里应该同时有 napcat 和 astrbot。1Panel 重建容器后容器名可能变化,网络关系也可能丢,需要重新执行。

AstrBot 这边新建 OneBot v11 平台:

项 值
名称 napcat
启用 是
反向 WebSocket 主机 0.0.0.0
端口 6199
Token 与 NapCat 客户端一致
配置文件 default

保存后执行 sudo docker restart astrbot。

NapCat 这边新增一个 WebSocket 客户端:

项 值
名称 astrbot
URL ws://astrbot:6199/ws
Token 与 AstrBot 平台一致
消息格式 Array
启用 是

注意别建错成「WebSocket 服务器」窗口,那个是采集器用的 collector。

验证看 AstrBot 日志:出现 aiocqhttp(OneBot v11) 适配器已连接。,再用主号私聊小号,日志里出现 [napcat(aiocqhttp)] 昵称/QQ号: 内容,就说明通了。没配模型时它不会回复,能收到消息就是成功。

这一步建议先保持“只收不回”。一旦配了模型,AstrBot 默认会回私聊和群里 @ 它的消息,必须先想好触发词和群白名单,否则很容易在采集群里乱说话。

日常运维

1
2
3
4
5
6
7
8
9
10
11
12
# 容器与服务状态
sudo docker ps --filter name=napcat
systemctl status qq-collector --no-pager
sudo docker network inspect qq-net --format '{{range .Containers}}{{.Name}} {{end}}'

# 日志
sudo journalctl -u qq-collector -n 50 --no-pager
sudo docker logs napcat --tail 50
sudo docker logs astrbot --tail 50

# 只重启采集器,不影响 NapCat
sudo systemctl restart qq-collector

几个不要碰的操作:

  • 不要执行 systemctl restart docker,会连带重启 1Panel 和所有容器。
  • 不要杀占用宿主 3001 的 node 进程,它属于 1Panel。
  • 不要删除 /opt/napcat/QQ,否则要重新扫码。
  • 1Panel 重建 NapCat 或 AstrBot 容器后,记得检查 qq-net 是否还在。

常见问题整理:

现象 原因 处理
编排启动报端口被占用 宿主 3001 被 node 占用 宿主改映射 13001,容器内保持 3001
WebUI 打不开 只绑了 127.0.0.1 走 SSH 隧道,不要改公网绑定
WS 握手 400,Content-Length: 43 Sec-WebSocket-Key 不合法 用 openssl rand -base64 16 生成
WS 握手失败或立即断开 Token 不一致 从配置中取 Token,URL 带 access_token
NapCat 日志反复 403 AstrBot Token 不一致或未重载 对齐 Token 后 docker restart astrbot
AstrBot 没有「适配器已连接」 网络不通、客户端未启用、URL 错误 检查 qq-net、URL、启用状态
采集器 Permission denied 目录属 root sudo chown -R ubuntu:ubuntu /opt/qq-collector
提取 Token 长度为 0 glob 没匹配到配置文件 用 grep -l '3001' /opt/napcat/config/*.json 定位
WebUI 显示离线但容器 Up WebUI 会话断开,或隧道断了 刷新页面/重开隧道;采集器 active 就不影响
1Panel 重建后两边连不上 容器名变化或脱离 qq-net 重新 docker network connect,URL 用实际容器名

安全与合规

  • 只用专用小号,不集中、不高频加群,保持固定环境。
  • 加群前告知群主采集范围、用途和保留期;群消息属于群友个人数据。
  • NapCat WebUI 只绑定本机,通过 SSH 隧道访问。
  • 6185、6199 在云安全组关闭入站放行。Docker 会绕过 ufw,以安全组为准。
  • Token 不进聊天、不进截图。疑似泄露时,NapCat WS Token、采集器 .env、AstrBot Token 三处一起轮换。
  • 大模型 API Key 只写在服务器 .env 中并 chmod 600,不要贴到聊天。
  • 数据库设置保留期(例如 30 天)并限制访问,备份文件也按敏感数据处理。

后续计划

采集跑通只是第一步,后面还想做几件事:

  1. DeepSeek 日报:按天、按群读取 SQLite,过滤纯图片和空消息,控制每群上下文长度,调用 deepseek-chat 生成 Markdown,输出到 /opt/qq-collector/reports/,先手动跑通再加定时任务。
  2. 加群策略:一次加一两个群,隔天再继续,先观察账号状态;需要严格限定时把群号填进 GROUPS。
  3. 数据保留与备份:定期清理 N 天前的数据,用 sqlite3 .backup 做一致性备份,再转移到独立目录。
  4. 交互规则:给 AstrBot 定触发词、群白名单和人格,避免它在采集群里抢话。
  5. 多平台适配:把采集和推送拆开,未来接 Telegram、Discord 或多账号时统一消息表,用 account_id 区分来源。

结语

整个流程最耗时的不是写代码,而是几个看起来很小的配置:端口冲突、WebSocket Key 格式、Token 不一致导致的 403,每一个都能让人卡半天。

现在采集器已经常驻,NapCat 登录态也稳定,AstrBot 这条旁路验证完毕但暂时不配模型。下一步就是把日报做出来,让这个值守的小号真正开始产出,而不是只做一个安静的记录器。

参考

Prev
2026-11-08 07:00:41