WhatsApp MCP 上线:在 Cloudflare 上免费让 AI 智能体读取聊天记录
Show HN: WhatsApp MCP running free on Cloudflare
作者发布 whatsapp-mcp,一个部署在自己 Cloudflare 账号里的 MCP 服务器,让 Claude Code、Claude Desktop 等 MCP 客户端搜索和读取 WhatsApp 聊天,但不能发送消息。
让你的 AI 智能体读取你的 WhatsApp 消息。
这是一个运行在你自己的 Cloudflare 账户中的小型服务器。你只需像连接 WhatsApp Web 那样,将手机与它连接一次,此后任何 MCP 客户端(Claude Code、Claude Desktop,以及任何支持 MCP 的客户端)都能搜索和读取你的聊天记录。它可以读取和搜索你的消息,并记住你为电话号码指定的名称。它无法发送任何内容。
你可以这样向你的智能体提问:
- “Dana 关于航班说了什么?”
- “在我所有的聊天记录中,找出每一条提到租约的消息。”
- “总结一下这周我在家庭群里错过了什么。”
工作原理
你的手机始终是数据的真实来源。服务器是一个已连接的设备,你的智能体与它通信。
它不会一直保持与 WhatsApp 的连接。当你的智能体请求某些内容时,以及每天一次,它会连接、收集 WhatsApp 自上次以来为它排队的全部内容、将其写入自己的数据库,然后断开连接。你的手机照常工作,并继续接收通知。
你的消息存储在你 Cloudflare 账户中一个 Durable Object 内的 SQLite 数据库里。不会发送到任何其他地方。
设置
你需要一个使用 Workers 付费计划的 Cloudflare 账户(见下方说明)、Node 20 或更高版本,以及你的手机。
git clone https://github.com/finereli/whatsapp-mcp cd whatsapp-mcp npm install # 1. A place for the sign-in tokens. Paste the id it prints into wrangler.jsonc. npx wrangler kv namespace create OAUTH_KV # 2. A password for the page that links your phone. 12+ characters. npx wrangler secret put OWNER_PASSWORD # 3. Deploy. npm run deploy
Wrangler 会打印出你的 URL,类似 https://whatsapp-mcp.your-name.workers.dev。打开它。首页包含剩余的两个步骤:
-
连接 WhatsApp。 用 WhatsApp 扫描二维码(设置、已连接的设备、连接设备)。如果你就在手机上操作、无法扫描屏幕,请改用电话号码选项。首次连接会导入你的历史记录。页面会显示进度,你可以关闭它。
-
连接你的智能体。 在 Claude Code 中:
claude mcp add --transport http whatsapp https://your-url/mcp
然后输入
/mcp并用密码登录。其他客户端:将它们指向同一个/mcpURL。
如果你想使用自己的域名,请参阅 wrangler.jsonc 底部的注释。
关于 Workers 付费计划的说明
导入大量历史记录会一次性解码大量数据,而 Workers 的默认 CPU 限制对此来说太小了。wrangler.jsonc 可以提高该限制,而提高限制需要付费计划,每月几美元。我没有尝试过免费计划。如果你使用的是免费计划,请移除 limits 块,并预期首次导入会不太稳定。
注意事项
这不是官方产品。 WhatsApp 没有用于读取你自己个人消息的 API。本项目通过一个开源库(Baileys)使用与 WhatsApp Web 相同的协议。这违反了 WhatsApp 的条款。对于一个只读取的已连接设备,实际风险很低,但并非为零。请在你自己的账户上使用,不要用它发送消息。
你只能得到 WhatsApp 发送的内容。 首次连接会导入 WhatsApp 决定交给新设备的任何历史记录。它可能不是全部内容。每次连接只会执行一次。要再次导入,请重新连接(你已存储的消息会保留,重新导入会补全任何缺失的内容)。
部分聊天会显示为电话号码。 WhatsApp 不会向已连接的设备提供所有联系人名称,而且其中大多数是你从未保存过的人发来的一次性消息。对于重要的那些,你的智能体可以修复:它会列出未命名的聊天,用它已有的任何联系人工具(例如 Google Contacts 或 Gmail 连接器)在你的联系人中查找这些号码,并保存名称。服务器本身从不连接联系人服务。参见下方的“命名聊天”。
媒体不会被下载。 照片、语音消息和文档会显示为类似 [image] 或 [voice note 12s] 的标签,如果有说明文字则会一并显示。不会进行任何转录。
这是给一个人用的。一个部署就是一个 WhatsApp 账号和一个所有者。这是有意为之:每个部署都保存完整的消息历史,并持有该账号的实时登录状态,所以每个人都应该运行自己的部署。
重新关联。如果你的手机提示该设备已被移除,或者你想重新开始,让你的 agent 运行 relink,或者打开 /setup 并选择“取消关联并重新扫描”。你扫描一个新的二维码。已存储的消息会保留。
你的 agent 能做什么
| 工具 | 作用 |
|---|---|
list_chats |
聊天列表,按最近优先排序。可按名称或号码筛选。 |
get_messages |
读取一个聊天。按日期向前或向后翻页。 |
search_messages |
在所有内容中,或在单个聊天内进行全文搜索。 |
unnamed_chats |
仅以电话号码标识的聊天,按最活跃优先排序,并附上对方说的最后一句话。 |
set_contact_names |
为电话号码保存名称,格式不限。名称会绑定到号码上,因此也会在群组中显示。 |
sync_now |
立即拉取新消息。 |
link_status |
是否已关联、上次拉取时间、已存储多少内容。 |
relink |
取消关联并重新开始。必须由人扫描新的二维码。 |
如果存储的副本超过十分钟,每个读取工具都会先从 WhatsApp 补充最新内容。
为聊天命名
WhatsApp 只向此服务器提供它已知的名称。其余的都显示为电话号码。服务器没有登录联系人服务(那意味着云项目、同意屏幕,对 Google 而言还需要应用审核),而是把这项工作交给你的 agent,它通常已经有办法访问你的联系人。
说“为我的未命名聊天查找名称”。agent 会调用 unnamed_chats,将号码与你的联系人匹配,并用 set_contact_names 保存找到的结果。它不会找到所有人:大多数未命名聊天都是陌生人发来的一条消息。
agent 会被自动引导这样做。服务器的说明中提到了这一点,每个“未找到聊天”和“无匹配”结果中也会提到,因为那正是 agent 卡在名称上的时刻。link_status 还会说明有多少聊天未命名。
面向 agent 和修改此项目的人
以下内容面向任何修改代码的人,包括在分支上工作的 AI agent。本文件的开头部分面向部署的所有者。
整体结构
MCP client ──OAuth──> Worker (src/index.ts)
├─ /mcp ───────────> mcp.ts: tools, one fresh McpServer per request
├─ /, /setup, /authorize, /login/*, /pair/* (landing, sign-in, pairing UI)
└─ cron 04:00 UTC ──> pull()
│ RPC
v
WhatsAppStore (Durable Object, one instance named "owner")
├─ SQLite: auth keys, messages, chats, contacts, FTS5 index
└─ Baileys socket, opened only during a session (pairing or pull)
│ WebSocket
v
WhatsApp servers
一个 Durable Object 承载一切。它的 SQLite 既是 Baileys 认证存储,也是消息存储。Worker 是无状态的,除了 OAuth 提供方用于令牌的 KV 命名空间和一个短期的“待登录”记录。
文件
| 文件 | 职责 |
|---|---|
src/index.ts |
路由。OAuth 提供方接线、登录、配对端点、/debug、cron。 |
src/auth.ts |
谁算作所有者。两种模式:密码,或登录服务。还有所有者的浏览器会话。 |
src/phone.ts |
任意格式的电话号码到 WhatsApp jid 的转换。 |
src/whatsapp-do.ts |
Durable Object。运行 Baileys 会话,为 Worker 暴露 RPC 方法。 |
src/store.ts |
Schema、将 Baileys 事件摄入 SQLite,以及所有查询。 |
src/auth-state.ts |
由 DO 的 SQLite 支持的 Baileys 认证状态(替代 useMultiFileAuthState)。 |
src/mcp.ts |
MCP 工具,以及引导 agent 使用命名工具的提示。 |
src/pages.ts、src/landing.ts、src/privacy.ts、src/tos.ts、src/ui.ts |
全部为 HTML。ui.ts 包含共享的设计令牌。 |
src/ws-shim.ts、src/lru-shim.ts、src/polyfills.ts |
让 Baileys 在 workerd 上运行。见下文。 |
scripts/vendor-bridge.mjs |
从 Baileys 的 Rust 桥中提取 wasm。在 npm install 上运行。 |
local-export/ |
一个单独的一次性脚本,无需服务器即可将你的历史记录导出为 JSON 文件。不属于部署的一部分。 |
会话如何工作
所有 WhatsApp 流量都发生在 whatsapp-do.ts 中的 runSession() 内。有两种类型:
- 配对(
startPairing)。尚无凭据。Baileys 发出 QR 字符串,保存在内存中并由pairStatus()渲染为 SVG。扫描后 WhatsApp 以代码 515 关闭套接字(需要重启),会话随后用新凭据重新连接。历史记录以messaging-history.set分块到达,并带有progress百分比。当 WhatsApp 报告 100% 且流已静默几秒后,会话结束。 - 拉取(
pull)。使用已存储的凭据重新连接。WhatsApp 会投递我们离线期间排队的全部内容,然后发出receivedPendingNotifications信号。会话在几秒静默后结束。
调用方共享同一个会话:当一个会话正在运行时,第二个 pull() 会拿到同一个 promise。
配对会话的生命周期长于启动它的 HTTP 请求。startPairing 设置一个 Durable Object 闹钟,alarm() 等待会话 promise。正在执行的闹钟处理器正是让该对象保持存活的原因。配对页面只负责观察;关闭它不会停止导入。
空闲检测只统计数据事件(messaging-history.set、messages.upsert),不统计连接杂讯。统计所有内容会让会话永远保持打开,因为在线状态和联系人更新永远不会停止。
数据模型
全部位于 Durable Object 的 SQLite 中(store.ts、initSchema)。
messages(chat, id, ts, from_me, sender, push_name, type, body),主键(chat, id)。媒体是body中的一个标签。编辑会更新body;删除会将其设为[deleted]。messages_fts,基于body的 FTS5 索引,由触发器保持同步。chats(jid, name, is_group)、contacts(jid, name, notify)和custom_names(jid, name)。显示名称按 WhatsApp 聊天名称、WhatsApp 联系人名称、已保存名称、此人自己的推送名称、号码的顺序解析。custom_names是set_contact_names写入的内容。alias(lid, pn)。WhatsApp 正在转向匿名@lid标识符。这里将它们映射到电话号码 JID,并且addAlias会把现有行折叠为电话号码形式,使一个人对应一个聊天。auth(k, v),Baileys 凭据和信号密钥,JSON 中的 Buffer 由 Baileys 的BufferJSON编码。meta(k, v),目前只有last_pull。
摄取是幂等的。重新导入历史记录会执行 upsert:补全缺失的 sender 或 push_name,且绝不重复。这正是重新关联安全的原因。
改动前须知
以下每一条都花了真实时间才找到。在触碰 Baileys 相关部分之前请先阅读。
- Baileys needs three shims to run on workerd.
ws:该包需要 Node 套接字。ws-shim.ts包装了 Workers WebSocket,以fetch()和一个Upgrade头打开,因为这是设置 WhatsApp 所检查的Origin头的唯一方式。whatsapp-rust-bridge:它将其 wasm 以 base64 形式发布并在运行时编译,而 workerd 禁止这样做。vendor-bridge.mjs将 wasm 写入文件并修补加载器,使其作为CompiledWasm模块导入。vendor/文件夹是生成的,不提交。- 定时器:Node 定时器有
.unref(),而 workerd 的没有。polyfills.ts添加了它。它必须是 Worker 和 Durable Object 模块中的第一个导入。
lru-cache自动清除会破坏首次导入。 Baileys 用ttlAutopurge: true创建缓存,这会为每个条目启动一个setTimeout。workerd 允许每个对象有 10,000 个待处理定时器,而首次历史同步会以QuotaExceededError突破该限制。lru-shim.ts关闭自动清除。过期条目仍会在访问时惰性丢弃。- QR 配对从不设置
creds.registered。 该标志属于移动端注册流程。“是否已关联”是creds.me?.id。 - 历史记录每次关联只来一次。 如果首次导入中途失败,该历史记录就会丢失,直到你重新关联。不要在未针对真实首次关联进行测试的情况下发布摄取代码的更改。
- 群组发送者出现在两个地方。 实时消息有
key.participant。历史消息将其放在msg.participant上。两者都要读取。 - 精确锁定 Baileys 版本。 它是预发布版本,其 API 在候选发布版本之间会变化。
- 部署不会驱逐繁忙的 Durable Object。 有活动会话的对象会继续运行旧代码,直到会话结束。用迁移重命名类也不会驱逐它。如果你已经发布了修复,而对象仍然行为异常,请停止向它发送请求并等待三到五分钟。每次探测,包括
/debug,都会重置其空闲计时器,因此轮询一个陈旧对象会让它一直保持陈旧。Baileys 还会在套接字关闭时泄漏间隔计时器;whatsapp-do.ts中的finish()会清除它们,以便对象能够真正进入空闲状态。 - 注销需要两步。
logout()首先连接并调用 Baileys 的logout(),这样设备会从手机的列表中消失,然后清除本地凭据。如果第一步失败,它会在调试事件中说明,用户必须手动移除该设备。 - WhatsApp 会在本该是名字的位置存储占位符。 它没有联系人的聊天会以号码命名,有时会被掩码(
+972∙∙∙∙∙∙∙20),出现在聊天名称和联系人名称中。store.ts中的numberLike会将那些视为无名称,并且摄取会拒绝存储它们。在该修复之前,占位符的优先级高于真实保存的名称。 - 不要调用
markOnlineOnConnect。 它被故意设置为 false。将关联设备标记为在线会使手机停止显示通知。
配置
| 名称 | 类型 | 用途 |
|---|---|---|
OWNER_PASSWORD |
secret | 密码模式(默认)。12 个以上字符,否则登录会被拒绝。 |
OWNER_EMAIL |
var | 登录服务模式:允许登录的唯一电子邮件。 |
OPERATOR_NAME |
var,可选 | 在隐私页面上显示为运行此服务器的人。 |
DEFAULT_COUNTRY_CODE |
var,可选 | 国家代码(数字,不带加号),当你的代理保存一个未带国家代码的号码时使用,例如 972。 |
LOGIN_ORIGIN |
var | 设置此项以切换到登录服务模式。 |
ALLOWED_ORIGINS |
var | 登录服务模式:逗号分隔的来源,其令牌会被接受。生产环境应只列出生产环境。 |
PIN |
secret,可选 | 32 个以上随机字符。通过 x-pin 头启用 /debug 路由。保持未设置以关闭它们。 |
OAUTH_KV |
binding | 用于 OAuth 提供程序和待处理登录的令牌存储。 |
WA |
binding | Durable Object。 |
将部署自身的值(域、KV id、电子邮件)保存在 wrangler.local.jsonc 中,该文件已被 gitignore,并使用 npm run deploy:local 进行部署。wrangler.jsonc 是其他人开始使用的模板。
适配它
- 添加工具。 在
mcp.ts中注册它,并将 SQL 放入store.ts。读取工具应首先调用fresh()。除非你有意为之,否则保持工具只读。如果失败导致代理卡住,请在工具结果中说明下一步该尝试什么,就像NAME_HINT对缺失名称所做的那样。 - 更改谁可以登录。
auth.ts选择一种模式(authMode),index.ts中的路由会遵循它。要添加另一个身份提供程序,请在那里添加一个模式和一个回调路由。其余代码只能看到不透明的 bearer 令牌。 - 使其多用户。 目前 Durable Object 通过固定名称
owner(index.ts中的wa(),mcp.ts中的handleMcp)寻址,而userId是 OAuth 属性中的常量'owner'。要走向多用户,请从已登录身份派生两者,并以此作为对象的键。首先考虑你是否想持有其他人的消息历史。 - 填充名称的其他方式。 任何能生成
{ jid, name }行的东西都可以调用store.setCustomNames。CSV 上传、CardDAV 拉取或计划任务都可以。代理路由的存在是为了让服务器不需要其中任何一项的凭据。 - 发送消息。 故意未构建。在添加之前请三思。
调试
设置 PIN 后,这些会返回 JSON 并需要头 x-pin: <PIN>:
/debug:会话阶段、是否已关联、计数以及会话的最后事件(连接变化、历史块大小、错误)。/debug/pull:立即运行一次拉取。/debug/chats?q=、/debug/messages?chat=、/debug/search?q=、/debug/types:不经过 MCP 直接读取存储。/debug/unnamed:unnamed_chats会返回的聊天记录。/debug/sql?q=:对存储执行一次只读的SELECT,用于查明某个名称或计数为何看起来不对。/debug/logout:与relink相同。
本地开发:使用 npm run dev,其中 .dev.vars 包含 OWNER_PASSWORD=...(12 个字符以上)。它会运行一个带有自身空存储的本地 Durable Object,因此你可以走通登录流程并看到真实的二维码,但从本地开发关联手机将产生第二台已关联设备。
要在没有手机的情况下测试配对界面的各种状态,可用模拟的 /pair/status 提供该页面。页面所需的一切就是那一个 JSON 结构:paired、running、progress、qrSvg、messages、chats。
检查改动
- 先
npm run typecheck,再npm test。该测试针对 Node 内置的 SQLite(Node 22.13 或更高版本)运行真实的存储代码,包括从旧版本迁移。该 SQLite 没有 FTS5,因此搜索本身未被覆盖。 - 部署,然后用 PIN 执行
GET /debug。它应返回phase: "idle"和paired: true。 GET /debug/pull。一个安静的账户应在约十秒内返回reason: "idle"。- 调用
list_chats,然后在一个希伯来语或大量使用表情符号的聊天上调用get_messages,再调用search_messages。接着调用unnamed_chats,并在一个号码上调用set_contact_names,确认该聊天显示名称。 - 如果你改动了数据导入,请重新关联并确认存储的消息数量没有增长(重新导入是幂等的),以及群组消息显示真实的发送者名称。
已知缺口
- 在 WhatsApp 中没有名称且未保存名称的聊天会显示为电话号码。没有任何机制会自动刷新名称:只有在代理(或你)执行时才会保存它们。
- 如果每日拉取发现会话已登出,不会有任何提示。
link_status会在你的代理下次询问时显示出来。 - 编辑和删除会在作为协议消息到达时被应用。该路径尚未针对真实消息进行测试。
- 它仅在一个真实账户上运行过。
许可证
MIT
来源:Hacker News · github.com