用 iPhone 快捷指令 + Cloudflare Worker,把微信通知安全转发到另一台手机的 Telegram

状态:草稿。 带有 TODO 标记的地方是作者尚未在真机上验证的内容,发布前必须逐项确认(见文末「发布前待验证清单」)。

0. 这篇文章解决什么问题

你有两台手机:

  • iPhone A:登录了微信,微信通知会在它上面弹出。
  • iPhone B:日常随身携带,希望在微信来消息时收到提醒。

目标是把 A 上的微信通知,有选择地转发到 B 的 Telegram。

设计原则:

  1. Telegram Bot Token 不出现在手机里,只保存在 Cloudflare 的加密 Secret 中。
  2. 默认拒绝:只有白名单里的联系人才会被转发。
  3. 默认不带正文:只告诉你「某某发来消息」,需要时再开启正文。
  4. 敏感内容兜底拦截:验证码、银行卡、支付等关键词与数字串不转发。
  5. 不需要自己的服务器:只用 Cloudflare 免费额度。

1. 架构与数据流

iPhone A
  微信通知
    │  (iOS 通知自动化触发,标题过滤 = 白名单)
    ▼
  快捷指令 「获取 URL 内容」
    │  HTTPS POST + X-Webhook-Key
    ▼
Cloudflare Worker
    ├─ ① 校验 Webhook Key
    ├─ ② 限制请求体大小
    ├─ ③ 白名单(标题精确匹配)
    ├─ ④ 敏感内容过滤
    ├─ ⑤ 按模式组装文本(notify / full)
    │
    │  HTTPS + Bot Token(仅存在于 Worker Secret)
    ▼
Telegram Bot API
    ▼
iPhone B(Telegram)

2. 隐私边界:必须先读懂

明文会经过三方:iPhone A、Cloudflare、Telegram。

  • Cloudflare Worker 的代码不写数据库、不写 KV、不主动打日志,但这不等于消息「从未经过 Cloudflare」。
  • Telegram 的 Bot 消息是普通云端聊天,不是端到端加密,Telegram 服务器能看到明文,Bot API 也无法使用 Secret Chat。
  • 微信本身的消息就不是端到端加密的,这篇文章不改变这一点。

所以本方案的正确定位是「降低泄露面」,不是「私密通道」。如果你只是想知道「微信来消息了」,请保持默认的 notify 模式,正文根本不会离开 iPhone A。

2.1 各层防护各自挡什么

层位置作用局限
触发器标题过滤iPhone A不在名单的通知不离开手机取决于能否拿到标题(见 §7.1)
Webhook KeyWorker防止别人直接调用你的 Worker静态密钥,泄露即失效
Rate LimitingCloudflare即使密钥泄露也限制刷屏需在控制台配置
Worker 白名单Worker第二道白名单此时数据已到 Cloudflare
敏感词 / 数字过滤Worker兜底拦截验证码类只是兜底,会漏
notify 模式Worker正文不发往 Telegram发送者名字仍会发送

2.2 为什么 Token 不放手机

Token 放在手机上并不是「必然灾难」:它只对应你自己的一个私有 Bot,泄露后可在 BotFather 用 /revoke 立刻作废。真正的好处是快捷指令被分享、导出、备份时不会带出 Bot 控制权。但要注意,WEBHOOK_KEY 本身同样是秘密,泄露后别人可以往你的 Telegram 灌消息,所以不要分享这个快捷指令。

3. 准备工作

  • 一个 Cloudflare 账号(免费即可)
  • 一个 Telegram 账号,以及接收通知的手机 B
  • iPhone A:已安装微信和「快捷指令」,系统版本需支持「通知」自动化触发器
  • 一台 Mac / Linux 电脑的终端(生成密钥、测试接口)

4. 创建 Telegram Bot

  1. 在 Telegram 中搜索 @BotFather,发送 /newbot。
  2. 按提示输入显示名称(如 WeChat Notify)和唯一的用户名(必须以 bot 结尾)。
  3. BotFather 返回形如 1234567890:AAxxxxxxxx… 的字符串,这就是 Bot Token。
  4. 不要把它贴到聊天、截图、博客或快捷指令里。

如果 Token 曾暴露,在 BotFather 里发送 /revoke 作废并重新生成。

5. 获取 Chat ID

5.1 让接收方启动 Bot

在 iPhone B 的 Telegram 里打开刚创建的 Bot,点 Start,或发送任意一句话。Bot 不会主动给从未联系过它的用户发消息,这一步不能省。

