1. 从文字聊天到多媒体:OpenClaw 接入 QQ 个人号后真正难啃的部分
如果你已经用 OpenClaw 通过 NapCat 把 QQ 个人号跑通了文字对话,接下来大概率会撞上同一堵墙:机器人只会回文字。用户发来一张图,它说“没收到文本”;你想让它把生成的 PDF 发出去,它把本地路径原样丢给 NapCat,然后报 Invalid URL;你想让它每天早上主动问候,结果 Agent 只会被动等消息。
这篇就专门解决这三类高频场景:图片/文件发送、主动推送、定时任务。核心链路是 OpenClaw 作为 Agent 层,NapCat 作为 OneBot11 协议实现层,两者通过 WebSocket 通信。难点不在协议本身,而在于 NapCat 通常跑在 Docker 容器里,看不到宿主机文件系统,所以文件必须先通过一个 HTTP 文件服务器暴露成 URL,NapCat 才能下载并转发给 QQ。
适合谁看:已经完成 OpenClaw + NapCat 基础文字接入、想继续做多媒体和主动推送的开发者。下面所有配置都基于 TaoToken 统一 Key 的方式,把模型调用凭证集中管理,避免在多个插件里散落 Key。
2. TaoToken 前置:统一 Key 与 OpenClaw 配置骨架
TaoToken 在这里的角色是统一模型调用入口。OpenClaw 的 Agent 需要调用大模型来生成回复、理解图片描述、生成定时问候语,这些请求都走同一个 Key,配置一次即可。官网入口在 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,API 端点是 https://taotoken.net/api 。
先拿到 Key:进入控制台 https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,在 API Keys 页面创建一个新 Key,复制保存。如果你还没决定用哪个模型,可以先去模型对话 https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 试一下对话效果,确认模型能正常响应再写进配置。
OpenClaw 的配置文件在~/.openclaw/openclaw.json,把 TaoToken 的 Key 写进模型 provider 段。下面是一个可复制的骨架,注意baseUrl指向 TaoToken 的 API 地址,apiKey换成你自己的:
{ "providers": { "taotoken": { "type": "openai-compatible", "baseUrl": "https://taotoken.net/api", "apiKey": "sk-你的TaoTokenKey", "models": { "default": "claude-sonnet-4-20250514" } } }, "plugins": { "entries": { "qq": { "enabled": true } } }, "channels": { "qq": { "enabled": true, "wsUrl": "ws://127.0.0.1:3001", "accessToken": "your_napcat_token" } }, "gateway": { "port": 18789, "controlUi": { "allowInsecureAuth": true } } }这里wsUrl指向 NapCat 的 OneBot11 WebSocket 端口,accessToken是 NapCat 配置里设置的 token。改完配置后重启 OpenClaw Gateway,确认日志里出现 QQ 插件加载成功、WebSocket 连接建立。如果连接失败,先检查 NapCat 容器是否在运行、端口是否映射到宿主机。
3. 可复制配置:文件服务器 + 多媒体发送 + 主动推送
3.1 为什么需要 HTTP 文件服务器
OpenClaw 的 Agent 生成文件后保存在工作目录,比如/root/openclaw/work/。NapCat 在 Docker 容器里,无法直接读宿主机路径。OneBot11 的文件上传接口接受 URL 或 base64,但 base64 对用户不友好,所以正确做法是起一个 HTTP 文件服务器,把工作目录暴露成 URL,NapCat 通过 URL 下载。
创建~/.openclaw/extensions/qq/src/file-server.ts:
import * as http from "http"; import * as fs from "fs"; import * as path from "path"; let server: http.Server | null = null; export function startFileServer(port: number = 18790): void { if (server) return; server = http.createServer((req, res) => { try { const decodedUrl = decodeURIComponent(req.url || "/"); const filePath = path.join("/root/openclaw/work", decodedUrl); const realPath = fs.realpathSync(filePath); if (!realPath.startsWith("/root/openclaw/work")) { res.writeHead(403); res.end("Forbidden"); return; } if (fs.existsSync(realPath) && fs.statSync(realPath).isFile()) { const stat = fs.statSync(realPath); const ext = path.extname(realPath).toLowerCase(); const mimeTypes: Record<string, string> = { ".jpg": "image/jpeg", ".png": "image/png", ".gif": "image/gif", ".txt": "text/plain", ".pdf": "application/pdf", ".mp3": "audio/mpeg", ".mp4": "video/mp4", }; res.writeHead(200, { "Content-Type": mimeTypes[ext] || "application/octet-stream", "Content-Length": stat.size, "Access-Control-Allow-Origin": "*", }); fs.createReadStream(realPath).pipe(res); } else { res.writeHead(404); res.end("Not Found"); } } catch (err) { res.writeHead(500); res.end("Error"); } }); server.listen(port, "0.0.0.0", () => { console.log("[QQ FileServer] Started on port " + port); }); }关键点:路径穿越检查用realpathSync解析后再判断前缀,防止../逃逸。MIME 类型映射决定 NapCat 能否正确识别图片和音频。
3.2 路径转 URL 与 Docker 网桥
在channel.ts里加转换函数。如果 NapCat 在 Docker 里,必须用宿主机 Docker 网桥 IP,不能用 127.0.0.1:
const FILE_SERVER_PORT = 18790; // 查看网桥 IP:ip addr show docker0 | grep inet const FILE_SERVER_BASE_URL = "http://172.17.0.1:" + FILE_SERVER_PORT; function convertLocalPathToUrl(filePath: string): string { if (filePath.startsWith("http://") || filePath.startsWith("https://")) return filePath; if (filePath.startsWith("base64://")) return filePath; if (filePath.startsWith("/root/openclaw/work/")) { const relativePath = filePath.substring("/root/openclaw/work".length); // 不要用 encodeURIComponent,否则 / 会变成 %2F return FILE_SERVER_BASE_URL + relativePath; } return filePath; }这里踩过的坑:对relativePath用encodeURIComponent()会导致路径里的/被编码成%2F,生成http://172.17.0.1:18790%2Ftest.txt这种错误 URL,NapCat 直接报 Invalid URL。正确做法是直接拼接。
3.3 图片与文件发送
图片走 OneBot11 的image消息段,文件走upload_private_file/upload_group_file接口。在client.ts里封装:
async uploadPrivateFile(userId: number, file: string, name: string) { return this.callApi("upload_private_file", { user_id: userId, file, name }); } async uploadGroupFile(groupId: number, file: string, name: string, folder = "") { return this.callApi("upload_group_file", { group_id: groupId, file, name, folder }); }发送时根据扩展名判断媒体类型:
function detectMediaType(url: string): "image" | "audio" | "video" | "file" { const ext = path.extname(url).toLowerCase(); if ([".jpg", ".jpeg", ".png", ".gif", ".webp"].includes(ext)) return "image"; if ([".mp3", ".wav", ".ogg"].includes(ext)) return "audio"; if ([".mp4", ".mov"].includes(ext)) return "video"; return "file"; }图片消息段格式:[{ type: "image", data: { file: processedUrl } }]。文件则调用上传接口,URL 同样经过convertLocalPathToUrl处理。
3.4 主动推送:绕过 Agent 直接调 API
OpenClaw 的 Agent 是被动响应的,只有收到消息才回复。主动推送需要独立建立 WebSocket 连接,直接调 NapCat 的send_private_msg。创建/root/qq-tools/send-message.js:
const WebSocket = require('ws'); const NAPCAT_WS_URL = 'ws://127.0.0.1:3001'; const ACCESS_TOKEN = 'your_token_here'; function sendQQMessage(target, message) { return new Promise((resolve, reject) => { const ws = new WebSocket(NAPCAT_WS_URL, { headers: { 'Authorization': 'Bearer ' + ACCESS_TOKEN } }); const timeout = setTimeout(() => { ws.close(); reject(new Error('超时')); }, 10000); ws.on('open', () => { let action, params; if (target.startsWith('group:')) { action = 'send_group_msg'; params = { group_id: parseInt(target.replace('group:', ''), 10), message: [{ type: 'text', data: { text: message } }] }; } else { action = 'send_private_msg'; params = { user_id: parseInt(target, 10), message: [{ type: 'text', data: { text: message } }] }; } ws.send(JSON.stringify({ action, params, echo: 'send_' + Date.now() })); }); ws.on('message', (data) => { const response = JSON.parse(data.toString()); if (response.echo && response.echo.startsWith('send_')) { clearTimeout(timeout); response.status === 'ok' ? resolve(response.data) : reject(new Error(response.message)); ws.close(); } }); ws.on('error', (error) => { clearTimeout(timeout); reject(error); }); }); } module.exports = { sendQQMessage };初始化依赖:mkdir -p /root/qq-tools && cd /root/qq-tools && npm init -y && npm install ws。
3.5 定时任务:crontab + 随机问候
定时任务推荐用 Linux crontab,比 OpenClaw 内置 Cron 更稳定。创建/root/qq-tools/simple-cron.js,核心是往 crontab 里追加任务行:
function addDailyTask(hour, minute, message) { const cronExpr = hour + ' ' + minute + ' * * *'; const jobId = 'daily_' + hour + '_' + minute + '_' + Date.now(); let currentCrontab = ''; try { currentCrontab = execSync('crontab -l 2>/dev/null').toString().trim(); } catch (e) { currentCrontab = '# QQ定时任务'; } const taskLine = cronExpr + ' node /root/qq-tools/send-message.js ' + QQ_USER + " '" + message + "' # " + jobId; execSync('echo "' + (currentCrontab + '\n' + taskLine).replace(/"/g, '\\"') + '" | crontab -'); }使用示例:
node /root/qq-tools/simple-cron.js add-daily 8 0 "早上好,新的一天开始了" node /root/qq-tools/simple-cron.js add-daily 22 0 "晚安,早点休息" node /root/qq-tools/simple-cron.js list随机问候更进一步:每天生成 15-25 个随机时间点,按时间段选择问候语。核心逻辑是generateRandomTimes(count)在 7:00-23:00 之间生成不重复时间,generateGreeting()根据当前小时选择 morning/noon/afternoon/evening 模板。每天凌晨用 crontab 重新生成一次,避免固定时间太机械。
4. 验证请求:三条动作跑通完整链路
4.1 验证文件服务器
# 启动 OpenClaw 后检查文件服务器 curl -I http://127.0.0.1:18790/ # 创建测试文件 echo "Hello World" > /root/openclaw/work/test.txt # 验证可访问 curl http://127.0.0.1:18790/test.txt如果 curl 返回 200 和文件内容,说明文件服务器正常。然后在 QQ 里对机器人说“帮我创建一个 txt 文件并发送给我”,观察是否收到文件。
4.2 验证图片发送
在 QQ 里让机器人发送一张图片,或者直接测试主动推送:
node /root/qq-tools/send-message.js 你的QQ号 "这是一条主动推送的消息"如果 QQ 立即收到消息,说明主动推送链路通了。这条消息完全绕过了 OpenClaw Agent,直接通过 NapCat API 发送。
4.3 验证定时任务
# 创建一个 1 分钟后的任务测试 node /root/qq-tools/simple-cron.js add-daily 14 30 "定时任务测试" # 查看 crontab crontab -l到时间后 QQ 应收到消息。如果没收到,检查 crontab 日志:grep CRON /var/log/syslog。
5. 本篇常见错排查
5.1 文件发送报 Invalid URL
现象:日志出现Error: Invalid URL,URL 类似http://127.0.0.1:18790%2Ftest.txt。原因是对路径用了encodeURIComponent(),/被编码成%2F。解决:直接拼接路径,不要编码。
5.2 NapCat 无法访问文件服务器
现象:NapCat 日志报连接127.0.0.1:18790失败。原因是 NapCat 在 Docker 容器里,127.0.0.1指向容器自身。解决:用宿主机 Docker 网桥 IP:
ip addr show docker0 | grep inet # 输出类似 inet 172.17.0.1/16然后把FILE_SERVER_BASE_URL改成http://172.17.0.1:18790。
5.3 发送文件后附带多余消息
现象:AI 发送文件后,还会额外发一条“I didn't receive any text in your message”。原因是 NapCat 发送文件后会产生一个空消息回执事件,OpenClaw 把它当作用户消息处理。解决:在消息接收处加空消息过滤:
if (!text || text.trim() === "") { console.log("[QQ] Ignoring empty message event"); return; }5.4 图片消息被当作空消息
现象:用户发图片后,AI 回复“没收到文本”。原因是图片消息的raw_message为空。解决:为媒体消息添加描述性文本,把[图片]、[文件]等作为文本内容传给 Agent:
if (!text && event.message && Array.isArray(event.message)) { const mediaTypes = event.message.map((seg: any) => seg.type).filter((t: string) => t !== "text"); if (mediaTypes.length > 0) { text = mediaTypes.map((type: string) => { if (type === "image") return "[图片]"; if (type === "file") return "[文件]"; if (type === "record") return "[语音]"; return "[" + type + "]"; }).join(" "); } }5.5 base64 文件用户看不懂
现象:AI 发送的文件内容是一堆 base64 字符。原因是用了data:URL。解决:在sendMedia里拒绝 data URL,强制走 HTTP URL:
if (mediaUrl.startsWith("data:")) { return { channel: "qq", sent: false, error: "Data URLs not supported. Save file and use HTTP URL." }; }6. 继续扩展与统一 Key 的长期价值
跑通上面三条链路后,你的 QQ 机器人已经能发图片、发文件、主动推送、定时问候。后续可以接入邮件通知(IMAP 监听新邮件)、GitHub Webhooks(代码提交通知)、天气 API(每天早上推送天气预报)、RSS 订阅(新闻推送)。
这些扩展都依赖模型调用,而 TaoToken 统一 Key 的价值就在这里:不管你有多少个插件、多少个定时任务,模型凭证只配一次。如果后续要做长期编码或 Agent 任务,可以了解 Coding Plan https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ;接入过程中遇到 Key 或端点问题,查接入文档 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,需要新建或轮换 Key 去 API Keys https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 。
最后提醒一句:文件服务器的路径穿越检查不能省,realpathSync解析后再判断前缀,否则../能读到工作目录外的文件。这个坑我在测试时踩过,本地跑没问题,一放到有敏感文件的服务器上就是安全隐患。