wa-confirm
v0.2.0
Published
Verify that a WhatsApp number really belongs to your user — by having them message you, so you never send a message to a stranger.
Downloads
119
Maintainers
Readme
wa-confirm
English · Bahasa Indonesia · 中文
验证一个 WhatsApp 号码确实属于你的用户 —— 且永远不必向陌生号码发送消息。
你的应用 WhatsApp
────────────────────────────────────────────────────────────────
"验证我的号码" ──▶ 验证码 A7K2QX
深度链接 ──▶ 用户点击并发送
"#confirm A7K2QX"
│
webhook ◀────────────┘
号码已确认 ◀── 发送方 = 6281234567890为什么要把流程反过来
大多数实现会把一次性验证码发送到被声称的号码。这有两个问题:
- 号码会被封。 向从未联系过你的号码发消息,正是 WhatsApp 反垃圾系统专门要抓的模式。这样做上几百次,你的服务号码就没了。
- 验证的东西是错的。 你验证的是用户输入的号码。打错字、数字顺序颠倒,乃至故意填别人的号码,全都能通过这道检查。
wa-confirm 把它反了过来。用户从该号码发出验证码,你从消息信封里读取发送方。你的服务号码只会回复刚刚给它写过信的人 —— 这是普通的双向对话 —— 而你存下的号码不可能输错,因为用户根本不需要输入它。
安装
npm install wa-confirm # 或:pnpm add / yarn add / bun add零运行时依赖,同时提供 ESM 与 CommonJS。CI 覆盖 Node 20、22、24 与 Bun。仅使用
fetch 与 Web Crypto,因此其他现代运行时理应也可运行。不支持 Node 18 —— 它没有全局的 crypto。
快速上手
import { WAConfirm, MemoryStore, WahaTransport, fromWahaWebhook, verifyWebhookSecret } from 'wa-confirm'
const transport = new WahaTransport({
baseUrl: 'https://waha.example.com',
session: 'default',
apiKey: process.env.WAHA_API_KEY,
})
const confirm = new WAConfirm({
keyword: '#confirm',
appName: 'Acme',
store: new MemoryStore(), // 换成真正的数据库
servicePhone: () => transport.getServicePhone(), // 始终取当前在线的号码
transport,
})1. 你的应用申请一个验证码。
const challenge = await confirm.challenge(user.id)
if (!challenge.verified) {
// 展示 challenge.code,并把用户引导到 challenge.link。
// 该链接会打开 WhatsApp,收件人和消息内容都已填好 ——
// 用户只需按发送。
}2. WhatsApp 调用你的 webhook。
app.post('/webhooks/whatsapp', async (req, res) => {
if (!verifyWebhookSecret(process.env.WEBHOOK_SECRET, req.get('X-Webhook-Secret'))) {
return res.sendStatus(401)
}
const message = fromWahaWebhook(req.body)
if (message) {
const result = await confirm.handleInbound(message)
if (result.status === 'confirmed') {
// result.subject 是你的用户 id,result.phone 是已验证的号码
}
}
res.sendStatus(200) // 始终返回 200,避免 WhatsApp 重复投递
})整个接入就这些。给用户的回复 —— 成功、验证码过期、号码已被占用 —— 会替你发出,并先发送已读回执和打字停顿,读起来像一场对话。
与其读文档,不如直接看它跑起来:wa-confirm-playground 是一份完整的接入示例, 对接真实的 WAHA 实例与真实的 PostgreSQL 数据库,十二个步骤逐一断言,全程没有任何 mock。
那些不用亲自踩坑就已被处理好的事
这些都不是假设。每一条都曾在本库源出的那个应用里真实进入过生产环境。
匿名化的发送方。 WhatsApp 越来越多地把发送方标识为 299887766554433@lid,其中根本不含任何电话号码。粗糙的代码会把这串数字当成号码,从而绑错对象。wa-confirm 会拒绝它,并在放弃之前先回退到 SenderAlt —— WAHA 的 GOWS 引擎把真实号码放在那里。
看起来像电话号码的群组 id。 一个群组形如 [email protected]。去掉后缀就是十八位数字,能轻松通过任何长度校验。可它不是任何人的电话号码。
回复到群里。 如果有人在一个千人群里发了验证码而你回复了,你就等于向所有人宣布了他的验证 —— 同时也把「随时让你的服务号码开口」的办法交给了任何人。群消息会被完全静默地丢弃。
回复洪水。 有人反复发送无效验证码,会让你的号码以同样的频率回复,而这种外发突发正是号码被标记的原因。回复按会话限流(默认每 10 分钟 4 条)。
跨会话的突发。 按会话的限流并不限制号码本身。五十个人在同一分钟内完成注册,每人各收到一条回复,看上去毫无异常 —— 而你的号码却在同一瞬间发出了五十条消息。outboundRate 会把它们排入队列;详见下文。
一个号码,多个应用。 如果两个应用共用一个 WhatsApp 号码且使用相同关键词,彼此都会去应答对方的用户。请给每个应用各自的 keyword;其余消息一律无声忽略。
一个号码想占两个账号。 当号码属于他人时,store.confirm() 必须抛出 PhoneAlreadyUsedError。用户会收到告知;不会有任何改绑。
容易读错的验证码。 字母表排除了 0 O 1 I L,因此从屏幕上读出、再手工输入的验证码,不可能变成另一个有效验证码。
接入你自己的存储
MemoryStore 面向测试与原型 —— 进程重启即全部遗忘。请基于你的数据库实现 ConfirmStore:
interface ConfirmStore {
getConfirmedPhone(subject: string): Promise<string | null>
findPendingBySubject(subject: string): Promise<Challenge | null>
findPendingByCode(code: string): Promise<Challenge | null>
countRequestsSince(subject: string, since: Date): Promise<number>
createChallenge(input: { subject: string; code: string; expiresAt: Date }): Promise<Challenge>
confirm(input: { challengeId: string; subject: string; phone: string }): Promise<void>
}一份满足该契约的表结构,以及两个关键索引:
CREATE TABLE wa_verifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
subject TEXT NOT NULL,
code VARCHAR(16) NOT NULL,
phone VARCHAR(20),
expires_at TIMESTAMPTZ NOT NULL,
verified_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 两个人绝不能同时持有相同的有效验证码,否则该验证码会确认到恰好先被查到的
-- 那一行。已过期的验证码可以重复使用。
CREATE UNIQUE INDEX wa_verifications_active_code
ON wa_verifications (code) WHERE verified_at IS NULL;
CREATE INDEX wa_verifications_subject
ON wa_verifications (subject, created_at DESC);
-- 一个号码,一个账号。交给数据库来保证;这样 `confirm()` 只需把唯一约束冲突
-- 转换成 PhoneAlreadyUsedError。
CREATE UNIQUE INDEX users_whatsapp_number
ON users (whatsapp_number) WHERE whatsapp_number IS NOT NULL;接入你自己的传输层
WahaTransport 面向 WAHA。任何能发送消息的东西都可以 —— Baileys、whatsapp-web.js、Meta Cloud API:
interface ConfirmTransport {
sendText(chatId: string, text: string): Promise<void>
markSeen?(chatId: string): Promise<void> // 可选
setTyping?(chatId: string, typing: boolean): Promise<void> // 可选
}或者干脆不传 transport,设置 autoReply: false,再根据返回结果自行发送回复。
为服务号码控速
replyThrottle 限的是单个会话,而不是号码本身。真正会出事的并非某个人刷屏,而是五十位普通用户在同一分钟内完成注册。每人恰好收到一条回复,按会话的限流看不出有什么该拦的,而你的号码却在同一瞬间发出五十条消息。正是这种突发会让账号被标记。
outboundRate 让每条回复都走同一个队列,逐条发出:
const confirm = new WAConfirm({
// 其余配置项
outboundRate: true, // 每分钟 20 条,间隔一秒
})
process.on('SIGTERM', async () => {
await confirm.flush() // 否则队列中剩下的都会丢失
process.exit(0)
})它默认关闭,因为开启会改变回复发出的时机:handleInbound 在回复入队时即返回,而不是等它真正发出之后。验证结果不受影响 —— 那时早已确定 —— 并且 webhook 会被立即释放,这正是重点所在。等待队列意味着请求要一直挂到积压清空为止,而在这个机制本就要抚平的突发之下,那就等于超时。
传对象而非 true 即可调整 perMinute(默认 20)、minGapMs(默认 1000)和 maxQueue(默认 100)。队列满时丢弃最新到达的那条,而不是最早的:积压到这个程度说明回复本就已经迟了,等得最久的人不该因为后来者而失去位置。confirm.outbound 提供 size 和 dropped 供监控使用。
配置项
| 配置项 | 默认值 | 作用 |
| --- | --- | --- |
| keyword | 必填 | 消息必须以此开头。共用号码时每个应用一个。 |
| appName | 必填 | 会被插入到回复文案中。 |
| store | 必填 | 验证挑战的存放位置。 |
| servicePhone | — | 用户发送消息的目标号码。可为字符串,或每次调用时解析的函数。 |
| transport | — | 回复的发送方式。不传则由你自行处理回复。 |
| codeTtlMinutes | 15 | 验证码的有效时长。 |
| requestsPerHour | 5 | 单个 subject 每小时可申请的验证码数量。 |
| codeLength | 6 | 生成验证码的长度。 |
| messages | 英文 | 可覆盖任意子集。已内置 indonesianMessages。 |
| autoReply | true | 设为 false 则永不发送任何内容。 |
| typing | 25 毫秒/字符,0.9–2.5 秒 | 回复前的打字停顿。false 可关闭。 |
| replyThrottle | 每 10 分钟 4 条 | 每会话的回复上限。false 可关闭 —— 不建议。 |
| outboundRate | 关闭 | 跨所有会话为回复排队并控速。true 即每分钟 20 条。 |
| defaultCountryCode | '62' | 对本地格式号码所假定的国家码。 |
返回结果
对于常规情形,handleInbound 从不抛出异常 —— 它会报告发生了什么:
| status | 含义 |
| --- | --- |
| confirmed | 已验证。携带 subject、phone、code。 |
| unknown_code | 验证码已过期或从未签发。已告知用户。 |
| phone_taken | 该号码已属于另一个 subject。已告知用户。 |
| hidden_phone | 无法从发送方读出任何号码。已告知用户。 |
| ignored | 并非验证请求。未发送任何内容。详见 reason。 |
reason 取值为 from_me、group_chat、keyword_mismatch、reply_throttled 之一。
常见问题
从 .env 读取时关键词为什么是空的? 因为它以 # 开头,而 dotenv 会把它当作
注释的开始 —— KEYWORD=#confirm 会悄悄变成空字符串,而空关键词什么都匹配不到。
请加引号:KEYWORD="#confirm"。
需要 WhatsApp Business API 吗? 不需要。WahaTransport 驱动的是
WAHA,它跑在你自己服务器上的普通 WhatsApp 账号之上。
如果你已经有 Cloud API 权限,也可以改写一个 ConfirmTransport 来用它 —— 本库并不
关心是谁把消息送出去的。
要花多少钱? 除了你自己的服务器之外没有别的开销。不存在按条计费,因为你这边 唯一会发出的消息,是回复刚刚给你写信的人。
用户换号了怎么办? 重新走一遍流程。请先清空用户表里的旧号码,否则唯一索引会 报告该号码已被占用 —— 而占用它的正是那个想要更换它的账号。
一个 WhatsApp 号码能服务多个应用吗? 可以,这正是 keyword 的用途。给每个应用
各自的关键词,各自就只应答自己的用户。以别人关键词开头的消息会被忽略且不作回复。
验证码会被暴力破解吗? 六位字符取自 31 个字符的字母表,约有 8.87 亿种可能,而且 只有当前仍然有效的验证码才可能匹配。在此之上,一个会话连发四个错误验证码后,十分钟内 不再获得任何回复,猜测者因此得不到任何信号。
我的服务号码会被封吗? 这套设计移除了最常见的封号原因:给陌生人发消息。你的号码 只会回复先联系它的人。没有什么是绝对安全的 —— 被足够多用户举报的号码仍可能受限 —— 但这与向未知号码群发消息是截然不同的风险。
如何接入真正的数据库? 实现 ConfirmStore。这里有一份
可用的 PostgreSQL 示例,覆盖了事务边界与唯一约束冲突的转换 —— 也就是
最容易出错的那两处。
安全须知
webhook 密钥是你唯一的防线。 webhook 没有已认证的用户。任何知道该 URL 的人都能声称自己是任意号码,从而接管他人账号。verifyWebhookSecret 以恒定时间比较,并且在未配置密钥时返回 false —— 未设置密钥意味着彻底关闭,而非彻底敞开。
请求确认为合法后,webhook 一律返回 200。 非 2xx 会引来对你已处理过的消息的重复投递。
限流分别按 subject、按会话、按号码计算。 requestsPerHour 限制验证码申请;replyThrottle 限制每个会话的外发回复;outboundRate 则对所有会话统一限流并控速。三者都保存在进程内存中 —— 这是有意为之的近似值,因为重启后丢失计数并无损失。在多实例部署下,各实例各自计数,因此请把 perMinute 除以共用同一个 WhatsApp 号码的实例数。
许可证
MIT © Adam Suchi Hafizullah