5.2 调用 getUpdates(避免 Token 进入命令历史)

zsh(macOS 默认):

read -s "TOKEN?粘贴 Bot Token 后回车: "
curl -s "https://api.telegram.org/bot${TOKEN}/getUpdates"
unset TOKEN

bash:

read -rsp "粘贴 Bot Token 后回车: " TOKEN; echo
curl -s "https://api.telegram.org/bot${TOKEN}/getUpdates"
unset TOKEN

说明:

  • 直接在命令行里写 Token,会被 shell 写进历史文件(zsh 为 ~/.zsh_history)。用 read -s 输入则不会留下。
  • 粘贴时屏幕不显示内容,属于正常现象。

5.3 读取结果

在返回的 JSON 里找到:

"chat": { "id": 123456789, "type": "private" }

这个数字就是 TELEGRAM_CHAT_ID。

5.4 常见报错

返回含义处理
404 Not FoundToken 格式不对检查是否保留了 < >、是否漏了 bot 前缀、是否复制不完整或带了空格/引号
401 Unauthorized格式对但 Token 无效是否已被 /revoke;回 BotFather 用 /token 重新确认
{"ok":true,"result":[]}Token 正确,但 Bot 还没收到过消息回到 §5.1,让 B 手机给 Bot 发一条消息再重试

6. 部署 Cloudflare Worker

6.1 创建并粘贴代码

  1. 登录 Cloudflare Dashboard → Workers & Pages → Create → 创建 Worker,命名如 wechat-notify。
  2. 打开代码编辑器,把 附录 A 的完整代码粘贴进去,保存并 Deploy。
  3. 记下地址,形如 https://wechat-notify.<你的子域>.workers.dev。

6.2 配置 Secrets(加密,部署后不可见)

Worker → Settings → Variables and Secrets,类型选 Secret:

名称值说明
TELEGRAM_BOT_TOKENBotFather 给的 Token最高权限凭证
TELEGRAM_CHAT_ID§5.3 得到的数字发送目标
WEBHOOK_KEY随机长字符串iPhone → Worker 的共享密钥

生成 WEBHOOK_KEY:

openssl rand -hex 32

6.3 配置普通变量(Plaintext)

名称示例说明
ALLOW_TITLES张三,老婆允许转发的通知标题,精确匹配,逗号分隔。为空则什么都不转发
MODEnotifynotify = 只发「某某 发来消息」;full = 带正文

要点:

  • ALLOW_TITLES 必须与微信通知里实际显示的标题完全一致(联系人备注名、群名)。匹配时会做全角/半角归一化与大小写忽略,但不会做模糊匹配。
  • 修改变量后需要重新部署才能生效(TODO:确认当前控制台行为,部分版本保存变量即自动生效)。

6.4 配置限流(强烈建议)

在 Cloudflare 控制台为该 Worker 所在的域/路由配置 Rate Limiting(安全 / WAF 相关设置),例如限制同一来源每分钟请求数。这样即使 WEBHOOK_KEY 泄露,攻击者也无法无限刷屏。

TODO:免费计划下该功能的入口位置、可用规则数量和匹配范围(是否适用于 workers.dev 默认域名,还是需要绑定自有域名)需要按当前控制台实际情况核实后再写进正文。

6.5 日志

保持 Worker 的 Observability / Logs 处于关闭状态,避免不必要的请求记录。

7. 配置 iPhone A 的快捷指令

7.1 第一步:先实测通知变量(整个方案的前提)

Apple 的通知自动化触发器可以按 App,以及 Message、Subtitle、Title 设置过滤条件。但「可以过滤」不等于「变量里能取到这三个字段」。必须先在你自己的手机上确认通知变量实际暴露了哪些属性。

  1. 快捷指令 → 自动化 → + → 通知。
  2. App 选择「微信」。
  3. 添加一个动作:显示结果,内容选择「通知」变量。
  4. 运行方式选 运行前询问(仅测试用)。
  5. 让别人给你发一条微信,观察输出。

记录下你看到的属性名称(例如是否同时有标题、副标题、正文),后面的字段映射以实测为准。

  • 如果取不到标题:白名单无法工作,需要改用别的思路(例如仅依赖触发器的 Message 过滤,或放弃按联系人过滤),本文后续步骤不适用。
  • 如果标题取到了:继续。

TODO:把实测截图(已打码)放进正文,并写明微信私聊、群聊通知各自的 Title / Message 实际长什么样。

