实战:通过微信 ilinkai 接口实现个人号接收 SMS 转发

背景

短信验证码转发场景中,常用的通知渠道有 Bark、飞书、企业微信、钉钉。但这些要么需要额外 App,要么依赖企业账号体系。一个更直觉的需求是——直接转发到微信个人号。微信近期开放了 ilinkai Bot 接口,理论上支持通过 HTTP API 向个人微信发送消息。

整体架构

项目基于 Cloudflare Worker 构建,是一个无服务器 SMS 转发网关:

手机收到短信 → HTTP POST → Cloudflare Worker(鉴权 → 去重 → 限流)→ 并行推送
                                                    ↓
                           ┌────────┬────────┬────────┬────────┬────────┐
                           │  Bark  │  飞书  │企业微信│  钉钉  │  微信  │
                           │  Push  │Webhook │Webhook │Webhook │ilinkai │
                           └────────┴────────┴────────┴────────┴────────┘
                                                       ↓
                          ┌──────────────────┐
                          │ Armbian 服务器   │
                          │ 保活脚本(systemd)│
                          │ 长轮询保持在线   │
                          └──────────────────┘

微信 ilinkai 作为第五个通知渠道接入,与其他渠道完全并行。但由于 ilinkai 的 token 机制,需要一个额外的常驻进程来保活——这是本方案与其他渠道最大的区别。

ilinkai 接口简析

ilinkai 是微信提供的 Bot API,核心接口:

接口方法 用途
/ilink/bot/get_bot_qrcode GET 获取登录二维码
/ilink/bot/get_qrcode_status GET 轮询扫码状态,获取 token
/ilink/bot/getupdates POST 长轮询接收消息(服务端 hold 35 秒)
/ilink/bot/sendmessage POST 发送消息

认证方式

每次请求携带以下 Headers:

Content-Type: application/json
AuthorizationType: ilink_bot_token
X-WECHAT-UIN: <base64(random_uint32)>
Authorization: Bearer <bot_token>

踩坑全过程

坑1:二维码不是 base64 图片

API 文档中 qrcode_img_content 字段名容易让人以为是 base64 编码的图片数据。实际返回的是一个 URL 链接

{
  "qrcode": "683bfcab...",
  "qrcode_img_content": "https://liteapp.weixin.qq.com/q/xxxx?qrcode=xxx&bot_type=3"
}

直接在浏览器打开这个 URL 扫码即可。

坑2:HTTP 200 不代表成功

ilinkai 的 sendmessage 接口有一个反直觉的行为:即使 token 过期或参数错误,也可能返回 HTTP 200

不能仅依赖 HTTP 状态码判断,必须检查响应体中的 ret 字段。

坑3:Token 失效极快

这是最大的坑。扫码获取的 bot_token 在短时间不活跃后就会失效。

根因:ilinkai 的设计是为对话式 Bot 服务的,客户端需要通过 getupdates 长轮询持续保持连接。如果没有持续的轮询请求,服务端会判定 session 离线,token 随即失效。

而 Cloudflare Worker 是请求驱动的无状态服务,两次 SMS 推送之间可能间隔数小时,期间没有任何请求维持会话。

坑4:发送消息的隐藏必填字段(关键)

这是调试时间最长的问题。消息发送后 HTTP 200 返回 {},看起来成功了,但微信上始终收不到消息。

通过反向工程微信官方的 OpenClaw 插件(@tencent-weixin/openclaw-weixin),发现 sendmessage 有几个文档中未明确说明但实际必需的字段和 Headers:

必需的消息体字段:

{
  "msg": {
    "from_user_id": "",
    "to_user_id": "xxx@im.wechat",
    "client_id": "unique-id-per-message",
    "message_type": 2,
    "message_state": 2,
    "item_list": [...]
  },
  "base_info": {
    "channel_version": "2.1.1"
  }
}

必需的额外 Headers:

iLink-App-Id: bot
iLink-App-ClientVersion: 131329

缺少以上任何一项,接口都会静默返回 200 + {} 但不实际投递消息。没有任何错误提示。

反向工程过程

直接 npm pack 下载 OpenClaw 插件的 npm 包,解包后阅读 TypeScript 源码:

npm pack @tencent-weixin/openclaw-weixin
tar xzf tencent-weixin-openclaw-weixin-2.1.1.tgz

关键文件:
- src/messaging/send.ts——发送消息的完整实现
- src/api/api.ts——HTTP 请求封装,包含所有 Headers
- src/api/types.ts——消息类型定义
- src/monitor/monitor.ts——长轮询保活循环
- src/api/session-guard.ts——session 过期处理

最终实现

Worker 端:发送通知核心代码

