网站咨询推送到飞书、钉钉、Slack 和 Telegram:一个转发脚本搞定
让新咨询自己找上门,比盯着工作台靠谱。这篇给一个可以直接用的转发脚本:收 Webhook、验签、去重,再按四个平台各自的消息格式发到你们的工作群里。
把网站咨询推送到飞书、钉钉、Slack 或 Telegram,只需要在 SealReply 和群之间放一个小程序:它接住 Webhook、校验签名,再把“谁问了什么”变成一句话发给群机器人。整个转发脚本是一个零依赖的 Node 文件,只接一个平台 50 行左右,四个平台全接一百行出头。
要点速览
- 四个平台都能用群机器人地址或机器人令牌往群里发消息,不用建应用审核,也不用走 OAuth。
- 脚本对着原始请求体校验
X-Signature,先回 200 再转发,并按X-SealReply-Event-Id去重。 - 订阅的事件只勾两个:“发送了消息或内部备注”和“访客留下离线留言”。
- 飞书和钉钉的加签算法正好相反,搞混是“群里收不到”最常见的原因。
- Webhook 是付费功能(入门版及以上);只想收提醒的话,手机推送不用写一行代码。
下面是完整的做法:先拿到四个平台的群机器人地址,再看脚本,然后部署到一台小服务器或者云函数上,最后是让群里不变成噪音的几个取舍。

为什么要把网站咨询推送到群里?
因为能回答难题的人都在群里,而不在客服工作台里。客服工作台是给坐下来回消息的人用的。但团队里还有一些人,他们不会整天开着工作台:做技术的、管发货的、老板本人。网站上有人问「这个能不能发美国」的时候,你希望这句话出现在他们本来就在看的那个群里,而不是指望谁想起来去刷一下后台。
推到群里还有两个顺带的好处:一是出现在群里的咨询,谁有空谁先答,不用先分配;二是群里有记录,晚上回头翻一遍就知道今天漏没漏人。
Webhook 是付费功能
Webhook 要入门版及以上套餐才有。如果你只是想在有人咨询时收到提醒,不用写代码:装手机 App 或开浏览器推送、设好工作时间就够了,人不在电脑前,怎么不漏掉网站上的客户里讲了这几种办法。Webhook 适合的是「要进我们自己的群、自己的系统」这种场合。
动手之前,先确认手上有这三样:
- 一个入门版及以上套餐的 SealReply 工作区,网站上已经装好在线客服插件。
- 一个能跑 Node 18 以上、有公网 HTTPS 地址的地方:一台小云主机,或者一个云函数。
- 你要推送的飞书群、钉钉群、Slack 工作区或 Telegram 群的管理权限(至少能加机器人)。
飞书、钉钉、Slack、Telegram 的群机器人地址怎么拿?
四个平台都有「往群里发消息」的最简接口,都不需要建应用、走 OAuth。拿到的东西不一样:Slack 和飞书给你一个完整的网址,钉钉给你网址加 token,Telegram 给你机器人令牌,群号要自己查。
Slack
完整说明见 Slack 官方的 Incoming Webhooks 文档,简单说就是:打开 api.slack.com/apps,Create New App → From scratch,起个名字、选工作区。进去之后在左边点 Incoming Webhooks,打开开关,再点 Add New Webhook to Workspace,选一个频道并授权。回到这个页面就能看到地址,形如 https://hooks.slack.com/services/T…/B…/…。
想发到私有频道的话,你自己得先在那个频道里。
Telegram
在 Telegram 里找 @BotFather,发 /newbot,按提示起名字,它会给你一串令牌(123456:AA…),每一步在 Telegram 官方的 BotFather 说明里都有。然后把这个机器人拉进你们的群,在群里随便发一句话,再在浏览器里打开:
https://api.telegram.org/bot<你的令牌>/getUpdates
返回的 JSON 里 chat.id 就是群号,群和频道的群号是负数,形如 -1001234567890,前面的减号要一起复制。
飞书
在群里点右上角 设置 → 群机器人 → 添加机器人 → 自定义机器人,起名字,然后在安全设置里至少选一项。建议选签名校验,它会给你一个密钥;也可以选自定义关键词,但那样每条消息里都必须带上那个词。最后拿到的地址形如 https://open.feishu.cn/open-apis/bot/v2/hook/<一串 token>。三种安全设置和签名算法,以飞书开放平台的自定义机器人使用指南为准。
钉钉
在群里点右上角 群设置 → 机器人 → 添加机器人 → 自定义,勾上同意,起名字,安全设置同样至少选一项(自定义关键词、加签、IP 地址段)。选加签会给你一个 SEC 开头的密钥,三种安全设置的说明见钉钉开放平台的自定义机器人安全设置。拿到的地址形如 https://oapi.dingtalk.com/robot/send?access_token=…。
四个平台认的请求体各不相同,脚本要按平台分别拼:
| 平台 | 请求体 |
|---|---|
| Slack | {"text": "…"} |
| Telegram | {"chat_id": "-100…", "text": "…"} |
| 飞书 | {"msg_type": "text", "content": {"text": "…"}} |
| 钉钉 | {"msgtype": "text", "text": {"content": "…"}} |
转发脚本做了哪几件事?