7.2 创建正式自动化

  1. 新建自动化 → 通知 → App 选「微信」。
  2. 在 Title 过滤中填入联系人名称。
    • TODO:确认 iOS 27 是否允许在同一自动化中叠加多个 Title 条件(或多个触发器)。如果不行,每个联系人单独建一个自动化。
  3. 添加动作 获取 URL 内容:
    • URL:你的 Worker 地址
    • 方法:POST
    • 请求头:
      • Content-Type: application/json
      • X-Webhook-Key: 你的 WEBHOOK_KEY
    • 请求体:JSON,添加三个字段,值分别选择通知变量里对应的属性:
      • title
      • subtitle
      • message
  4. 运行方式改为 立即运行,关闭「运行前询问」,否则每条微信都要你手动确认。

不需要再额外加「字典」动作,直接在 JSON 请求体里逐字段选变量即可,少一步就少一处出错。

7.3 失败提示

自动化里的网络失败默认是静默的,你会误以为「没有消息」。建议在「获取 URL 内容」之后:

  1. 从返回结果里取字段 ok。
  2. 如果 ok 不是「是」,用 显示通知 提醒「微信转发失败」。

说明:

  • Worker 的 sent / ignored / blocked 都是 HTTP 200。401(密钥错误)、502(Telegram 出错)等返回的是纯文本而非 JSON,这时取 ok 会失败,正好落入「提醒失败」的分支。
  • 「显示通知」由快捷指令 App 发出,不是微信,不会再次触发这个自动化。

TODO:实测上述分支在失败时是否确实能按预期执行。

8. 测试

把下面命令里的地址和密钥替换成你自己的。

正常转发(标题需在 ALLOW_TITLES 里):

curl -i -X POST 'https://wechat-notify.example.workers.dev' \
  -H 'Content-Type: application/json' \
  -H 'X-Webhook-Key: 你的密钥' \
  --data '{"title":"张三","message":"明天见"}'

预期:{"ok":true,"action":"sent"},B 手机收到消息。notify 模式下只显示「张三 发来消息」。

不在白名单:

curl -i -X POST 'https://wechat-notify.example.workers.dev' \
  -H 'Content-Type: application/json' \
  -H 'X-Webhook-Key: 你的密钥' \
  --data '{"title":"陌生人","message":"你好"}'

预期:{"ok":true,"action":"ignored","reason":"not_allowed"}。

敏感内容:

curl -i -X POST 'https://wechat-notify.example.workers.dev' \
  -H 'Content-Type: application/json' \
  -H 'X-Webhook-Key: 你的密钥' \
  --data '{"title":"张三","message":"验证码 123456"}'

预期:{"ok":true,"action":"blocked","reason":"sensitive"}。

**错误密钥:**把 X-Webhook-Key 改成 wrong,预期 HTTP 401。

**非 POST:**用 GET 访问,预期 HTTP 405。

最后用真实微信消息做端到端测试,分别覆盖:私聊、群聊、锁屏状态、专注模式开启时。

9. 密钥轮换与应急

事件处理
WEBHOOK_KEY 泄露 / 快捷指令被分享重新生成密钥 → 更新 Cloudflare Secret → 更新快捷指令里的请求头
Bot Token 泄露BotFather /revoke → 新 Token 更新到 Cloudflare Secret
B 手机换号或换账号重新获取 Chat ID,更新 TELEGRAM_CHAT_ID
怀疑被刷屏先收紧 Rate Limiting,再轮换 WEBHOOK_KEY

10. 常见问题排查

现象可能原因
curl 返回 401请求头名称不是 X-Webhook-Key,或密钥与 Secret 不一致(注意首尾空格)
curl 返回 500 Server Misconfigured三个必需 Secret 缺少任意一个
返回 ignored / not_allowedALLOW_TITLES 为空,或与微信通知标题不完全一致
返回 blocked / sensitive触发了关键词或「连续 4–8 位数字」规则(时间、金额、手机号片段都可能误触发)
返回 502Telegram 拒绝或超时:检查 Token、Chat ID,以及 B 手机是否已对 Bot 点过 Start
curl 正常但微信通知没转发自动化未触发:检查专注模式、微信通知预览设置、自动化是否设为「立即运行」
有时转发有时不转发系统对后台任务的限制,无法保证每条必达