export async function sendWeixinNotification(env, title, content, device, code) {
  const botToken = env.WEIXIN_BOT_TOKEN;
  const targetUser = env.WEIXIN_TARGET_USER;

  if (!botToken || !targetUser) {
    return { success: false, error: 'Not configured' };
  }

  const text = buildWeixinText(title, content, device, code);
  const uin = btoa(String(Math.floor(Math.random() * 4294967296)));
  const clientId = `sms-forwarder-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;

  const payload = {
    msg: {
      from_user_id: '',
      to_user_id: targetUser,
      client_id: clientId,
      message_type: 2,   // BOT 消息
      message_state: 2,  // FINISH 状态
      item_list: [{ type: 1, text_item: { text } }],
    },
    base_info: { channel_version: '2.1.1' },
  };

  const baseUrl = env.WEIXIN_BASE_URL || 'https://ilinkai.weixin.qq.com';
  const response = await fetch(`${baseUrl}/ilink/bot/sendmessage`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'AuthorizationType': 'ilink_bot_token',
      'X-WECHAT-UIN': uin,
      'Authorization': `Bearer ${botToken}`,
      'iLink-App-Id': 'bot',
      'iLink-App-ClientVersion': '131329',
    },
    body: JSON.stringify(payload),
  });

  const result = await safeJson(response);
  if (response.ok && (result.ret === undefined || result.ret === 0)) {
    return { success: true };
  }
  return { success: false, error: `ret=${result.ret}` };
}

保活脚本:维持 Session

由于 Cloudflare Worker 无法运行常驻进程,需要在一台服务器上部署保活脚本。脚本逻辑很简单——无限循环调用 getupdates,模拟客户端持续在线:

// weixin-keepalive.mjs(核心逻辑简化版)
import crypto from 'node:crypto';

const BOT_TOKEN = process.env.WEIXIN_BOT_TOKEN;
const BASE_URL = 'https://ilinkai.weixin.qq.com';

while (true) {
  try {
    const res = await fetch(`${BASE_URL}/ilink/bot/getupdates`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'AuthorizationType': 'ilink_bot_token',
        'X-WECHAT-UIN': randomUin(),
        'Authorization': `Bearer ${BOT_TOKEN}`,
      },
      body: JSON.stringify({
        get_updates_buf: cursor,
        base_info: { channel_version: '1.0.2' },
      }),
      signal: AbortSignal.timeout(40000),
    });

    const data = await res.json();

    // 更新游标
    if (data.get_updates_buf) cursor = data.get_updates_buf;

    // session 过期处理
    if (data.errcode === -14 || data.ret === -14) {
      console.error('Session 过期,需要重新扫码');
      await sleep(3600000); // 暂停 1 小时
    }
  } catch (err) {
    if (err.name === 'AbortError') continue; // 长轮询超时是正常的
    await sleep(5000); // 网络错误重试
  }
}

完整脚本还包括:游标持久化到文件(重启不丢失)、连续失败退避、优雅退出信号处理、运行统计等。

部署指南

前置条件

第一步:获取 Token 和 User ID

运行配置脚本:

node scripts/weixin-setup.mjs

脚本会输出一个 URL,在浏览器中打开用微信扫码,然后用目标微信号给 Bot 发一条消息。脚本自动获取:

第二步:部署保活脚本到服务器

scripts/weixin-keepalive.mjs 上传到服务器:

mkdir -p /opt/weixin-keepalive
scp scripts/weixin-keepalive.mjs user@server:/opt/weixin-keepalive/

创建 systemd 服务:

# /etc/systemd/system/weixin-keepalive.service
[Unit]
Description=WeChat ilinkai Bot Keepalive
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
Environment=WEIXIN_BOT_TOKEN=<your_token>
Environment=WEIXIN_STATE_FILE=/opt/weixin-keepalive/state.json
ExecStart=/usr/bin/node /opt/weixin-keepalive/weixin-keepalive.mjs
Restart=always
RestartSec=10

[Install]
WantedBy=multi-user.target

启动并设置开机自启:

systemctl daemon-reload
systemctl enable weixin-keepalive
systemctl start weixin-keepalive

查看运行状态:

systemctl status weixin-keepalive
journalctl -u weixin-keepalive -f

正常运行时日志类似:

微信保活脚本启动
BASE_URL: https://ilinkai.weixin.qq.com
TOKEN: ...
运行正常 | 轮询: 100 | 错误: 0

第三步:配置 Worker 环境变量

在 Cloudflare 控制台 → Workers & Pages → 你的 Worker → Settings → Variables and Secrets,添加:

类型 变量名
Secret WEIXIN_BOT_TOKEN 第一步获取的 token
Secret WEIXIN_TARGET_USER 第一步获取的 user ID

保存后 Worker 自动重新部署。

第四步:验证

curl -X POST https://your-worker.workers.dev/api/sms/forward \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer <your_api_token>" \
  -d '{"content": "您的验证码是 123456,10分钟内有效", "device": "test"}'

预期返回:

{
  "success": true,
  "message": "forwarded",
  "code": "123456",
  "feishu": true,
  "bark": 1,
  "weixin": true
}

同时微信收到推送通知。

运维注意事项

Token 不会因服务器重启而变更

Token 是扫码登录时微信服务端生成的,存储在 Worker secrets 和 systemd 环境变量中。服务器重启后 systemd 自动拉起保活脚本,从 state.json 恢复游标继续轮询。

什么情况下需要重新扫码

重新扫码后需要同时更新服务器的 systemd 环境变量和 Cloudflare 的 Worker Secret。

保活脚本资源占用

脚本非常轻量:

适合跑在树莓派、Armbian 盒子等低功耗设备上。

总结

项目状态:
- 接口集成已完成
- 单向推送验证通过
- Worker 部署正常运行
- Token 保活已部署,运行稳定
- 长期稳定性观察中

微信个人号接收 SMS 转发在技术上完全可行,但实现过程远比其他渠道复杂。核心难点不在于接口调用本身,而在于:

  1. 文档不完整——关键字段(from_user_id、client_id、iLink-App-Id)未在文档中说明,需要反向工程官方插件才能发现
  2. 静默失败——参数不完整时接口返回 200 不报错,排查困难
  3. 需要常驻进程——无状态的 Serverless 架构无法独立支撑,必须额外部署保活服务

如果你的通知需求已经被 Bark/飞书等渠道满足,没有必要折腾微信渠道。但如果你确实需要推送到微信——希望这篇文章能帮你少走弯路。