状态:草稿。 带有
TODO标记的地方是作者尚未在真机上验证的内容,发布前必须逐项确认(见文末「发布前待验证清单」)。
0. 这篇文章解决什么问题
你有两台手机:
- iPhone A:登录了微信,微信通知会在它上面弹出。
- iPhone B:日常随身携带,希望在微信来消息时收到提醒。
目标是把 A 上的微信通知,有选择地转发到 B 的 Telegram。
设计原则:
- Telegram Bot Token 不出现在手机里,只保存在 Cloudflare 的加密 Secret 中。
- 默认拒绝:只有白名单里的联系人才会被转发。
- 默认不带正文:只告诉你「某某发来消息」,需要时再开启正文。
- 敏感内容兜底拦截:验证码、银行卡、支付等关键词与数字串不转发。
- 不需要自己的服务器:只用 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 Key | Worker | 防止别人直接调用你的 Worker | 静态密钥,泄露即失效 |
| Rate Limiting | Cloudflare | 即使密钥泄露也限制刷屏 | 需在控制台配置 |
| 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
- 在 Telegram 中搜索 @BotFather,发送
/newbot。 - 按提示输入显示名称(如
WeChat Notify)和唯一的用户名(必须以bot结尾)。 - BotFather 返回形如
1234567890:AAxxxxxxxx…的字符串,这就是 Bot Token。 - 不要把它贴到聊天、截图、博客或快捷指令里。
如果 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 Found | Token 格式不对 | 检查是否保留了 < >、是否漏了 bot 前缀、是否复制不完整或带了空格/引号 |
401 Unauthorized | 格式对但 Token 无效 | 是否已被 /revoke;回 BotFather 用 /token 重新确认 |
{"ok":true,"result":[]} | Token 正确,但 Bot 还没收到过消息 | 回到 §5.1,让 B 手机给 Bot 发一条消息再重试 |
6. 部署 Cloudflare Worker
6.1 创建并粘贴代码
- 登录 Cloudflare Dashboard → Workers & Pages → Create → 创建 Worker,命名如
wechat-notify。 - 打开代码编辑器,把 附录 A 的完整代码粘贴进去,保存并 Deploy。
- 记下地址,形如
https://wechat-notify.<你的子域>.workers.dev。
6.2 配置 Secrets(加密,部署后不可见)
Worker → Settings → Variables and Secrets,类型选 Secret:
| 名称 | 值 | 说明 |
|---|---|---|
TELEGRAM_BOT_TOKEN | BotFather 给的 Token | 最高权限凭证 |
TELEGRAM_CHAT_ID | §5.3 得到的数字 | 发送目标 |
WEBHOOK_KEY | 随机长字符串 | iPhone → Worker 的共享密钥 |
生成 WEBHOOK_KEY:
openssl rand -hex 32
6.3 配置普通变量(Plaintext)
| 名称 | 示例 | 说明 |
|---|---|---|
ALLOW_TITLES | 张三,老婆 | 允许转发的通知标题,精确匹配,逗号分隔。为空则什么都不转发 |
MODE | notify | notify = 只发「某某 发来消息」;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 设置过滤条件。但「可以过滤」不等于「变量里能取到这三个字段」。必须先在你自己的手机上确认通知变量实际暴露了哪些属性。
- 快捷指令 → 自动化 → + → 通知。
- App 选择「微信」。
- 添加一个动作:显示结果,内容选择「通知」变量。
- 运行方式选 运行前询问(仅测试用)。
- 让别人给你发一条微信,观察输出。
记录下你看到的属性名称(例如是否同时有标题、副标题、正文),后面的字段映射以实测为准。
- 如果取不到标题:白名单无法工作,需要改用别的思路(例如仅依赖触发器的 Message 过滤,或放弃按联系人过滤),本文后续步骤不适用。
- 如果标题取到了:继续。
TODO:把实测截图(已打码)放进正文,并写明微信私聊、群聊通知各自的 Title / Message 实际长什么样。
7.2 创建正式自动化
- 新建自动化 → 通知 → App 选「微信」。
- 在 Title 过滤中填入联系人名称。
TODO:确认 iOS 27 是否允许在同一自动化中叠加多个 Title 条件(或多个触发器)。如果不行,每个联系人单独建一个自动化。
- 添加动作 获取 URL 内容:
- URL:你的 Worker 地址
- 方法:POST
- 请求头:
Content-Type:application/jsonX-Webhook-Key: 你的WEBHOOK_KEY
- 请求体:JSON,添加三个字段,值分别选择通知变量里对应的属性:
titlesubtitlemessage
- 运行方式改为 立即运行,关闭「运行前询问」,否则每条微信都要你手动确认。
不需要再额外加「字典」动作,直接在 JSON 请求体里逐字段选变量即可,少一步就少一处出错。
7.3 失败提示
自动化里的网络失败默认是静默的,你会误以为「没有消息」。建议在「获取 URL 内容」之后:
- 从返回结果里取字段
ok。 - 如果
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_allowed | ALLOW_TITLES 为空,或与微信通知标题不完全一致 |
返回 blocked / sensitive | 触发了关键词或「连续 4–8 位数字」规则(时间、金额、手机号片段都可能误触发) |
| 返回 502 | Telegram 拒绝或超时:检查 Token、Chat ID,以及 B 手机是否已对 Bot 点过 Start |
| curl 正常但微信通知没转发 | 自动化未触发:检查专注模式、微信通知预览设置、自动化是否设为「立即运行」 |
| 有时转发有时不转发 | 系统对后台任务的限制,无法保证每条必达 |
11. 已知限制(写进文章,别隐瞒)
- 明文经过三方,见 §2。
- 群聊:群通知的标题是群名,发送者通常在正文里。白名单放行群名,等于放行群内所有人的消息;在
notify模式下只会看到「群名 发来消息」。 - 敏感词过滤只是兜底:两位数验证码、被空格拆开的数字、其他语言措辞、图片/语音类通知都可能漏过。真正的防线是白名单。
- 数字规则会误伤:包含 4–8 位连续数字的正常消息(金额、日期、订单号)在
full模式下会被拦截。 - 不保证每条必达:通知自动化依赖系统调度,Worker 也没有持久队列,失败不会自动补发。
- 不处理图片、语音、文件:只有通知文字能被转发。
- 不支持从 B 回复到微信,本文方案只做单向提醒。 微信在快捷指令中确实提供「发送消息」动作,但收件人只能选系统通讯录里的联系人,而不是微信联系人,群聊更无法支持,因此无法作为通用回复通道。反向通道还等于「替你说话」,安全要求远高于单向通知,本文有意不涉及。
- 静态共享密钥:没有时间戳与重放防护,安全性依赖密钥保密和限流。
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