让 AI 助理拥有发文权:OpenClaw 接微信 + 自动投稿到自建博客
我给自己的博客加了一个 Agent 投稿接口,然后让一个跑在服务器上的 AI 助理通过微信接单、写稿、投稿。这篇是这套链路的实现记录,包括几个只有真跑一遍才会撞上的坑。
先说结论:能跑,但权限闸门必须留在人这一侧。
一、整体链路 #
微信(个人号)
↓ @tencent-weixin/openclaw-weixin 插件
OpenClaw Gateway ──→ Agent(Claude Code)
↓ Bearer token
博客 /api/agent/posts
↓
SQLite(status=draft)
↓ 人工审核
线上发布
四段各自独立,任何一段挂掉不会污染其他段。这个解耦不是我设计的,是 OpenClaw 的通道插件契约本来就这么划的,值得单独说一下。
二、微信这一段:核心仓库里没有微信代码 #
OpenClaw 接微信走的是腾讯维护的外部插件 @tencent-weixin/openclaw-weixin,通道 id 是 openclaw-weixin(weixin / wechat 是别名)。
openclaw plugins install "@tencent-weixin/openclaw-weixin"
openclaw config set plugins.entries.openclaw-weixin.enabled true
openclaw channels login --channel openclaw-weixin # 扫码
启动顺序是:Gateway 发现插件 manifest → 加载入口 → 插件注册通道 id → 扫码登录、凭据落在状态目录(默认 ~/.openclaw)→ Gateway 启动时插件为每个账号拉起 Weixin monitor → 入站消息经通道契约归一化后路由给指定 agent,出站再走插件。
关键点在于:微信登录、iLink API 调用、媒体上下行、上下文 token、账号监控,全部由外部插件持有,核心保持通道无关。 这带来一个直接的工程收益——我升级博客、改 agent 逻辑、换模型,都不用碰微信这一层;反过来插件更新也不会波及我的业务代码。
一个必须说清的边界:这条通道是个人微信,插件的 capability metadata 只声明 direct chat,不支持群聊,也和微信公众号没有任何关系。 公众号自动发文是另一套东西(appid/secret、素材上传、draft/add、freepublish/submit、后台 IP 白名单),本文不涉及,我也没跑过。
三、博客这一段:给 Agent 开一个写权限 #
博客是 Nuxt 4 + better-sqlite3 + drizzle 自建的。给 agent 单独开了一组 /api/agent/* 接口,走 Bearer token,和管理端的 JWT cookie 完全分离。
token 校验只认 sha256,明文只在生成那一刻出现一次:
export function requireAgent(event: H3Event) {
const raw = getRequestHeader(event, 'authorization') || ''
const m = raw.match(/^Bearer\s+(.+)$/i)
if (!m) throw createError({ statusCode: 401, statusMessage: 'Missing bearer token' })
const db = useDb()
const row = db.select().from(schema.apiTokens)
.where(and(
eq(schema.apiTokens.tokenHash, hashToken(m[1].trim())),
eq(schema.apiTokens.revoked, 0),
))
.get()
if (!row) throw createError({ statusCode: 401, statusMessage: 'Invalid token' })
db.update(schema.apiTokens)
.set({ lastUsedAt: Math.floor(Date.now() / 1000) })
.where(eq(schema.apiTokens.id, row.id)).run()
return { tokenId: row.id, name: row.name, allowPublish: row.allowPublish === 1 }
}
revoked 和 lastUsedAt 是刻意加的:token 泄露时要能单点吊销,而不是全站改密;lastUsedAt 让"这个 token 还在被谁用"可观测。
四、最重要的一个决定:默认只发草稿 #
这是全套设计里我唯一坚持没让步的地方。
let status = body.status === 'published' ? 'published' : 'draft'
let downgraded = false
if (status === 'published' && !agent.allowPublish) {
status = 'draft'
downgraded = true
}
注意这里不是"拒绝请求",是静默降级 + 显式告知:请求照常成功,但落库状态被强制改成草稿,响应里带 downgraded: true、说明文案和 reviewUrl。
{
"id": 12, "slug": "...", "status": "draft", "downgraded": true,
"note": "该 token 无直接发布权限,已保存为草稿,请在管理端审核后发布",
"reviewUrl": "/admin/posts/12"
}
为什么不直接 403?因为 403 会让调用方以为整件事失败了,于是重试、于是改参数绕。降级 + reviewUrl 是一条更诚实的路径:活干完了,只是最后一步在人手里。
而背后的真实理由跟技术无关——署名风险是不对称的。 AI 写的东西挂我的名字给客户看,写对了收益有限,写错了信誉是我自己的。所以 allowPublish=0 是默认值,不是可选项。
这条约束我做了实测:带 status: "published" 发请求,落库确实是 draft,响应带 downgraded: true,草稿的公开详情页返回 404。闸门有效。
五、几个真撞上的坑 #
nginx 1.24 不认 http2 on; #
http2 on; 是 1.25.1+ 的语法。1.24 必须写老形式:
listen 443 ssl http2;
nginx -t 拦住了,没造成线上事故。这也是为什么 reload 前必须 -t——不是流程洁癖,是它真的能救你。
SQLite FTS5 trigram 对中文搜索基本不可用 #
搜索用了 FTS5 的 trigram 分词器。上线前一测就傻了:「良率」「制程」「架构」这类两个汉字的查询恒定返回空。
原因不复杂——trigram 按 3 个字符切分,2 字符的查询根本构不出一个 trigram。另外含 + # 的词(C++、C#)会直接抛 syntax error。对一个技术博客来说,这两类恰好是高频词。
解法是混合策略,写在 server/utils/posts.ts 的 searchIds() 里:
- 查询长度 < 3 或含特殊符号 → 直接走
LIKE - 长词 → 走 FTS,空结果再用 LIKE 兜一次
第二条兜底很关键,因为 FTS 命中率对中文本来就不稳。附一句版本信息:better-sqlite3 13.0.2 捆的 SQLite 是 3.53.4,所以这不是老版本 bug,是 trigram 的固有行为。
3.6G 内存的机器上打包 Nuxt #
整机 3.6G / 2 vCPU,Gateway 常驻 718M,还有个 chromium 吃掉约 550M。Nuxt 构建期是纯内存杀手。
三件事解决:
- 打包前停 chromium。注意它由 systemd user unit 托管且
Restart=always,kill完 3 秒内原地复活,PID 变了但进程数不变,极易误判成杀掉了。 正确姿势:systemctl --user stop lighthouse-chromium.service - 构建加
NODE_OPTIONS=--max-old-space-size=1024 - 运行时 systemd 配
MemoryMax=512M兜底
结果:产物 30.9MB,运行时实际只占 37M,离 512M 预算很远。构建期和运行时的内存画像可以差一个数量级,按构建峰值去估算服务器规格是浪费钱。
上传接口:不要信 Content-Type #
顺手说一个跟 agent 无关但同批做的。图片上传按 magic bytes 判类型,不信 Content-Type 也不信扩展名,只放 PNG/JPEG/GIF/WebP。
SVG 是故意拒绝的——它能内嵌 <script>,是一个现成的存储型 XSS 面。实测返 415。
文件名完全由服务端生成(时间戳 + 随机),不复用任何用户输入,路径穿越和同名覆盖一起免掉。
六、这套东西值不值 #
我的判断:值,但价值不在"AI 能写文章"。
真正的收益是把"想到一件事"到"它落到线上有个 URL"之间的摩擦压到接近零。以前一个想法要经过:想 → 记 → 找时间 → 打开编辑器 → 排版 → 发布,每一步都是流失点。现在是微信里说一句,剩下的自动跑到草稿箱,我只做最后一次判断——发或不发。
代价是你必须接受最后那道人工闸门。想全自动的话,这套设计就不适合你,但我也不建议那么干。
顺手记一条工程教训,跟主题无关但吃过亏:批量编辑工具的多个 replace block 通常是原子的,一个匹配不上就全部回滚。 我曾因一个字符写错,以为前面几处已生效,实际全没落地。改完一定 grep 复核,别信返回值。
评论
还没有评论。