4564 字
23 分钟
阅读量
基于 Cloudflare Worker 搭建轻量多渠道 Webhook 消息转发网关

在日常运维与开发中,服务器监控告警、自动化脚本(Shell/Python)、定时任务以及各类服务(如 Uptime Kuma、爱快软路由)通常需要将通知实时推送到手机端。

传统的做法是在 VPS 服务器上使用 Go、Python 或 Node.js 编写常驻后台服务来接收 Webhook 并调用推送 API。但常驻服务存在以下维护成本:

  1. 占用服务器内存与 CPU 资源,需要配置守护进程(Systemd/Supervisor);
  2. 存在端口暴露与服务器宕机导致告警失联的风险;
  3. 需要处理日志轮转(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 图标。

接口参数规范#

参数名类型必填传递位置说明
tokenStringQuery / Header授权认证密钥(支持 ?token=X-Token / X-Gotify-Key
titleStringBody / Query消息标题(可选,默认「无标题消息」)
message / contentStringBody / Query消息正文(支持多行换行、特殊字符与长文本)
typeStringBody / Query传入 markdown 可开启企业微信/Telegram富文本排版

环境变量与机密配置#

在 Cloudflare Worker 的 Settings -> Variables and Secrets 中添加以下配置(按需开启对应渠道,所有敏感密钥建议设为 Encrypted Secret):

变量名类型示例值说明
WECOM_CORPIDSecretww1234567890abcdef企业微信 CorpID
WECOM_SECRETSecretk9s8...xYz1企业微信应用 Secret
WECOM_AGENTIDPlaintext1000002企业微信应用 AgentID
TOKEN_CONFIGSPlaintext[{"apiToken":"token1","receiver":"@all","msgCategory":"告警"}]接口鉴权 Token 列表(JSON 字符串)
BARK_KEYPlaintextdevice_key(可选)Bark 推送 Key
TG_BOT_TOKENSecret123456:ABC-DEF...(可选)Telegram Bot Token
TG_CHAT_IDPlaintext987654321(可选)Telegram Chat ID
DINGTALK_TOKENPlaintexttoken_or_webhook(可选)钉钉机器人 Token 或完整 Webhook
FEISHU_WEBHOOKPlaintextwebhook_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 引导页,方便随时调用与查看接口规范。