11. 已知限制(写进文章,别隐瞒)

  1. 明文经过三方,见 §2。
  2. 群聊:群通知的标题是群名,发送者通常在正文里。白名单放行群名,等于放行群内所有人的消息;在 notify 模式下只会看到「群名 发来消息」。
  3. 敏感词过滤只是兜底:两位数验证码、被空格拆开的数字、其他语言措辞、图片/语音类通知都可能漏过。真正的防线是白名单。
  4. 数字规则会误伤:包含 4–8 位连续数字的正常消息(金额、日期、订单号)在 full 模式下会被拦截。
  5. 不保证每条必达:通知自动化依赖系统调度,Worker 也没有持久队列,失败不会自动补发。
  6. 不处理图片、语音、文件:只有通知文字能被转发。
  7. 不支持从 B 回复到微信,本文方案只做单向提醒。 微信在快捷指令中确实提供「发送消息」动作,但收件人只能选系统通讯录里的联系人,而不是微信联系人,群聊更无法支持,因此无法作为通用回复通道。反向通道还等于「替你说话」,安全要求远高于单向通知,本文有意不涉及。
  8. 静态共享密钥:没有时间戳与重放防护,安全性依赖密钥保密和限流。

12. 发布前待验证清单

  • 通知变量实际暴露了哪些属性(§7.1),并补充打码截图
  • iOS 27 是否支持叠加多个 Title 条件(§7.2)
  • Cloudflare 变量保存后是否需要手动重新部署(§6.3)
  • Rate Limiting 在免费计划下的入口、额度、对 workers.dev 的适用性(§6.4)
  • 失败提示分支(§7.3)真机表现
  • 锁屏 / 专注模式 / 微信关闭预览 下的触发情况
  • 私聊 / 群聊通知的 Title、Message 实际格式
  • 全文检查:不含真实 Token、Chat ID、Webhook Key、联系人姓名、Worker 真实地址

附录 A:完整 Worker 代码

// Cloudflare Worker: iPhone Shortcut -> Worker -> Telegram Bot
//
// Secrets (Settings -> Variables and Secrets -> type "Secret"):
//   TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID, WEBHOOK_KEY
// Plain-text variables:
//   ALLOW_TITLES  comma-separated exact notification titles allowed to be forwarded
//                 (contact remark names / group names). Empty = forward nothing.
//   MODE          "notify" (default: only sender, no message body)
//                 "full"   (sender + body, after filters)

const MAX_BODY_CHARS = 8192;

export default {
  async fetch(request, env) {
    if (request.method !== "POST") {
      return plain("Method Not Allowed", 405, { Allow: "POST" });
    }
    if (!env.WEBHOOK_KEY || !env.TELEGRAM_BOT_TOKEN || !env.TELEGRAM_CHAT_ID) {
      return plain("Server Misconfigured", 500);
    }

    // 1. Authenticate before reading the body
    const supplied = request.headers.get("X-Webhook-Key") || "";
    if (!(await safeEqual(supplied, env.WEBHOOK_KEY))) {
      return plain("Unauthorized", 401);
    }

    // 2. Parse with a size cap
    const raw = await request.text();
    if (raw.length > MAX_BODY_CHARS) return plain("Payload Too Large", 413);

    let data;
    try {
      data = JSON.parse(raw);
    } catch {
      return plain("Bad Request", 400);
    }
    if (!data || typeof data !== "object") return plain("Bad Request", 400);

    const title = clean(data.title, 200);
    const subtitle = clean(data.subtitle, 200);
    const message = clean(data.message, 1500);

    if (!title && !subtitle && !message) {
      return reply("ignored", "empty");
    }

    // 3. Allowlist (fail closed): only exact-match titles are forwarded
    const allow = parseAllowList(env.ALLOW_TITLES);
    if (!allow.has(normalize(title))) {
      return reply("ignored", "not_allowed");
    }

    // 4. Sensitive-content filter (second line of defence, biased toward dropping)
    if (isSensitive(subtitle, message)) {
      return reply("blocked", "sensitive");
    }

    // 5. Build text (no parse_mode, so no markup injection)
    const full = (env.MODE || "notify").toLowerCase() === "full";
    let text = "📱 微信\n👤 " + title;
    if (full) {
      if (subtitle) text += "\nℹ️ " + subtitle;
      if (message) text += "\n\n💬 " + message;
    } else {
      text += " 发来消息";
    }
    text = truncate(text, 3500);

    // 6. Send
    const ok = await sendTelegram(env, text);
    if (!ok) return plain("Upstream Error", 502);

    return reply("sent");
  },
};

