4564 字
23 分钟
阅读量
基于 Cloudflare Worker 搭建轻量多渠道 Webhook 消息转发网关
在日常运维与开发中,服务器监控告警、自动化脚本(Shell/Python)、定时任务以及各类服务(如 Uptime Kuma、爱快软路由)通常需要将通知实时推送到手机端。
传统的做法是在 VPS 服务器上使用 Go、Python 或 Node.js 编写常驻后台服务来接收 Webhook 并调用推送 API。但常驻服务存在以下维护成本:
- 占用服务器内存与 CPU 资源,需要配置守护进程(Systemd/Supervisor);
- 存在端口暴露与服务器宕机导致告警失联的风险;
- 需要处理日志轮转(Logrotate),避免长期运行写满磁盘。
本文介绍如何利用 Cloudflare Worker 无服务器(Serverless)架构,零成本搭建一个高可用、免维护的多渠道 Webhook 消息转发网关,并内置现代护眼温润风格的 Web 引导看板。
架构拓扑
消息由监控端或脚本发起 HTTP Webhook 请求,Cloudflare 边缘节点接收请求、鉴权后并发分发给已配置的消息通道:
+-------------------+ +-----------------------+ +-------------------+| 各种监控/脚本 | ----> | Cloudflare Worker | ----> | 企业微信 / Bark || (Shell/Python/路由) | | (边缘鉴权与格式解析) | | Telegram/钉钉/飞书|+-------------------+ +-----------------------+ +-------------------+核心特性
- Serverless 零维护:无需自建服务器、不占本地资源、全球边缘节点毫秒级响应。
- 全渠道静默分发:内置企业微信、Bark (iOS)、Telegram、钉钉、飞书通道,未配置的渠道自动跳过,已配置渠道并发秒级送达。
- 多格式协议兼容:自动解析 JSON 格式、表单(Form)格式、URL Query 参数、纯文本 Body,原生兼容 Gotify 客户端协议。
- 超长安全截断:自动处理超长消息,防止超出渠道 API 长度限制导致拒收。
- 现代护眼温润 Web 看板:
- 采用
#f8fafc浅色低反光护眼底色,长时间阅读舒适不刺眼; - 绿色实时呼吸指示灯,动态加载服务状态与有效密钥数量;
- 接口参数规范速查表与一键复制代码卡片;
- 自带高辨识度专属矢量 Favicon 图标。
- 采用
接口参数规范
| 参数名 | 类型 | 必填 | 传递位置 | 说明 |
|---|---|---|---|---|
token | String | 是 | Query / Header | 授权认证密钥(支持 ?token= 或 X-Token / X-Gotify-Key) |
title | String | 否 | Body / Query | 消息标题(可选,默认「无标题消息」) |
message / content | String | 是 | Body / Query | 消息正文(支持多行换行、特殊字符与长文本) |
type | String | 否 | Body / Query | 传入 markdown 可开启企业微信/Telegram富文本排版 |
环境变量与机密配置
在 Cloudflare Worker 的 Settings -> Variables and Secrets 中添加以下配置(按需开启对应渠道,所有敏感密钥建议设为 Encrypted Secret):
| 变量名 | 类型 | 示例值 | 说明 |
|---|---|---|---|
WECOM_CORPID | Secret | ww1234567890abcdef | 企业微信 CorpID |
WECOM_SECRET | Secret | k9s8...xYz1 | 企业微信应用 Secret |
WECOM_AGENTID | Plaintext | 1000002 | 企业微信应用 AgentID |
TOKEN_CONFIGS | Plaintext | [{"apiToken":"token1","receiver":"@all","msgCategory":"告警"}] | 接口鉴权 Token 列表(JSON 字符串) |
BARK_KEY | Plaintext | device_key | (可选)Bark 推送 Key |
TG_BOT_TOKEN | Secret | 123456:ABC-DEF... | (可选)Telegram Bot Token |
TG_CHAT_ID | Plaintext | 987654321 | (可选)Telegram Chat ID |
DINGTALK_TOKEN | Plaintext | token_or_webhook | (可选)钉钉机器人 Token 或完整 Webhook |
FEISHU_WEBHOOK | Plaintext | webhook_url | (可选)飞书自定义机器人 Webhook |
完整代码实现
在 Cloudflare 创建 Worker,将以下代码粘贴至编辑器中并部署:
/** * Cloudflare Worker - 全能多渠道 Webhook 消息转发网关 * 特性: * 1. 深度优化柔和暖调护眼浅色(消除纯白眩光)+ 微微黑柔和暗色自由切换 * 2. 宽屏(30寸/2K/4K)与手机端全屏自适应 + 专属 SVG Favicon * 3. 多渠道静默分发(企业微信 / Bark / Telegram / 钉钉 / 飞书) * 4. 兼容 Gotify、Webhook、JSON、表单等多种调用协议 */
let cachedWeComToken = { token: null, expiresAt: 0 };
const FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none"> <defs> <linearGradient id="grad" x1="8" y1="8" x2="56" y2="56" gradientUnits="userSpaceOnUse"> <stop stop-color="#0284c7"/> <stop offset="1" stop-color="#059669"/> </linearGradient> </defs> <rect width="64" height="64" rx="16" fill="#eef2f6"/> <rect x="1.5" y="1.5" width="61" height="61" rx="14.5" stroke="#cbd5e1" stroke-width="2"/> <path d="M16 36L26 26L36 36L48 20" stroke="url(#grad)" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/> <circle cx="16" cy="36" r="4.5" fill="#0284c7"/> <circle cx="26" cy="26" r="4.5" fill="#0284c7"/> <circle cx="36" cy="36" r="4.5" fill="#059669"/> <circle cx="48" cy="20" r="5.5" fill="#10b981"/> <circle cx="48" cy="20" r="9.5" stroke="#10b981" stroke-width="1.5" stroke-opacity="0.6"/></svg>`;
export default { async fetch(request, env, ctx) { const url = new URL(request.url); const path = url.pathname;
if (path === '/favicon.ico' || path === '/favicon.svg') { return new Response(FAVICON_SVG, { headers: { 'Content-Type': 'image/svg+xml; charset=utf-8', 'Cache-Control': 'public, max-age=604800, immutable' } }); }
if (path === '/' || path === '/index.html') { return handleIndex(request); } if (path === '/health') { return handleHealth(env); } if (path === '/message') { return handleMessage(request, url, env, ctx); }
return Response.redirect(`${url.origin}/health`, 301); },};
function handleIndex(request) { const host = request.headers.get('host') || 'sms.example.com'; const html = `<!DOCTYPE html><html lang="zh-CN"><head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"> <link rel="icon" type="image/svg+xml" href="/favicon.ico"> <title>Webhook 消息推送网关</title> <style> :root { --bg: #ebeff4; --surface: #f6f8fb; --surface-sub: #e2e8f0; --border: #d1d8e2; --border-sub: #b8c3d2; --accent: #0284c7; --accent-hover: #0369a1; --accent-light: rgba(2, 132, 199, 0.12); --text: #1e293b; --text-muted: #576579; --success: #059669; --success-bg: #def7ec; --success-border: #9ee3c3; --code-bg: #e5ebf2; --code-text: #0f172a; --table-th: #e2e8f0; } [data-theme="dark"] { --bg: #0b1120; --surface: #1e293b; --surface-sub: #2d3a4f; --border: #334155; --border-sub: #475569; --accent: #38bdf8; --accent-hover: #0ea5e9; --accent-light: rgba(56, 189, 248, 0.15); --text: #f1f5f9; --text-muted: #94a3b8; --success: #10b981; --success-bg: rgba(16, 185, 129, 0.14); --success-border: rgba(16, 185, 129, 0.35); --code-bg: #0f172a; --code-text: #f8fafc; --table-th: #182234; } * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif; background: var(--bg); color: var(--text); line-height: 1.6; padding: 2.5rem 2rem; min-height: 100vh; display: flex; justify-content: center; transition: background 0.3s, color 0.3s; -webkit-font-smoothing: antialiased; } .container { max-width: 1400px; width: 100%; min-width: 0; }
/* Header */ header { margin-bottom: 1.75rem; display: flex; justify-content: space-between; align-items: flex-start; gap: 1rem; } .header-content { flex: 1; min-width: 0; } .badge { display: inline-block; font-size: 0.75rem; font-weight: 600; color: var(--accent); background: var(--accent-light); border: 1px solid var(--border); padding: 0.22rem 0.7rem; border-radius: 9999px; margin-bottom: 0.6rem; } .title-wrap { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.4rem; } .header-logo { width: 36px; height: 36px; border-radius: 9px; flex-shrink: 0; } h1 { font-size: 2.1rem; font-weight: 800; color: var(--text); letter-spacing: -0.02em; line-height: 1.25; } .subtitle { color: var(--text-muted); font-size: 0.98rem; line-height: 1.5; }
/* Theme Toggle Button */ .theme-toggle-btn { background: var(--surface); border: 1px solid var(--border); color: var(--text); width: 40px; height: 40px; border-radius: 10px; display: inline-flex; align-items: center; justify-content: center; cursor: pointer; font-size: 1.15rem; transition: all 0.2s ease; box-shadow: 0 1px 3px rgba(0,0,0,0.04); flex-shrink: 0; margin-top: 4px; } .theme-toggle-btn:hover { background: var(--surface-sub); border-color: var(--border-sub); transform: scale(1.05); }
/* Status Bar */ .status-bar { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 1.1rem 1.4rem; margin-bottom: 1.5rem; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.03); } .status-indicator { display: inline-flex; align-items: center; gap: 0.65rem; font-size: 0.92rem; font-weight: 600; color: var(--success); background: var(--success-bg); border: 1px solid var(--success-border); padding: 0.38rem 0.85rem; border-radius: 8px; } .pulse-dot { width: 8px; height: 8px; background-color: var(--success); border-radius: 50%; box-shadow: 0 0 0 0 rgba(5, 150, 105, 0.7); animation: pulse 2s infinite; flex-shrink: 0; } @keyframes pulse { 0% { transform: scale(0.95); box-shadow: 0 0 0 0 rgba(5, 150, 105, 0.7); } 70% { transform: scale(1); box-shadow: 0 0 0 7px rgba(5, 150, 105, 0); } 100% { transform: scale(0.95); box-shadow: 0 0 0 0 rgba(5, 150, 105, 0); } } .status-meta { display: flex; align-items: center; gap: 1.5rem; font-size: 0.88rem; color: var(--text-muted); } .meta-item { display: inline-flex; align-items: center; gap: 0.35rem; white-space: nowrap; } .meta-label { color: var(--text-muted); } .meta-val { color: var(--text); font-weight: 600; white-space: nowrap; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; }
/* Panel Card */ .panel { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 1.5rem; margin-bottom: 1.5rem; box-shadow: 0 1px 3px rgba(0,0,0,0.03); min-width: 0; } .panel-title { font-size: 1.15rem; font-weight: 700; color: var(--text); margin-bottom: 0.85rem; display: flex; align-items: center; gap: 0.5rem; } .panel-desc { font-size: 0.9rem; color: var(--text-muted); margin-bottom: 0.85rem; word-break: break-all; }
/* Responsive Table Container */ .table-container { overflow-x: auto; -webkit-overflow-scrolling: touch; margin-top: 0.5rem; border: 1px solid var(--border); border-radius: 8px; width: 100%; } table { width: 100%; min-width: 540px; border-collapse: collapse; font-size: 0.88rem; text-align: left; background: var(--surface); } th { background: var(--table-th); color: var(--text-muted); font-weight: 600; padding: 0.75rem 1rem; border-bottom: 1px solid var(--border); white-space: nowrap; } td { padding: 0.75rem 1rem; border-bottom: 1px solid var(--border); color: var(--text); line-height: 1.55; vertical-align: middle; } tr:last-child td { border-bottom: none; } td code { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; background: var(--surface-sub); padding: 0.15rem 0.45rem; border-radius: 4px; color: var(--accent); font-size: 0.84rem; border: 1px solid var(--border); word-break: break-all; }
/* Code Block & Header */ .code-container { margin-top: 0.9rem; border-radius: 8px; overflow: hidden; background: var(--surface); border: 1px solid var(--border); } .code-header { padding: 0.85rem 1.1rem; background: var(--surface-sub); color: var(--text); display: flex; justify-content: space-between; align-items: center; gap: 0.85rem; cursor: pointer; user-select: none; border-bottom: 1px solid transparent; transition: background 0.2s, border-color 0.2s; } .code-header:hover { opacity: 0.9; } .code-header.unfolded { border-bottom-color: var(--border); }
.header-left { display: flex; align-items: center; gap: 0.55rem; min-width: 0; flex: 1; } .arrow { font-size: 0.75rem; color: var(--text-muted); transition: transform 0.2s; flex-shrink: 0; } .code-header.unfolded .arrow { transform: rotate(90deg); } .header-title { font-size: 0.88rem; font-weight: 600; color: var(--text); line-height: 1.45; word-break: break-word; }
.copy-btn { background: var(--surface); border: 1px solid var(--border-sub); color: var(--text); padding: 0.28rem 0.7rem; border-radius: 6px; font-size: 0.78rem; font-weight: 500; cursor: pointer; display: inline-flex; align-items: center; gap: 0.3rem; flex-shrink: 0; white-space: nowrap; transition: all 0.2s; } .copy-btn:hover { background: var(--accent-light); color: var(--accent); border-color: var(--accent); }
.code-content { max-height: 0; overflow: hidden; transition: max-height 0.35s cubic-bezier(0.4, 0, 0.2, 1); } .code-content pre { padding: 1.1rem 1.25rem; white-space: pre-wrap; word-wrap: break-word; word-break: break-all; color: var(--code-text); background: var(--code-bg); font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: 0.84rem; line-height: 1.6; overflow-x: auto; }
/* Toast */ .toast { position: fixed; bottom: 24px; right: 24px; background: var(--surface); color: var(--text); border: 1px solid var(--border); font-weight: 600; font-size: 0.88rem; padding: 0.75rem 1.3rem; border-radius: 8px; box-shadow: 0 10px 25px rgba(0,0,0,0.15); z-index: 9999; opacity: 0; transform: translateY(20px); transition: all 0.25s ease; pointer-events: none; } .toast.show { opacity: 1; transform: translateY(0); }
/* Footer */ footer { text-align: center; margin-top: 3rem; color: var(--text-muted); font-size: 0.85rem; }
/* Mobile Clean Alignment */ @media (max-width: 640px) { body { padding: 1.2rem 0.85rem; } .container { max-width: 100%; } h1 { font-size: 1.45rem; } .header-logo { width: 28px; height: 28px; } .subtitle { font-size: 0.86rem; } .panel { padding: 1rem 0.9rem; border-radius: 10px; margin-bottom: 1rem; } .panel-title { font-size: 1.02rem; } .status-bar { padding: 0.85rem 0.95rem; flex-direction: column; align-items: stretch; gap: 8px; border-radius: 10px; } .status-indicator { width: 100%; justify-content: flex-start; } .status-meta { width: 100%; display: flex; flex-direction: column; gap: 5px; padding-top: 7px; border-top: 1px dashed var(--border); } .meta-item { display: flex; justify-content: space-between; align-items: center; width: 100%; font-size: 0.82rem; } .code-header { padding: 0.65rem 0.8rem; } .header-title { font-size: 0.82rem; } .code-content pre { padding: 0.75rem 0.8rem; font-size: 0.76rem; } .toast { left: 50%; right: auto; transform: translate(-50%, 20px); width: max-content; } .toast.show { transform: translate(-50%, 0); } } </style></head><body> <div class="toast" id="toast">✓ 已复制到剪贴板</div>
<div class="container"> <header> <div class="header-content"> <div class="badge">Serverless Multi-Channel Push</div> <div class="title-wrap"> <img src="/favicon.ico" class="header-logo" alt="Logo" /> <h1>Webhook 消息推送网关</h1> </div> <p class="subtitle">高可用边缘推送架构 · 支持企业微信、Bark、Telegram、钉钉、飞书多渠道秒级转发</p> </div> <button class="theme-toggle-btn" id="themeToggle" onclick="toggleTheme()" title="切换明亮/暗色模式">🌙</button> </header>
<div class="status-bar"> <div class="status-indicator" id="healthStatus"> <div class="pulse-dot"></div> <span>正在检测服务健康状态...</span> </div> <div class="status-meta"> <div class="meta-item"> <span class="meta-label">有效密钥:</span> <span class="meta-val"><strong id="token-count">--</strong> 个</span> </div> <div class="meta-item"> <span class="meta-label">服务架构:</span> <span class="meta-val">Cloudflare Worker</span> </div> </div> </div>
<!-- 接口参数规范 --> <div class="panel"> <div class="panel-title">📋 接口参数规范</div> <p class="panel-desc"> 请求路径:<code style="color:var(--accent); background:var(--surface-sub); border:1px solid var(--border); padding:2px 6px; border-radius:4px;">https://${host}/message</code>(支持 POST、GET、PUT、PATCH) </p> <div class="table-container"> <table> <thead> <tr> <th>参数名</th> <th>类型</th> <th>必填</th> <th>位置</th> <th>说明</th> </tr> </thead> <tbody> <tr> <td><code>token</code></td> <td>String</td> <td>是</td> <td>Query / Header</td> <td>通道授权密钥(支持 URL <code>?token=</code> 或 <code>X-Token</code> / <code>X-Gotify-Key</code>)</td> </tr> <tr> <td><code>title</code></td> <td>String</td> <td>否</td> <td>Body / Query</td> <td>消息标题(可选,默认「无标题消息」)</td> </tr> <tr> <td><code>message</code> / <code>content</code></td> <td>String</td> <td>是</td> <td>Body / Query</td> <td>消息正文,支持换行与长文本(超长自动截断)</td> </tr> <tr> <td><code>type</code></td> <td>String</td> <td>否</td> <td>Body / Query</td> <td>传 <code>markdown</code> 可启用富文本渲染</td> </tr> </tbody> </table> </div> </div>
<!-- 核心推送命令 --> <div class="panel"> <div class="panel-title">📤 核心推送命令与示例</div>
<div class="code-container"> <div class="code-header unfolded"> <div class="header-left"> <span class="arrow">▶</span> <span class="header-title">cURL - POST JSON 格式(推荐首选,支持标题+正文)</span> </div> <button class="copy-btn" onclick="copyCode(event, this)">📋 复制</button> </div> <div class="code-content" style="max-height: 500px;"> <pre>curl -X POST "https://${host}/message?token=你的密钥" \ -H "Content-Type: application/json" \ -d '{ "title": "生产环境告警", "message": "Webhook 通道测试成功!当前服务已正常接入。" }'</pre> </div> </div>
<div class="code-container"> <div class="code-header collapsed"> <div class="header-left"> <span class="arrow">▶</span> <span class="header-title">cURL - GET 极速调用格式(适合浏览器/简单测试)</span> </div> <button class="copy-btn" onclick="copyCode(event, this)">📋 复制</button> </div> <div class="code-content"> <pre>curl "https://${host}/message?token=你的密钥&title=通知测试&content=通过GET直接发送的消息内容"</pre> </div> </div>
<div class="code-container"> <div class="code-header collapsed"> <div class="header-left"> <span class="arrow">▶</span> <span class="header-title">Shell 告警函数(适合 Linux / Cron / 自动化脚本)</span> </div> <button class="copy-btn" onclick="copyCode(event, this)">📋 复制</button> </div> <div class="code-content"> <pre>send_alert() { local title="$1" local msg="$2" local token="你的密钥" curl -s -X POST "https://${host}/message?token=$token" \ -H "Content-Type: application/json" \ -d "{\"title\": \"$title\", \"message\": \"$msg\"}"}
# 调用示例:send_alert "服务器磁盘告警" "根分区使用率已达 92%,请及时处理。"</pre> </div> </div>
<div class="code-container"> <div class="code-header collapsed"> <div class="header-left"> <span class="arrow">▶</span> <span class="header-title">Python 推送模块(基于 requests)</span> </div> <button class="copy-btn" onclick="copyCode(event, this)">📋 复制</button> </div> <div class="code-content"> <pre>import requests
def notify(title: str, text: str, token: str = "你的密钥"): url = f"https://${host}/message?token={token}" payload = {"title": title, "message": text} resp = requests.post(url, json=payload, timeout=8) return resp.json()
# 调用示例:notify("备份任务完成", "数据库每日增量快照已同步上传至云端存储。")</pre> </div> </div>
<div class="code-container"> <div class="code-header collapsed"> <div class="header-left"> <span class="arrow">▶</span> <span class="header-title">JavaScript / Node.js(基于标准 Fetch API)</span> </div> <button class="copy-btn" onclick="copyCode(event, this)">📋 复制</button> </div> <div class="code-content"> <pre>async function sendPush(title, message, token = "你的密钥") { const res = await fetch(\`https://${host}/message?token=\${token}\`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title, message }) }); return await res.json();}
// 调用示例:sendPush("编译构建通知", "版本 v1.0.0 已成功打包发布。");</pre> </div> </div>
<div class="code-container"> <div class="code-header collapsed"> <div class="header-left"> <span class="arrow">▶</span> <span class="header-title">Gotify 协议兼容对接(支持 Gotify 官方客户端)</span> </div> <button class="copy-btn" onclick="copyCode(event, this)">📋 复制</button> </div> <div class="code-content"> <pre>curl -X POST "https://${host}/message" \ -H "X-Gotify-Key: 你的密钥" \ -H "Content-Type: application/json" \ -d '{ "title": "Gotify 协议通知", "message": "直接兼容 Gotify 客户端消息结构投递", "priority": 5 }'</pre> </div> </div>
</div>
<footer> Powered by Cloudflare Workers · 高可用 Serverless 消息网关 </footer> </div>
<script> function initTheme() { const saved = localStorage.getItem('theme'); const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; if (saved === 'dark' || (!saved && prefersDark)) { document.documentElement.setAttribute('data-theme', 'dark'); updateThemeBtn(true); } else { document.documentElement.setAttribute('data-theme', 'light'); updateThemeBtn(false); } } function toggleTheme() { const isDark = document.documentElement.getAttribute('data-theme') === 'dark'; const next = isDark ? 'light' : 'dark'; document.documentElement.setAttribute('data-theme', next); localStorage.setItem('theme', next); updateThemeBtn(!isDark); } function updateThemeBtn(isDark) { const btn = document.getElementById('themeToggle'); if (btn) btn.innerHTML = isDark ? '☀️' : '🌙'; } initTheme();
function showToast() { const t = document.getElementById('toast'); t.classList.add('show'); setTimeout(() => t.classList.remove('show'), 2200); }
function copyCode(e, btn) { e.stopPropagation(); const container = btn.closest('.code-container'); const pre = container.querySelector('pre'); const text = pre.innerText.trim();
navigator.clipboard.writeText(text).then(() => { showToast(); const orig = btn.innerText; btn.innerText = '✓ 已复制'; setTimeout(() => btn.innerText = orig, 1800); }).catch(() => { const ta = document.createElement('textarea'); ta.value = text; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); showToast(); }); }
document.querySelectorAll('.code-header').forEach(header => { header.addEventListener('click', (e) => { if (e.target.classList.contains('copy-btn')) return; const content = header.nextElementSibling; const isCollapsed = header.classList.contains('collapsed'); if (isCollapsed) { header.classList.remove('collapsed'); header.classList.add('unfolded'); content.style.maxHeight = content.scrollHeight + 'px'; } else { header.classList.remove('unfolded'); header.classList.add('collapsed'); content.style.maxHeight = '0'; } }); });
async function loadHealth() { const el = document.getElementById('healthStatus'); const countEl = document.getElementById('token-count'); try { const res = await fetch('/health'); if (!res.ok) throw new Error(); const data = await res.json(); if (data.code === 200) { el.innerHTML = '<div class="pulse-dot"></div><span>● 服务正常运行</span>'; countEl.textContent = data.valid_token_count || 0; } } catch (err) { el.innerHTML = '<span style="color:#ef4444;">● 服务异常</span>'; countEl.textContent = '0'; } } window.addEventListener('DOMContentLoaded', loadHealth); </script></body></html>`;
return new Response(html, { headers: { 'Content-Type': 'text/html; charset=utf-8' }, });}
function handleHealth(env) { const tokens = parseTokenConfigs(env); const now = new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' });
const channels = { wecom: Boolean(env.WECOM_CORPID && env.WECOM_SECRET && env.WECOM_AGENTID), bark: Boolean(env.BARK_KEY || env.BARK_SERVER), telegram: Boolean(env.TG_BOT_TOKEN && env.TG_CHAT_ID), dingtalk: Boolean(env.DINGTALK_TOKEN), feishu: Boolean(env.FEISHU_WEBHOOK), };
const data = { code: 200, msg: "服务正常运行", time: now, service: "Cloudflare Serverless Notification Gateway", valid_token_count: tokens.length, active_channels: channels, };
return new Response(JSON.stringify(data, null, 2), { headers: { 'Content-Type': 'application/json; charset=utf-8' }, });}
async function handleMessage(request, url, env, ctx) { const clientIP = request.headers.get('cf-connecting-ip') || 'unknown'; const token = url.searchParams.get('token') || request.headers.get('x-token') || request.headers.get('x-gotify-key');
if (!token) { console.warn(`[拒绝] IP: ${clientIP} 未提供Token`); return jsonResponse({ status: 'fail', error: '未提供Token' }, 403); }
const tokenConfigs = parseTokenConfigs(env); const matched = tokenConfigs.find((t) => t.apiToken === token); if (!matched) { console.warn(`[拒绝] IP: ${clientIP} 无效Token`); return jsonResponse({ status: 'fail', error: '无效Token' }, 403); }
const { title, message, formatType, isMarkdown } = await extractMessage(request, url); if (!message || message.trim() === '') { return jsonResponse({ status: 'success', msg: '无有效消息内容,已跳过转发', format_type: formatType, }); }
const finalTitle = title || '无标题消息'; const category = matched.msgCategory || '默认通知'; const receiver = matched.receiver || '@all'; const safeMessage = truncateSafe(message, 1500); const nowStr = new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' });
const fullContent = `【${finalTitle}】内容:${safeMessage}来源:${category}发送时间:${nowStr}`;
const dispatchTasks = [];
if (env.WECOM_CORPID && env.WECOM_SECRET && env.WECOM_AGENTID) { dispatchTasks.push( sendWeCom(env, receiver, finalTitle, safeMessage, category, isMarkdown, nowStr) .then(() => ({ channel: 'wecom', success: true })) .catch((e) => ({ channel: 'wecom', success: false, error: e.message })) ); }
const barkKey = matched.barkKey || env.BARK_KEY; if (barkKey) { dispatchTasks.push( sendBark(env, barkKey, finalTitle, fullContent) .then(() => ({ channel: 'bark', success: true })) .catch((e) => ({ channel: 'bark', success: false, error: e.message })) ); }
const tgToken = env.TG_BOT_TOKEN; const tgChatId = matched.tgChatId || env.TG_CHAT_ID; if (tgToken && tgChatId) { dispatchTasks.push( sendTelegram(tgToken, tgChatId, finalTitle, safeMessage, category, nowStr) .then(() => ({ channel: 'telegram', success: true })) .catch((e) => ({ channel: 'telegram', success: false, error: e.message })) ); }
const dingToken = matched.dingtalkToken || env.DINGTALK_TOKEN; if (dingToken) { dispatchTasks.push( sendDingTalk(dingToken, finalTitle, fullContent) .then(() => ({ channel: 'dingtalk', success: true })) .catch((e) => ({ channel: 'dingtalk', success: false, error: e.message })) ); }
const feishuWebhook = matched.feishuWebhook || env.FEISHU_WEBHOOK; if (feishuWebhook) { dispatchTasks.push( sendFeishu(feishuWebhook, finalTitle, fullContent) .then(() => ({ channel: 'feishu', success: true })) .catch((e) => ({ channel: 'feishu', success: false, error: e.message })) ); }
if (dispatchTasks.length === 0) { return jsonResponse({ status: 'fail', error: '服务端未配置任何有效的消息通道密钥', }, 500); }
const results = await Promise.all(dispatchTasks); const successCount = results.filter((r) => r.success).length;
return jsonResponse({ status: successCount > 0 ? 'success' : 'fail', msg: `消息推送完成(成功: ${successCount}/${results.length})`, channels: results, timestamp: nowStr, }, successCount > 0 ? 200 : 500);}
async function sendWeCom(env, receiver, title, message, category, isMarkdown, nowStr) { const token = await getWeComAccessToken(env); let payload; if (isMarkdown) { const mdContent = `### ${title}> **内容**:${message}> **来源**:<font color="comment">${category}</font>> **时间**:<font color="comment">${nowStr}</font>`; payload = { touser: receiver, msgtype: 'markdown', agentid: parseInt(env.WECOM_AGENTID, 10), markdown: { content: mdContent }, }; } else { payload = { touser: receiver, msgtype: 'text', agentid: parseInt(env.WECOM_AGENTID, 10), text: { content: `【${title}】内容:${message}来源:${category}发送时间:${nowStr}` }, safe: 0, }; } const res = await fetch(`https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=${token}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); const data = await res.json(); if (data.errcode !== 0) throw new Error(`[${data.errcode}] ${data.errmsg}`); return data;}
async function getWeComAccessToken(env) { const now = Math.floor(Date.now() / 1000); if (cachedWeComToken.token && cachedWeComToken.expiresAt > now + 60) { return cachedWeComToken.token; } const res = await fetch( `https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=${encodeURIComponent(env.WECOM_CORPID)}&corpsecret=${encodeURIComponent(env.WECOM_SECRET)}` ); const data = await res.json(); if (data.errcode !== 0) throw new Error(`WeCom Token失败: [${data.errcode}] ${data.errmsg}`); cachedWeComToken = { token: data.access_token, expiresAt: now + (data.expires_in || 7200) }; return cachedWeComToken.token;}
async function sendBark(env, deviceKey, title, content) { const server = (env.BARK_SERVER || 'https://api.day.app').replace(/\/+$/, ''); const res = await fetch(`${server}/${encodeURIComponent(deviceKey)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title, body: content, group: 'Webhook通知' }), }); const data = await res.json(); if (data.code !== 200) throw new Error(data.message || 'Bark推送失败'); return data;}
async function sendTelegram(botToken, chatId, title, message, category, nowStr) { const text = `*${escapeTg(title)}*
${escapeTg(message)}
_来源: ${escapeTg(category)}_ | _${escapeTg(nowStr)}_`; const res = await fetch(`https://api.telegram.org/bot${botToken}/sendMessage`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ chat_id: chatId, text, parse_mode: 'Markdown' }), }); const data = await res.json(); if (!data.ok) throw new Error(`[${data.error_code}] ${data.description}`); return data;}
async function sendDingTalk(token, title, content) { const webhook = token.startsWith('http') ? token : `https://oapi.dingtalk.com/robot/send?access_token=${token}`; const res = await fetch(webhook, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ msgtype: 'text', text: { content: `${title}
${content}` } }), }); const data = await res.json(); if (data.errcode !== 0) throw new Error(`[${data.errcode}] ${data.errmsg}`); return data;}
async function sendFeishu(webhookUrl, title, content) { const url = webhookUrl.startsWith('http') ? webhookUrl : `https://open.feishu.cn/open-apis/bot/v2/hook/${webhookUrl}`; const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ msg_type: 'text', content: { text: `${title}
${content}` } }), }); const data = await res.json(); if (data.code !== 0 && data.StatusCode !== 0) throw new Error(`[${data.code || data.StatusCode}] ${data.msg}`); return data;}
function parseTokenConfigs(env) { try { if (!env.TOKEN_CONFIGS) return []; return JSON.parse(env.TOKEN_CONFIGS); } catch (e) { return []; }}
function truncateSafe(str, maxChars = 1500) { if (!str || str.length <= maxChars) return str; return str.slice(0, maxChars) + '... (内容超长已自动截断)';}
function escapeTg(text) { return (text || '').replace(/[_*[\]()~`>#+-=|{}.!]/g, '\$&');}
async function extractMessage(request, url) { const contentType = request.headers.get('content-type') || ''; const isMarkdown = url.searchParams.get('type') === 'markdown'; let title = ''; let message = ''; let formatType = '默认格式';
if (url.searchParams.get('content') || url.searchParams.get('message')) { title = url.searchParams.get('title') || url.searchParams.get('from') || ''; message = url.searchParams.get('content') || url.searchParams.get('message') || ''; formatType = 'URL参数格式'; return { title, message, formatType, isMarkdown }; }
if (request.method === 'POST' || request.method === 'PUT') { if (contentType.includes('application/json')) { try { const json = await request.json(); title = json.title || json.from || ''; message = json.message || json.content || (typeof json === 'string' ? json : JSON.stringify(json)); const md = isMarkdown || json.msgtype === 'markdown' || json.type === 'markdown'; formatType = 'JSON格式'; return { title, message, formatType, isMarkdown: md }; } catch (e) {} }
if (contentType.includes('application/x-www-form-urlencoded') || contentType.includes('multipart/form-data')) { try { const formData = await request.formData(); title = formData.get('title') || formData.get('from') || ''; message = formData.get('message') || formData.get('content') || ''; formatType = '表单格式'; return { title, message, formatType, isMarkdown }; } catch (e) {} }
try { const text = await request.text(); if (text && text.trim().startsWith('{')) { const parsed = JSON.parse(text); title = parsed.title || parsed.from || ''; message = parsed.message || parsed.content || text; formatType = 'JSON格式'; } else { message = text; formatType = '纯文本格式'; } } catch (e) {} }
return { title, message, formatType, isMarkdown };}
function jsonResponse(data, status = 200) { return new Response(JSON.stringify(data), { status, headers: { 'Content-Type': 'application/json; charset=utf-8' }, });}总结
通过 Cloudflare Worker 实现多渠道 Webhook 消息转发网关,不仅彻底免去了 VPS 端口维护与常驻后台进程开销,还能借助全球边缘网络获得极低的消息投递延迟。配合现代护眼温润风格的 Web 引导页,方便随时调用与查看接口规范。