脚本做四件事:验签、去重、把事件变成一句话、按各平台的格式发出去。用 Node 18 以上,不需要 npm install,整个是一个 forward.mjs。
开头是验签。Webhook 的接收地址是公网上的,谁都能往上面发请求,所以每个请求都得对着原始请求体核一遍签名,顺手拒掉时间戳偏差超过 5 分钟的:
import { createServer } from "node:http";
import { createHmac, timingSafeEqual } from "node:crypto";
const SECRET = process.env.SEALREPLY_WEBHOOK_SECRET;
const APP = "https://app.sealreply.com";
const seen = new Map(); // 事件 id -> 收到的时间,用来去重
function verify(header, raw) {
const p = Object.fromEntries((header ?? "").split(",").map((x) => x.split("=", 2)));
if (!p.v1 || !/^\d+$/.test(p.t ?? "") || Math.abs(Date.now() / 1000 - Number(p.t)) > 300) return false;
const mine = Buffer.from(createHmac("sha256", SECRET).update(`${p.t}.`).update(raw).digest("hex"));
const theirs = Buffer.from(p.v1);
return mine.length === theirs.length && timingSafeEqual(mine, theirs);
}
这段为什么这么写、各语言的版本,在 Webhook 签名怎么验证里讲得更细。这里只强调一点:验签必须对着收到的那串字节算,不能先 JSON.parse 再 JSON.stringify 回去。
接着是把事件翻译成一句人话。我们只订阅两个事件:message.created(有人发了消息)和 offline_message.created(没人在线时访客留了言)。注意 message.created 对客服自己的回复也会触发,所以要按 sender.type 过滤掉,否则群里会出现你们自己说过的每一句话:
// 把事件变成一条通知:标题、正文、打开它的链接。返回 null 表示不转发。
function notice(ev) {
if (ev.type === "message.created") {
const { message: m, conversation: c } = ev.data;
if (m.sender.type !== "visitor") return null; // 只转访客说的话
return {
title: "新咨询",
body: m.text ?? `[${m.content_type}]`,
who: `访客 ${c.visitor_id.slice(0, 8)}`,
label: "打开会话",
link: `${APP}/conversations/${c.id}`,
};
}
if (ev.type === "offline_message.created") {
const o = ev.data.offline_message;
return {
title: "离线留言",
body: o.content,
who: `${o.name ?? "访客"} ${o.email}`,
label: "打开留言",
link: `${APP}/offline-messages`,
};
}
return null;
}
link 是这条通知最值钱的部分:点一下直接落到工作台的那条会话上,不用再去列表里翻。访客没留姓名时 visitor_id 的前 8 位就是个代号,同一个人连发三条你能看出来是同一个。
然后是发出去。配了哪个平台的环境变量就发哪个,一条都不配就只是干跑:
const post = (url, body) =>
fetch(url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
const plain = (n) => `${n.title}|${n.who}\n${n.body}\n${n.link}`;
// Slack 的 & < > 要转义,否则会被当成它自己的链接语法
const slackSafe = (s) => s.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
async function fanOut(n) {
const jobs = [];
if (process.env.SLACK_WEBHOOK_URL) {
jobs.push(post(process.env.SLACK_WEBHOOK_URL, {
text: `*${slackSafe(n.title)}*|${slackSafe(n.who)}\n${slackSafe(n.body)}\n<${n.link}|${n.label}>`,
}));
}
if (process.env.TELEGRAM_TOKEN) {
jobs.push(post(`https://api.telegram.org/bot${process.env.TELEGRAM_TOKEN}/sendMessage`, {
chat_id: process.env.TELEGRAM_CHAT_ID,
text: plain(n),
disable_web_page_preview: true,
}));
}
if (process.env.FEISHU_WEBHOOK_URL) {
const body = { msg_type: "text", content: { text: plain(n) } };
if (process.env.FEISHU_SECRET) {
const ts = Math.floor(Date.now() / 1000).toString(); // 秒
// 飞书:把 "时间戳\n密钥" 当作 HMAC 的密钥,内容为空
body.timestamp = ts;
body.sign = createHmac("sha256", `${ts}\n${process.env.FEISHU_SECRET}`).digest("base64");
}
jobs.push(post(process.env.FEISHU_WEBHOOK_URL, body));
}
if (process.env.DINGTALK_WEBHOOK_URL) {
let url = process.env.DINGTALK_WEBHOOK_URL;
if (process.env.DINGTALK_SECRET) {
const ts = Date.now().toString(); // 毫秒
// 钉钉:密钥当密钥,"时间戳\n密钥" 当内容,base64 之后再 URL 编码
const sign = createHmac("sha256", process.env.DINGTALK_SECRET)
.update(`${ts}\n${process.env.DINGTALK_SECRET}`).digest("base64");
url += `×tamp=${ts}&sign=${encodeURIComponent(sign)}`;
}
jobs.push(post(url, { msgtype: "text", text: { content: plain(n) } }));
}
for (const r of await Promise.allSettled(jobs)) {
if (r.status === "rejected") console.error("转发失败", r.reason);
else if (!r.value.ok) console.error("转发失败", r.value.status, await r.value.text());
}
}
飞书和钉钉的加签算法是反的
两家看起来差不多,其实正好相反,这是最容易卡住的地方,区别见下面这段。
飞书把 "时间戳\n密钥" 当作 HMAC 的密钥、待签内容是空的,时间戳是秒,签名放在请求体里;钉钉是密钥当密钥、"时间戳\n密钥" 当内容,时间戳是毫秒,base64 之后还要再做一次 URL 编码,拼在网址后面。照着对方文档抄的时候很容易把两者搞混,报错信息只会说签名不匹配。
最后是服务器本身。这里有两个要点:先回 200 再干活,以及按事件 id 去重:
createServer((req, res) => {
const chunks = [];
req.on("data", (c) => chunks.push(c));
req.on("end", async () => {
const raw = Buffer.concat(chunks);
if (req.method !== "POST" || !verify(req.headers["x-signature"], raw)) return res.writeHead(401).end();
res.writeHead(200).end(); // 先回 200,再慢慢转发
const id = req.headers["x-sealreply-event-id"];
if (seen.has(id)) return;
seen.set(id, Date.now());
for (const [k, t] of seen) if (Date.now() - t > 36e5) seen.delete(k);
try {
const n = notice(JSON.parse(raw.toString("utf8")));
if (n) await fanOut(n);
} catch (e) {
console.error("处理事件失败", e);
}
});
}).listen(process.env.PORT ?? 8080);
SealReply 要求 10 秒内返回任意 2xx,否则算失败,之后按 1 分钟、5 分钟、30 分钟、2 小时、6 小时、24 小时重试,连续失败 3 天的地址会被自动停用。四个平台里只要有一个卡住,等它返回再回 200 就可能超时,所以先把 200 发出去。
去重用的是 X-SealReply-Event-Id 请求头,重试和手动重发时它都不变。上面用一个内存里的 Map 存一小时,进程重启就空了——对「别在群里刷屏」这个目的来说够用了。
在 SealReply 工作台里怎么开 Webhook?
在工作台打开 开发者 → Webhook → 添加地址:
- 接收地址填你这台服务器的公网 HTTPS 地址,例如
https://hooks.你的域名.com/sealreply。内网地址和localhost会被拒绝。 - 订阅的事件只勾两个:「发送了消息或内部备注」和「访客留下离线留言」。少勾几个,群里就少几条用不上的消息。
- 保存后复制签名密钥(
whsec_…),它只显示这一次。密钥丢了就点更换密钥,旧的立即失效。
保存完点发送测试事件,它会投一个 ping 过去。ping 不在脚本认的两个类型里,所以群里不会出现消息,但在投递日志里应该看到 200。投递日志留 30 天,每次投递的状态码、耗时、下次重试时间和发出去的完整内容都在里面,排查问题基本只看这一页。真想看到群消息,就自己到网站上发一条咨询。
每个工作区最多 10 个地址,不够用就在脚本里按站点分流。
转发脚本部署在哪里?
一台最便宜的云主机就够,内存占用是个位数 MB。把脚本放到 /opt/sealreply-forward/forward.mjs,密钥写进一个只有 root 能读的环境变量文件:
# /etc/sealreply-forward.env(chmod 600)
SEALREPLY_WEBHOOK_SECRET=whsec_…
SLACK_WEBHOOK_URL=https://hooks.slack.com/services/…
FEISHU_WEBHOOK_URL=https://open.feishu.cn/open-apis/bot/v2/hook/…
FEISHU_SECRET=…
再写一个 systemd 单元,让它开机自启、崩了自动拉起:
# /etc/systemd/system/sealreply-forward.service
[Unit]
Description=SealReply webhook forwarder
After=network.target
[Service]
ExecStart=/usr/bin/node /opt/sealreply-forward/forward.mjs
EnvironmentFile=/etc/sealreply-forward.env
Restart=always
User=nobody
[Install]
WantedBy=multi-user.target
systemctl enable --now sealreply-forward 启动,journalctl -u sealreply-forward -f 看日志。
HTTPS 交给前面的反向代理。Caddy 最省事,三行就能自动签好证书:
hooks.你的域名.com {
reverse_proxy 127.0.0.1:8080
}
别让反向代理动请求体。 改写、重新压缩、改字符集都会让签名对不上。Caddy 和 Nginx 默认不会动,但如果你开过 sub_filter 之类的东西,记得把这个路径排除掉。
换成云函数
不想管服务器的话,这个脚本挪到云函数上也很顺:Cloudflare Workers、Vercel、阿里云和腾讯云的函数计算都行,逻辑几乎不用改,只有两点要注意。
一是拿原始请求体。云函数的运行时常常已经帮你解析好 JSON 了,要找到拿原始 body 的那个方法(Workers 里是 await request.text(),Vercel 的 Node 运行时要关掉 bodyParser),否则签名永远对不上。
二是去重。函数实例随时会被回收,内存里的 Map 不可靠。要么用一份 KV 存储放事件 id(设 1 小时过期),要么干脆不去重——重复投递本来就少见,群里偶尔重一条,比为它上一套存储划算。
怎么不让群变成噪音?

转发做通之后,下一个问题通常是「群太吵了,大家开始屏蔽这个机器人」。几个有效的取舍:
- 只转第一条。 一次会话往往来回十几条消息,但真正需要惊动别人的只有开头那条。判断办法是看
conversation.status:访客第一条进来时是waiting,有人接了之后就不是了。只在waiting时转发,一次咨询在群里就只有一条。 - 只转没人接的。 如果你们白天本来就有人盯工作台,就别转
message.created,只订阅offline_message.created,让群只负责兜住下班时段。 - 分群。 售前问题进销售群、报错进技术群:在脚本里按
conversation.site_id或消息内容的关键词选不同的群机器人地址。 - 别把内容全发出去。 群的权限通常比工作台松,建议正文截断到一两百字,剩下的点链接看。涉及访客邮箱、电话的内容更要谨慎,群里的人越多,这些信息传得越广。
最后一条值得多说一句:访客的信息在工作台里是加密存储、每个客户独立密钥的,但一旦你把它转进一个百人大群,保护它的就只剩群的权限设置了。只转「有新咨询」和一句话摘要,比把完整对话搬进群安全得多。
群里收不到消息怎么查?
按这个顺序查,基本一次就能定位:
- 投递日志里是什么状态码? 401 是你的验签没过(看签名验证那篇的排查清单);5xx 或超时是脚本里抛了异常;一直
重试中说明根本连不上,查 DNS 和防火墙。 - 日志里是 200,群里没消息? 那是转发那一步失败了,看
journalctl里的「转发失败」。常见原因:飞书钉钉的签名算反了;钉钉选了自定义关键词但消息里没带那个词;Telegram 群号漏了前面的减号;Slack 的地址被重新生成过。 - 群里只有客服自己的话?
sender.type的过滤没生效,确认取的是ev.data.message.sender.type。 - 同一条咨询出现两次? 事件 id 的去重没生效,或者换了实例。确认读的是
x-sealreply-event-id这个请求头(Node 里请求头名字是全小写的)。
常见问题
能在飞书或钉钉群里直接回复网站访客吗?
不能,这个脚本是单向的:只往群里发一条通知和一个链接。点链接会打开 SealReply 工作台或手机 App 里的那条会话,在那里回复。回复留在 SealReply 里,访客信息也只加密存在一个地方,不会散落在各个群里。
把网站咨询推送到飞书,必须买付费套餐吗?
用这个办法的话是的,Webhook 要入门版及以上套餐。只想自己及时收到提醒,装 SealReply 手机 App 或开浏览器推送就行,不用 Webhook,也不用写代码。
一定要有自己的服务器吗?
要有一个能从公网收 HTTPS 请求的地方。最便宜的云主机就够;不想管服务器,用云函数也行,前提是能拿到原始请求体。接收地址不能填 localhost 或内网地址,会被拒绝。
钉钉机器人提示签名不匹配怎么办?
先确认没有照着飞书的写法算钉钉的签名。钉钉是用密钥做 HMAC 的密钥、"时间戳\n密钥" 做内容,时间戳是毫秒,结果 base64 之后还要 URL 编码再拼到网址后面;飞书正好反过来,时间戳是秒。再看看服务器时间准不准,飞书会拒绝和它相差超过一小时的时间戳。
Telegram 群的 chat_id 怎么查?
把机器人拉进群,在群里随便发一句话,再用浏览器打开 https://api.telegram.org/bot<你的令牌>/getUpdates。返回内容里的 chat.id 就是群号。群和频道的群号是负数,形如 -1001234567890,前面的减号不能漏。
把访客的消息转进工作群安全吗?
安不安全取决于群里有哪些人。访客信息在 SealReply 工作台里是加密存储、每个客户独立密钥的;转进群之后,群里每个人都能看到、也能再转发。建议只发一句摘要和链接,不要把完整对话、邮箱和电话发进群。
接下来
这套东西搭完,你会发现 Webhook 还能往别处接:lead.updated 可以把线索同步进自己的 CRM,visitor.identified 可以在访客留下邮箱时触发一封欢迎邮件,conversation.closed 可以把当天的会话归档进数据仓库。事件和字段都在开发者文档里。
还没装在线客服插件的话,先花几分钟把它装上、接待第一位访客,再回来做转发:5 分钟上手 SealReply。从安装到接待到跟进成交的整条路,小网站在线客服完整指南里串了一遍。