// ---------------------------------------------------------------------------

const SENSITIVE_ZH = [
  "验证码", "驗證碼", "校验码", "校驗碼", "动态码", "動態碼", "安全码", "安全碼",
  "验证", "驗證", "校验", "登录", "登錄", "登入", "口令", "一次性",
  "密码", "密碼", "银行卡", "銀行卡", "信用卡", "身份证", "身份證", "护照", "護照",
  "转账", "轉賬", "转帐", "支付", "付款", "收款", "红包", "紅包", "账单", "賬單",
];

const SENSITIVE_EN =
  /\b(otp|pin|passcode|password|passwd|verification|verify|security code|one[- ]time|iban|cvv)\b/i;

function isSensitive(subtitle, message) {
  const body = normalize(`${subtitle}\n${message}`);
  if (SENSITIVE_ZH.some((w) => body.includes(w))) return true;
  if (SENSITIVE_EN.test(body)) return true;
  // Any run of 4-8 digits (typical verification codes); NFKC already folds full-width digits
  if (/\p{Nd}{4,8}/u.test(body)) return true;
  return false;
}

function parseAllowList(value) {
  const set = new Set();
  if (typeof value !== "string") return set;
  for (const item of value.split(/[,,、\n]/)) {
    const n = normalize(item);
    if (n) set.add(n);
  }
  return set;
}

function normalize(s) {
  return (s || "").normalize("NFKC").trim().toLowerCase();
}

function clean(value, max) {
  if (typeof value !== "string") return "";
  return truncate(value.replace(/\u0000/g, "").trim(), max);
}

// Code-point aware: never splits an emoji surrogate pair
function truncate(value, max) {
  if (!value) return "";
  const chars = Array.from(value);
  return chars.length <= max ? value : chars.slice(0, max).join("") + "…";
}

async function safeEqual(a, b) {
  const enc = new TextEncoder();
  const [ha, hb] = await Promise.all([
    crypto.subtle.digest("SHA-256", enc.encode(a)),
    crypto.subtle.digest("SHA-256", enc.encode(b)),
  ]);
  return crypto.subtle.timingSafeEqual(ha, hb);
}

async function sendTelegram(env, text) {
  const url = `https://api.telegram.org/bot${env.TELEGRAM_BOT_TOKEN}/sendMessage`;
  for (let attempt = 0; attempt < 2; attempt++) {
    try {
      const res = await fetch(url, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          chat_id: env.TELEGRAM_CHAT_ID,
          text,
          link_preview_options: { is_disabled: true },
        }),
        signal: AbortSignal.timeout(8000),
      });
      if (res.ok) return true;
      if (res.status === 429 && attempt === 0) {
        const j = await res.json().catch(() => ({}));
        const wait = Math.min(Number(j?.parameters?.retry_after) || 1, 3);
        await new Promise((r) => setTimeout(r, wait * 1000));
        continue;
      }
      return false;
    } catch {
      if (attempt === 1) return false;
    }
  }
  return false;
}

function reply(action, reason) {
  const body = { ok: true, action };
  if (reason) body.reason = reason;
  return new Response(JSON.stringify(body), {
    status: 200,
    headers: {
      "Content-Type": "application/json; charset=utf-8",
      "Cache-Control": "no-store",
    },
  });
}

function plain(text, status, extra = {}) {
  return new Response(text, {
    status,
    headers: { "Cache-Control": "no-store", ...extra },
  });
}

附录 B:代码设计要点(写博客时可展开)

  • 先验密钥再读请求体:未授权请求不消耗解析开销。
  • SHA-256 后再恒定时间比较:统一长度,避免提前返回泄露密钥长度。
  • 白名单默认拒绝:配置缺失时宁可不转发。
  • NFKC 归一化:全角数字、兼容字符在过滤前统一,减少绕过。
  • 按码点截断:Array.from 避免把 emoji 代理对切成两半导致 Telegram 返回 400。
  • 不使用 parse_mode:消息内容不会被当作 Markdown / HTML 解析。
  • link_preview_options:替代已弃用的 disable_web_page_preview。
  • 不回传 Telegram 详细响应:避免泄露上游信息。
  • 429 有限重试:尊重 retry_after,最多等 3 秒、重试一次。

附录 C:参考资料

  • Apple 支持文档:快捷指令中的事件触发器(Event triggers in Shortcuts)
  • Telegram Bot API 文档:sendMessage、getUpdates
  • Cloudflare Workers 文档:Secrets、Rate Limiting