feat(adapters): add WhatsApp linked-device support (#573)

Add a WhatsApp adapter backed by Baileys linked-device auth, plus desktop QR binding UI, server config endpoints, sidecar startup wiring, tests, and documentation.

Constraint: Uses WhatsApp Web linked-device auth, not Meta WhatsApp Business Cloud API.

Tested:
- cd adapters && bun run check:adapters
- bun test src/server/__tests__/adapters.test.ts
- cd desktop && bun run check:desktop
- bun run check:native
- bun run check:persistence-upgrade
- bun run check:docs

Not-tested:
- Live WhatsApp QR pairing, because no WhatsApp account/device was exercised here.
- bun run check:server, because the existing src/server/__tests__/conversations.test.ts timeout still fails independently.

Confidence: medium
Scope-risk: moderate
This commit is contained in:
程序员阿江(Relakkes) 2026-06-09 21:31:37 +08:00
parent db631dfdfd
commit ddcfa5faae
35 changed files with 2088 additions and 38 deletions

View File

@ -7,6 +7,7 @@
- `docs/im/index.md`
- `docs/im/wechat.md`
- `docs/im/dingtalk.md`
- `docs/im/whatsapp.md`
- `docs/im/telegram.md`
- `docs/im/feishu.md`
@ -40,6 +41,8 @@ bun run feishu
bun run wechat
# 或
bun run dingtalk
# 或
bun run whatsapp
```
## 开发
@ -54,6 +57,7 @@ bun test telegram/
bun test feishu/
bun test wechat/
bun test dingtalk/
bun test whatsapp/
```
### 目录结构
@ -73,6 +77,10 @@ adapters/
├── dingtalk/
│ ├── helpers.ts # 钉钉 Stream 消息解析与会话键
│ └── index.ts # 钉钉扫码绑定 / Stream 文本聊天 Adapter
├── whatsapp/
│ ├── session.ts # Baileys socket / auth state 封装
│ ├── protocol.ts # WhatsApp QR 登录 / 解绑协议封装
│ └── index.ts # WhatsApp Web 私聊 Adapter
├── package.json
├── tsconfig.json
└── README.md
@ -86,6 +94,7 @@ adapters/
- 飞书: 图片(jpg/png/gif/webp/heic)、文档(doc/xls/ppt/pdf 等)、post 富文本里的 img/file 元素
- Telegram: photo、document、video、audio、voice
- WhatsApp: image、document、video、audio、sticker
下载落地到 `~/.claude/im-downloads/{platform}/{sessionId}/`,24 小时后自动 GC(`.part` 孤文件 10 分钟超时)。大小限制:单张图 ≤10 MB、单个文件 ≤30 MB,超限直接拒收并在 IM 里提示。
@ -95,6 +104,7 @@ Agent 流式文本里的 markdown 图片引用 `![alt](path|url|data:)` 会被 `
- 飞书: `im.message.create(msg_type='image')` 单发(card 内嵌是后续优化)
- Telegram: `bot.api.sendPhoto(InputFile)` 单发
- WhatsApp: Baileys `sendMessage({ image })` 单发
非图片类出站(Agent 产的 pdf/zip 等)暂不支持。

View File

@ -6,6 +6,7 @@
"name": "claude-code-im-adapters",
"dependencies": {
"@larksuiteoapi/node-sdk": "^1.60.0",
"@whiskeysockets/baileys": "7.0.0-rc.9",
"dingtalk-stream": "2.1.4",
"grammy": "^1.42.0",
"ws": "^8.18.0",
@ -21,10 +22,80 @@
"protobufjs": "^7.5.5",
},
"packages": {
"@borewit/text-codec": ["@borewit/text-codec@0.2.2", "https://registry.npmmirror.com/@borewit/text-codec/-/text-codec-0.2.2.tgz", {}, "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ=="],
"@cacheable/memory": ["@cacheable/memory@2.0.9", "https://registry.npmmirror.com/@cacheable/memory/-/memory-2.0.9.tgz", { "dependencies": { "@cacheable/utils": "^2.4.1", "@keyv/bigmap": "^1.3.1", "hookified": "^1.15.1", "keyv": "^5.6.0" } }, "sha512-HdMx6DoGywB30vacDbBsITbIX4pgFqj1zsrV58jZBUw3klzkNoXhj7qOqAgledhxG7YZI5rBSJg7Zp8/VG0DuA=="],
"@cacheable/node-cache": ["@cacheable/node-cache@1.7.6", "https://registry.npmmirror.com/@cacheable/node-cache/-/node-cache-1.7.6.tgz", { "dependencies": { "cacheable": "^2.3.1", "hookified": "^1.14.0", "keyv": "^5.5.5" } }, "sha512-6Omk2SgNnjtxB5f/E6bTIWIt5xhdpx39fGNRQgU9lojvRxU68v+qY+SXXLsp3ZGukqoPjsK21wZ6XABFr/Ge3A=="],
"@cacheable/utils": ["@cacheable/utils@2.4.1", "https://registry.npmmirror.com/@cacheable/utils/-/utils-2.4.1.tgz", { "dependencies": { "hashery": "^1.5.1", "keyv": "^5.6.0" } }, "sha512-eiFgzCbIneyMlLOmNG4g9xzF7Hv3Mga4LjxjcSC/ues6VYq2+gUbQI8JqNuw/ZM8tJIeIaBGpswAsqV2V7ApgA=="],
"@emnapi/runtime": ["@emnapi/runtime@1.11.0", "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.11.0.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg=="],
"@grammyjs/types": ["@grammyjs/types@3.26.0", "https://registry.npmmirror.com/@grammyjs/types/-/types-3.26.0.tgz", {}, "sha512-jlnyfxfev/2o68HlvAGRocAXgdPPX5QabG7jZlbqC2r9DZyWBfzTlg+nu3O3Fy4EhgLWu28hZ/8wr7DsNamP9A=="],
"@hapi/boom": ["@hapi/boom@9.1.4", "https://registry.npmmirror.com/@hapi/boom/-/boom-9.1.4.tgz", { "dependencies": { "@hapi/hoek": "9.x.x" } }, "sha512-Ls1oH8jaN1vNsqcaHVYJrKmgMcKsC1wcp8bujvXrHaAqD2iDYq3HoOwsxwo09Cuda5R5nC0o0IxlrlTuvPuzSw=="],
"@hapi/hoek": ["@hapi/hoek@9.3.0", "https://registry.npmmirror.com/@hapi/hoek/-/hoek-9.3.0.tgz", {}, "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ=="],
"@img/colour": ["@img/colour@1.1.0", "https://registry.npmmirror.com/@img/colour/-/colour-1.1.0.tgz", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="],
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "https://registry.npmmirror.com/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "https://registry.npmmirror.com/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="],
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "https://registry.npmmirror.com/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="],
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "https://registry.npmmirror.com/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="],
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "https://registry.npmmirror.com/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="],
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "https://registry.npmmirror.com/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="],
"@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "https://registry.npmmirror.com/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="],
"@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "https://registry.npmmirror.com/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="],
"@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "https://registry.npmmirror.com/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="],
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "https://registry.npmmirror.com/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="],
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "https://registry.npmmirror.com/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="],
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "https://registry.npmmirror.com/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="],
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "https://registry.npmmirror.com/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="],
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "https://registry.npmmirror.com/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="],
"@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "https://registry.npmmirror.com/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="],
"@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "https://registry.npmmirror.com/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="],
"@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "https://registry.npmmirror.com/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="],
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "https://registry.npmmirror.com/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="],
"@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "https://registry.npmmirror.com/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="],
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "https://registry.npmmirror.com/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="],
"@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "https://registry.npmmirror.com/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="],
"@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "https://registry.npmmirror.com/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="],
"@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "https://registry.npmmirror.com/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="],
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "https://registry.npmmirror.com/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
"@keyv/bigmap": ["@keyv/bigmap@1.3.1", "https://registry.npmmirror.com/@keyv/bigmap/-/bigmap-1.3.1.tgz", { "dependencies": { "hashery": "^1.4.0", "hookified": "^1.15.0" }, "peerDependencies": { "keyv": "^5.6.0" } }, "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ=="],
"@keyv/serialize": ["@keyv/serialize@1.1.1", "https://registry.npmmirror.com/@keyv/serialize/-/serialize-1.1.1.tgz", {}, "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA=="],
"@larksuiteoapi/node-sdk": ["@larksuiteoapi/node-sdk@1.60.0", "https://registry.npmmirror.com/@larksuiteoapi/node-sdk/-/node-sdk-1.60.0.tgz", { "dependencies": { "axios": "~1.13.3", "lodash.identity": "^3.0.0", "lodash.merge": "^4.6.2", "lodash.pickby": "^4.6.0", "protobufjs": "^7.2.6", "qs": "^6.14.2", "ws": "^8.19.0" } }, "sha512-MS1eXx7K6HHIyIcCBkJLb21okoa8ZatUGQWZaCCUePm6a37RWFmT6ZKlKvHxAanSX26wNuNlwP0RhgscsE+T6g=="],
"@pinojs/redact": ["@pinojs/redact@0.4.0", "https://registry.npmmirror.com/@pinojs/redact/-/redact-0.4.0.tgz", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="],
"@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "https://registry.npmmirror.com/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="],
"@protobufjs/base64": ["@protobufjs/base64@1.1.2", "https://registry.npmmirror.com/@protobufjs/base64/-/base64-1.1.2.tgz", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="],
@ -45,28 +116,46 @@
"@protobufjs/utf8": ["@protobufjs/utf8@1.1.1", "https://registry.npmmirror.com/@protobufjs/utf8/-/utf8-1.1.1.tgz", {}, "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg=="],
"@tokenizer/inflate": ["@tokenizer/inflate@0.4.1", "https://registry.npmmirror.com/@tokenizer/inflate/-/inflate-0.4.1.tgz", { "dependencies": { "debug": "^4.4.3", "token-types": "^6.1.1" } }, "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA=="],
"@tokenizer/token": ["@tokenizer/token@0.3.0", "https://registry.npmmirror.com/@tokenizer/token/-/token-0.3.0.tgz", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="],
"@types/node": ["@types/node@25.5.2", "https://registry.npmmirror.com/@types/node/-/node-25.5.2.tgz", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg=="],
"@types/ws": ["@types/ws@8.18.1", "https://registry.npmmirror.com/@types/ws/-/ws-8.18.1.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
"@whiskeysockets/baileys": ["@whiskeysockets/baileys@7.0.0-rc.9", "https://registry.npmmirror.com/@whiskeysockets/baileys/-/baileys-7.0.0-rc.9.tgz", { "dependencies": { "@cacheable/node-cache": "^1.4.0", "@hapi/boom": "^9.1.3", "async-mutex": "^0.5.0", "libsignal": "git+https://github.com/whiskeysockets/libsignal-node.git", "lru-cache": "^11.1.0", "music-metadata": "^11.7.0", "p-queue": "^9.0.0", "pino": "^9.6", "protobufjs": "^7.2.4", "ws": "^8.13.0" }, "peerDependencies": { "audio-decode": "^2.1.3", "jimp": "^1.6.0", "link-preview-js": "^3.0.0", "sharp": "*" }, "optionalPeers": ["audio-decode", "jimp", "link-preview-js"] }, "sha512-YFm5gKXfDP9byCXCW3OPHKXLzrAKzolzgVUlRosHHgwbnf2YOO3XknkMm6J7+F0ns8OA0uuSBhgkRHTDtqkacw=="],
"abort-controller": ["abort-controller@3.0.0", "https://registry.npmmirror.com/abort-controller/-/abort-controller-3.0.0.tgz", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
"async-mutex": ["async-mutex@0.5.0", "https://registry.npmmirror.com/async-mutex/-/async-mutex-0.5.0.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA=="],
"asynckit": ["asynckit@0.4.0", "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
"atomic-sleep": ["atomic-sleep@1.0.0", "https://registry.npmmirror.com/atomic-sleep/-/atomic-sleep-1.0.0.tgz", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="],
"axios": ["axios@1.13.6", "https://registry.npmmirror.com/axios/-/axios-1.13.6.tgz", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ=="],
"bun-types": ["bun-types@1.3.11", "https://registry.npmmirror.com/bun-types/-/bun-types-1.3.11.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
"cacheable": ["cacheable@2.3.5", "https://registry.npmmirror.com/cacheable/-/cacheable-2.3.5.tgz", { "dependencies": { "@cacheable/memory": "^2.0.8", "@cacheable/utils": "^2.4.1", "hookified": "^1.15.0", "keyv": "^5.6.0", "qified": "^0.10.1" } }, "sha512-EQfaKe09tl615iNvq/TBRWTFf1AKJNXYQSsMx0Z3EI0nA+pVsVPS8wJhnRlkbdacKPh1d0qVIhwTc2zsQNFEEg=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
"call-bound": ["call-bound@1.0.4", "https://registry.npmmirror.com/call-bound/-/call-bound-1.0.4.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
"combined-stream": ["combined-stream@1.0.8", "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
"content-type": ["content-type@2.0.0", "https://registry.npmmirror.com/content-type/-/content-type-2.0.0.tgz", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
"curve25519-js": ["curve25519-js@0.0.4", "https://registry.npmmirror.com/curve25519-js/-/curve25519-js-0.0.4.tgz", {}, "sha512-axn2UMEnkhyDUPWOwVKBMVIzSQy2ejH2xRGy1wq81dqRwApXfIzfbE3hIX0ZRFBIihf/KDqK158DLwESu4AK1w=="],
"debug": ["debug@4.4.3", "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"delayed-stream": ["delayed-stream@1.0.0", "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
"detect-libc": ["detect-libc@2.1.2", "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"dingtalk-stream": ["dingtalk-stream@2.1.4", "https://registry.npmmirror.com/dingtalk-stream/-/dingtalk-stream-2.1.4.tgz", { "dependencies": { "axios": "^1.4.0", "debug": "^4.3.4", "ws": "^8.13.0" } }, "sha512-rgQbXLGWfASuB9onFcqXTnRSj4ZotimhBOnzrB4kS19AaU9lshXiuofs1GAYcKh5uzPWCAuEs3tMtiadTQWP4A=="],
"dunder-proto": ["dunder-proto@1.0.1", "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
@ -81,6 +170,10 @@
"event-target-shim": ["event-target-shim@5.0.1", "https://registry.npmmirror.com/event-target-shim/-/event-target-shim-5.0.1.tgz", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="],
"eventemitter3": ["eventemitter3@5.0.4", "https://registry.npmmirror.com/eventemitter3/-/eventemitter3-5.0.4.tgz", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="],
"file-type": ["file-type@21.3.4", "https://registry.npmmirror.com/file-type/-/file-type-21.3.4.tgz", { "dependencies": { "@tokenizer/inflate": "^0.4.1", "strtok3": "^10.3.4", "token-types": "^6.1.1", "uint8array-extras": "^1.4.0" } }, "sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g=="],
"follow-redirects": ["follow-redirects@1.16.0", "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.16.0.tgz", {}, "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw=="],
"form-data": ["form-data@4.0.5", "https://registry.npmmirror.com/form-data/-/form-data-4.0.5.tgz", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
@ -99,8 +192,18 @@
"has-tostringtag": ["has-tostringtag@1.0.2", "https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
"hashery": ["hashery@1.5.1", "https://registry.npmmirror.com/hashery/-/hashery-1.5.1.tgz", { "dependencies": { "hookified": "^1.15.0" } }, "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ=="],
"hasown": ["hasown@2.0.2", "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"hookified": ["hookified@1.15.1", "https://registry.npmmirror.com/hookified/-/hookified-1.15.1.tgz", {}, "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg=="],
"ieee754": ["ieee754@1.2.1", "https://registry.npmmirror.com/ieee754/-/ieee754-1.2.1.tgz", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"keyv": ["keyv@5.6.0", "https://registry.npmmirror.com/keyv/-/keyv-5.6.0.tgz", { "dependencies": { "@keyv/serialize": "^1.1.1" } }, "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw=="],
"libsignal": ["libsignal@github:whiskeysockets/libsignal-node#bcea72d", { "dependencies": { "curve25519-js": "^0.0.4", "protobufjs": "^7.5.5" } }, "WhiskeySockets-libsignal-node-bcea72d", "sha512-zKDaKfYGwQ/ALHPan2W2KZq/6njngElVNcqQQmQy0rl3fZbaqjiIchbPaJyCVwkTwHx1+s3Li1U/tf+f+AbCQA=="],
"lodash.identity": ["lodash.identity@3.0.0", "https://registry.npmmirror.com/lodash.identity/-/lodash.identity-3.0.0.tgz", {}, "sha512-AupTIzdLQxJS5wIYUQlgGyk2XRTfGXA+MCghDHqZk0pzUNYvd3EESS6dkChNauNYVIutcb0dfHw1ri9Q1yPV8Q=="],
"lodash.merge": ["lodash.merge@4.6.2", "https://registry.npmmirror.com/lodash.merge/-/lodash.merge-4.6.2.tgz", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
@ -109,24 +212,56 @@
"long": ["long@5.3.2", "https://registry.npmmirror.com/long/-/long-5.3.2.tgz", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
"lru-cache": ["lru-cache@11.5.1", "https://registry.npmmirror.com/lru-cache/-/lru-cache-11.5.1.tgz", {}, "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"media-typer": ["media-typer@2.0.0", "https://registry.npmmirror.com/media-typer/-/media-typer-2.0.0.tgz", {}, "sha512-kOy3OxT2HH39N70UnKgu4NWDZjLOz8W/mfyvniHjRH/DrL3f2pOfvWQ4p60offbbtDAnXWp0v9LfMIqMec269Q=="],
"mime-db": ["mime-db@1.52.0", "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"mime-types": ["mime-types@2.1.35", "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"ms": ["ms@2.1.3", "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"music-metadata": ["music-metadata@11.13.0", "https://registry.npmmirror.com/music-metadata/-/music-metadata-11.13.0.tgz", { "dependencies": { "@borewit/text-codec": "^0.2.2", "@tokenizer/token": "^0.3.0", "content-type": "^2.0.0", "debug": "^4.4.3", "file-type": "^21.3.4", "media-typer": "^2.0.0", "strtok3": "^10.3.5", "token-types": "^6.1.2", "uint8array-extras": "^1.5.0", "win-guid": "^0.2.1" } }, "sha512-uXRaov9dfjSpQufXIU7sMxVZnh+FilCQv2mXn+K5EJ/decP3dTWrgvPYa5r6MtRbieNSCE708Da4J0u1UGfQIw=="],
"node-fetch": ["node-fetch@2.7.0", "https://registry.npmmirror.com/node-fetch/-/node-fetch-2.7.0.tgz", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
"object-inspect": ["object-inspect@1.13.4", "https://registry.npmmirror.com/object-inspect/-/object-inspect-1.13.4.tgz", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
"on-exit-leak-free": ["on-exit-leak-free@2.1.2", "https://registry.npmmirror.com/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="],
"p-queue": ["p-queue@9.3.0", "https://registry.npmmirror.com/p-queue/-/p-queue-9.3.0.tgz", { "dependencies": { "eventemitter3": "^5.0.4", "p-timeout": "^7.0.0" } }, "sha512-7NED7xhQ74Ngp4JP/2e0VZHp7vSWfJfqeiR92jPgxsz6m0Se4P03YoTKa9dDXyZ3r6P616gUXttrB6nnHYKang=="],
"p-timeout": ["p-timeout@7.0.1", "https://registry.npmmirror.com/p-timeout/-/p-timeout-7.0.1.tgz", {}, "sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg=="],
"pino": ["pino@9.14.0", "https://registry.npmmirror.com/pino/-/pino-9.14.0.tgz", { "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^2.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^3.0.0" }, "bin": { "pino": "bin.js" } }, "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w=="],
"pino-abstract-transport": ["pino-abstract-transport@2.0.0", "https://registry.npmmirror.com/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", { "dependencies": { "split2": "^4.0.0" } }, "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw=="],
"pino-std-serializers": ["pino-std-serializers@7.1.0", "https://registry.npmmirror.com/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", {}, "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw=="],
"process-warning": ["process-warning@5.0.0", "https://registry.npmmirror.com/process-warning/-/process-warning-5.0.0.tgz", {}, "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA=="],
"protobufjs": ["protobufjs@7.5.6", "https://registry.npmmirror.com/protobufjs/-/protobufjs-7.5.6.tgz", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.1", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-M71sTMB146U3u0di3yup8iM+zv8yPRNQVr1KK4tyBitl3qFvEGucq/rGDRShD2rsJhtN02RJaJ7j5X5hmy8SJg=="],
"proxy-from-env": ["proxy-from-env@1.1.0", "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
"qified": ["qified@0.10.1", "https://registry.npmmirror.com/qified/-/qified-0.10.1.tgz", { "dependencies": { "hookified": "^2.1.1" } }, "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA=="],
"qs": ["qs@6.15.0", "https://registry.npmmirror.com/qs/-/qs-6.15.0.tgz", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ=="],
"quick-format-unescaped": ["quick-format-unescaped@4.0.4", "https://registry.npmmirror.com/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="],
"real-require": ["real-require@0.2.0", "https://registry.npmmirror.com/real-require/-/real-require-0.2.0.tgz", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="],
"safe-stable-stringify": ["safe-stable-stringify@2.5.0", "https://registry.npmmirror.com/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="],
"semver": ["semver@7.8.3", "https://registry.npmmirror.com/semver/-/semver-7.8.3.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-wnilbGyMxzbY7dNOl7jpKbLSjcfeweJWU5j4+u5qW+6/wuGD9KzIGOyZnQVSBM9E7DtWaaH3CyHkppYrKYoxwg=="],
"sharp": ["sharp@0.34.5", "https://registry.npmmirror.com/sharp/-/sharp-0.34.5.tgz", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
"side-channel": ["side-channel@1.1.0", "https://registry.npmmirror.com/side-channel/-/side-channel-1.1.0.tgz", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
"side-channel-list": ["side-channel-list@1.0.0", "https://registry.npmmirror.com/side-channel-list/-/side-channel-list-1.0.0.tgz", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="],
@ -135,16 +270,34 @@
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "https://registry.npmmirror.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
"sonic-boom": ["sonic-boom@4.2.1", "https://registry.npmmirror.com/sonic-boom/-/sonic-boom-4.2.1.tgz", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q=="],
"split2": ["split2@4.2.0", "https://registry.npmmirror.com/split2/-/split2-4.2.0.tgz", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
"strtok3": ["strtok3@10.3.5", "https://registry.npmmirror.com/strtok3/-/strtok3-10.3.5.tgz", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA=="],
"thread-stream": ["thread-stream@3.2.0", "https://registry.npmmirror.com/thread-stream/-/thread-stream-3.2.0.tgz", { "dependencies": { "real-require": "^0.2.0" } }, "sha512-zLBvqpwr4Esa0kRjcrzGU6zL25lePWaCLMx0RQFrmteozIfeNdaMLpG5U7PeHzvlFkAWaRKA9/KVW4F60iB+qw=="],
"token-types": ["token-types@6.1.2", "https://registry.npmmirror.com/token-types/-/token-types-6.1.2.tgz", { "dependencies": { "@borewit/text-codec": "^0.2.1", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww=="],
"tr46": ["tr46@0.0.3", "https://registry.npmmirror.com/tr46/-/tr46-0.0.3.tgz", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
"tslib": ["tslib@2.8.1", "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"uint8array-extras": ["uint8array-extras@1.5.0", "https://registry.npmmirror.com/uint8array-extras/-/uint8array-extras-1.5.0.tgz", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="],
"undici-types": ["undici-types@7.18.2", "https://registry.npmmirror.com/undici-types/-/undici-types-7.18.2.tgz", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"webidl-conversions": ["webidl-conversions@3.0.1", "https://registry.npmmirror.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
"whatwg-url": ["whatwg-url@5.0.0", "https://registry.npmmirror.com/whatwg-url/-/whatwg-url-5.0.0.tgz", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
"win-guid": ["win-guid@0.2.1", "https://registry.npmmirror.com/win-guid/-/win-guid-0.2.1.tgz", {}, "sha512-gEIQU4mkgl2OPeoNrWflcJFJ3Ae2BPd4eCsHHA/XikslkIVms/nHhvnvzIZV7VLmBvtFlDOzLt9rrZT+n6D67A=="],
"ws": ["ws@8.20.0", "https://registry.npmmirror.com/ws/-/ws-8.20.0.tgz", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="],
"@protobufjs/fetch/@protobufjs/inquire": ["@protobufjs/inquire@1.1.0", "https://registry.npmmirror.com/@protobufjs/inquire/-/inquire-1.1.0.tgz", {}, "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q=="],
"qified/hookified": ["hookified@2.2.0", "https://registry.npmmirror.com/hookified/-/hookified-2.2.0.tgz", {}, "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA=="],
}
}

View File

@ -33,8 +33,10 @@ describe('adapter config defaults', () => {
expect(config.feishu.defaultWorkDir).toBe(fs.realpathSync(workDir))
expect(config.wechat.defaultWorkDir).toBe(fs.realpathSync(workDir))
expect(config.dingtalk.defaultWorkDir).toBe(fs.realpathSync(workDir))
expect(config.whatsapp.defaultWorkDir).toBe(fs.realpathSync(workDir))
expect(getConfiguredWorkDir(config, config.wechat)).toBe(fs.realpathSync(workDir))
expect(getConfiguredWorkDir(config, config.dingtalk)).toBe(fs.realpathSync(workDir))
expect(getConfiguredWorkDir(config, config.whatsapp)).toBe(fs.realpathSync(workDir))
} finally {
fs.rmSync(configDir, { recursive: true, force: true })
fs.rmSync(workDir, { recursive: true, force: true })
@ -57,8 +59,10 @@ describe('adapter config defaults', () => {
expect(getConfiguredWorkDir(config, config.wechat)).toBe(defaultProjectDir)
expect(getConfiguredWorkDir(config, config.dingtalk)).toBe(defaultProjectDir)
expect(getConfiguredWorkDir(config, config.whatsapp)).toBe(defaultProjectDir)
expect(config.wechat.defaultWorkDir).toBe(fs.realpathSync(workDir))
expect(config.dingtalk.defaultWorkDir).toBe(fs.realpathSync(workDir))
expect(config.whatsapp.defaultWorkDir).toBe(fs.realpathSync(workDir))
} finally {
fs.rmSync(configDir, { recursive: true, force: true })
fs.rmSync(defaultProjectDir, { recursive: true, force: true })
@ -79,6 +83,7 @@ describe('adapter config defaults', () => {
expect(getConfiguredWorkDir(config, config.wechat)).toBe(fs.realpathSync(defaultProjectDir))
expect(getConfiguredWorkDir(config, config.dingtalk)).toBe(fs.realpathSync(defaultProjectDir))
expect(getConfiguredWorkDir(config, config.whatsapp)).toBe(fs.realpathSync(defaultProjectDir))
} finally {
fs.rmSync(configDir, { recursive: true, force: true })
fs.rmSync(defaultProjectDir, { recursive: true, force: true })
@ -102,6 +107,27 @@ describe('adapter config defaults', () => {
fs.rmSync(configDir, { recursive: true, force: true })
}
})
it('loads WhatsApp auth dir from file or env', () => {
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'adapter-config-'))
const originalWhatsAppAuthDir = process.env.WHATSAPP_AUTH_DIR
try {
fs.writeFileSync(
path.join(configDir, 'adapters.json'),
JSON.stringify({ whatsapp: { authDir: '~/custom-whatsapp-auth', accountJid: '15551234567@s.whatsapp.net' } }),
)
process.env.CLAUDE_CONFIG_DIR = configDir
delete process.env.WHATSAPP_AUTH_DIR
expect(loadConfig().whatsapp.authDir).toBe(path.join(os.homedir(), 'custom-whatsapp-auth'))
expect(loadConfig().whatsapp.accountJid).toBe('15551234567@s.whatsapp.net')
process.env.WHATSAPP_AUTH_DIR = path.join(configDir, 'wa-auth')
expect(loadConfig().whatsapp.authDir).toBe(path.join(configDir, 'wa-auth'))
} finally {
restoreEnv('WHATSAPP_AUTH_DIR', originalWhatsAppAuthDir)
fs.rmSync(configDir, { recursive: true, force: true })
}
})
})
function restoreEnv(key: string, value: string | undefined): void {

View File

@ -19,4 +19,13 @@ describe('pairing platform support', () => {
},
})).toBe(false)
})
it('checks WhatsApp paired users with the same shared access rule', () => {
expect(isPaired('whatsapp', '15551234567@s.whatsapp.net', {
whatsapp: {
pairedUsers: [{ userId: '15551234567@s.whatsapp.net', displayName: 'WhatsApp User', pairedAt: Date.now() }],
allowedUsers: [],
},
})).toBe(true)
})
})

View File

@ -6,7 +6,7 @@ import type { AttachmentRef } from '../ws-bridge.js'
export type { AttachmentRef }
/** Platform tag — used for local staging subdir and telemetry. */
export type ImPlatform = 'feishu' | 'telegram' | 'wechat' | 'dingtalk'
export type ImPlatform = 'feishu' | 'telegram' | 'wechat' | 'dingtalk' | 'whatsapp'
/** Result of downloading an IM resource into the local stage dir. */
export interface LocalAttachment {

View File

@ -58,6 +58,14 @@ export type DingtalkConfig = {
permissionCardTemplateId: string
}
export type WhatsAppConfig = {
accountJid: string
authDir: string
allowedUsers: string[]
pairedUsers: PairedUser[]
defaultWorkDir: string
}
export type AdapterConfig = {
serverUrl: string
defaultProjectDir: string
@ -66,6 +74,7 @@ export type AdapterConfig = {
feishu: FeishuConfig
wechat: WechatConfig
dingtalk: DingtalkConfig
whatsapp: WhatsAppConfig
}
export type AdapterPlatformConfig =
@ -73,6 +82,7 @@ export type AdapterPlatformConfig =
| FeishuConfig
| WechatConfig
| DingtalkConfig
| WhatsAppConfig
function getConfigPath(): string {
const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
@ -96,8 +106,12 @@ export function loadConfig(): AdapterConfig {
const fs_ = file.feishu ?? {}
const wc = file.wechat ?? {}
const dt = file.dingtalk ?? {}
const wa = file.whatsapp ?? {}
const pairing = file.pairing ?? {}
const fallbackWorkDir = resolveUserDefaultWorkDir()
const whatsappAuthDir = resolveConfiguredPath(
process.env.WHATSAPP_AUTH_DIR || wa.authDir || defaultWhatsAppAuthDir(),
)
return {
serverUrl: process.env.ADAPTER_SERVER_URL || file.serverUrl || 'ws://127.0.0.1:3456',
@ -141,6 +155,13 @@ export function loadConfig(): AdapterConfig {
endpoint: process.env.DINGTALK_STREAM_ENDPOINT || dt.endpoint || 'https://api.dingtalk.com',
permissionCardTemplateId: process.env.DINGTALK_PERMISSION_CARD_TEMPLATE_ID || dt.permissionCardTemplateId || '',
},
whatsapp: {
accountJid: process.env.WHATSAPP_ACCOUNT_JID || wa.accountJid || '',
authDir: whatsappAuthDir,
allowedUsers: wa.allowedUsers ?? [],
pairedUsers: wa.pairedUsers ?? [],
defaultWorkDir: wa.defaultWorkDir || fallbackWorkDir,
},
}
}
@ -182,3 +203,19 @@ function resolveExistingDirectory(value: string | undefined): string | null {
return null
}
}
function defaultWhatsAppAuthDir(): string {
const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
return path.join(configDir, 'whatsapp-auth', 'default')
}
function resolveConfiguredPath(value: string): string {
const trimmed = value.trim()
if (!trimmed) return defaultWhatsAppAuthDir()
const expanded = trimmed === '~'
? os.homedir()
: trimmed.startsWith('~/')
? path.join(os.homedir(), trimmed.slice(2))
: trimmed
return path.resolve(expanded)
}

View File

@ -13,7 +13,7 @@ import * as crypto from 'node:crypto'
import type { PairedUser, PairingState } from './config.js'
const SAFE_ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789' // 排除 0/O/1/I/L
export type ImPlatform = 'telegram' | 'feishu' | 'wechat' | 'dingtalk'
export type ImPlatform = 'telegram' | 'feishu' | 'wechat' | 'dingtalk' | 'whatsapp'
// 速率限制:每个 userId 在 RATE_LIMIT_WINDOW_MS 内最多 RATE_LIMIT_MAX_ATTEMPTS 次失败尝试
const RATE_LIMIT_WINDOW_MS = 5 * 60 * 1000 // 5 minutes

View File

@ -8,15 +8,18 @@
"feishu": "bun run feishu/index.ts",
"wechat": "bun run wechat/index.ts",
"dingtalk": "bun run dingtalk/index.ts",
"whatsapp": "bun run whatsapp/index.ts",
"test": "bun test",
"test:common": "bun test common/",
"test:telegram": "bun test telegram/",
"test:feishu": "bun test feishu/",
"test:wechat": "bun test wechat/",
"test:dingtalk": "bun test dingtalk/"
"test:dingtalk": "bun test dingtalk/",
"test:whatsapp": "bun test whatsapp/"
},
"dependencies": {
"@larksuiteoapi/node-sdk": "^1.60.0",
"@whiskeysockets/baileys": "7.0.0-rc.9",
"dingtalk-stream": "2.1.4",
"grammy": "^1.42.0",
"ws": "^8.18.0"

View File

@ -0,0 +1,36 @@
import { describe, expect, it } from 'bun:test'
import {
buildWhatsAppThinkingPreview,
formatWhatsAppOutboundText,
splitWhatsAppText,
} from '../format.js'
describe('WhatsApp message formatting', () => {
it('converts markdown tables to mobile-friendly bullets', () => {
const markdown = [
'| Feature | Status |',
'| --- | --- |',
'| WhatsApp | Ready |',
].join('\n')
expect(formatWhatsAppOutboundText(markdown)).toBe([
'WhatsApp',
'• Status: Ready',
].join('\n'))
})
it('splits long text within the WhatsApp chunk limit', () => {
const chunks = splitWhatsAppText('a'.repeat(8100), 4000)
expect(chunks).toHaveLength(3)
expect(chunks.every((chunk) => chunk.length <= 4000)).toBe(true)
})
it('accumulates thinking preview text without losing the full value', () => {
const first = buildWhatsAppThinkingPreview('', 'The user')
const second = buildWhatsAppThinkingPreview(first.fullText, ' asks for WhatsApp')
expect(second.fullText).toBe('The user asks for WhatsApp')
expect(second.messageText).toContain('The user asks for WhatsApp')
})
})

View File

@ -0,0 +1,24 @@
import { convertMarkdownTablesToBullets, splitMessage } from '../common/format.js'
const DEFAULT_THINKING_PREVIEW_LIMIT = 800
export function formatWhatsAppOutboundText(text: string): string {
return convertMarkdownTablesToBullets(text).trim()
}
export function buildWhatsAppThinkingPreview(
currentText: string,
deltaText: string,
previewLimit = DEFAULT_THINKING_PREVIEW_LIMIT,
): { fullText: string; messageText: string } {
const fullText = currentText + deltaText
const preview = fullText.slice(0, Math.max(0, previewLimit)).trimStart()
return {
fullText,
messageText: preview ? `思考中...\n${preview}` : '思考中...',
}
}
export function splitWhatsAppText(text: string, limit: number): string[] {
return splitMessage(formatWhatsAppOutboundText(text), limit).filter((chunk) => chunk.trim())
}

649
adapters/whatsapp/index.ts Normal file
View File

@ -0,0 +1,649 @@
/**
* WhatsApp Adapter for Claude Code Desktop
*
* Uses WhatsApp Web via Baileys. Link the account from Desktop Settings first,
* then run: bun run whatsapp
*/
import * as fs from 'node:fs/promises'
import * as path from 'node:path'
import {
normalizeMessageContent,
type proto,
} from '@whiskeysockets/baileys'
import { WsBridge, type ServerMessage, type AttachmentRef } from '../common/ws-bridge.js'
import { MessageDedup } from '../common/message-dedup.js'
import { enqueue } from '../common/chat-queue.js'
import { getConfiguredWorkDir, loadConfig } from '../common/config.js'
import {
formatImHelp,
formatImStatus,
formatPermissionRequest,
} from '../common/format.js'
import {
formatPermissionDecisionStatus,
formatPermissionInstructions,
parsePermissionCommand,
type PermissionDecision,
} from '../common/permission.js'
import { SessionStore } from '../common/session-store.js'
import { AdapterHttpClient } from '../common/http-client.js'
import { restoreStoredSessionBinding } from '../common/session-recovery.js'
import { isAllowedUser, tryPair } from '../common/pairing.js'
import { AttachmentStore } from '../common/attachment/attachment-store.js'
import { checkAttachmentLimit } from '../common/attachment/attachment-limits.js'
import { ImageBlockWatcher } from '../common/attachment/image-block-watcher.js'
import type { PendingUpload } from '../common/attachment/attachment-types.js'
import {
closeWhatsAppSocket,
createWhatsAppSocket,
hasWhatsAppAuth,
isWhatsAppLoggedOut,
type WhatsAppSocket,
} from './session.js'
import { WhatsAppMediaService } from './media.js'
import {
buildWhatsAppThinkingPreview,
formatWhatsAppOutboundText,
splitWhatsAppText,
} from './format.js'
const WHATSAPP_TEXT_LIMIT = 4000
const RECONNECT_BASE_MS = 1000
const RECONNECT_MAX_MS = 30_000
const config = loadConfig()
const authDir = config.whatsapp.authDir
if (!hasWhatsAppAuth(authDir)) {
console.error('[WhatsApp] No linked WhatsApp account found. Bind with QR in Desktop Settings first.')
process.exit(1)
}
const bridge = new WsBridge(config.serverUrl, 'whatsapp')
const dedup = new MessageDedup()
const sessionStore = new SessionStore()
const defaultWorkDir = getConfiguredWorkDir(config, config.whatsapp)
const httpClient = new AdapterHttpClient(config.serverUrl, { allowedProjectRoots: [defaultWorkDir] })
const attachmentStore = new AttachmentStore()
attachmentStore.gc().catch((err) => {
console.warn('[WhatsApp] AttachmentStore.gc failed:', err instanceof Error ? err.message : err)
})
let sock: WhatsAppSocket
let media: WhatsAppMediaService
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
let reconnectAttempts = 0
let shuttingDown = false
const accumulatedText = new Map<string, string>()
const accumulatedThinkingText = new Map<string, string>()
const thinkingNotices = new Set<string>()
const pendingProjectSelection = new Map<string, boolean>()
const runtimeStates = new Map<string, ChatRuntimeState>()
const pendingPermissions = new Map<string, Set<string>>()
const imageWatchers = new Map<string, ImageBlockWatcher>()
type ChatRuntimeState = {
state: 'idle' | 'thinking' | 'streaming' | 'tool_executing' | 'permission_pending'
verb?: string
model?: string
pendingPermissionCount: number
}
function getRuntimeState(chatId: string): ChatRuntimeState {
let state = runtimeStates.get(chatId)
if (!state) {
state = { state: 'idle', pendingPermissionCount: 0 }
runtimeStates.set(chatId, state)
}
return state
}
function getImageWatcher(chatId: string): ImageBlockWatcher {
let watcher = imageWatchers.get(chatId)
if (!watcher) {
watcher = new ImageBlockWatcher()
imageWatchers.set(chatId, watcher)
}
return watcher
}
function clearTransientChatState(chatId: string): void {
accumulatedText.delete(chatId)
accumulatedThinkingText.delete(chatId)
thinkingNotices.delete(chatId)
const runtime = getRuntimeState(chatId)
runtime.state = 'idle'
runtime.verb = undefined
runtime.pendingPermissionCount = 0
pendingPermissions.delete(chatId)
imageWatchers.delete(chatId)
}
async function sendWhatsAppText(jid: string, text: string): Promise<void> {
const chunks = splitWhatsAppText(text, WHATSAPP_TEXT_LIMIT)
for (const chunk of chunks) {
await sock.sendMessage(jid, { text: chunk })
}
}
async function sendHelp(jid: string): Promise<void> {
await sendWhatsAppText(jid, `Claude Code WhatsApp 已就绪。\n\n${formatImHelp()}`)
}
async function handlePermissionDecision(chatId: string, decision: PermissionDecision): Promise<void> {
const pending = pendingPermissions.get(chatId)
if (!pending?.has(decision.requestId)) {
await sendWhatsAppText(chatId, `未找到待确认的权限请求:${decision.requestId}`)
return
}
const sent = bridge.sendPermissionResponse(chatId, decision.requestId, decision.allowed, decision.rule)
if (sent) {
pending.delete(decision.requestId)
const runtime = getRuntimeState(chatId)
runtime.pendingPermissionCount = Math.max(0, runtime.pendingPermissionCount - 1)
}
await sendWhatsAppText(
chatId,
sent ? `${formatPermissionDecisionStatus(decision)}` : '权限响应发送失败,请检查会话状态。',
)
}
async function ensureExistingSession(chatId: string): Promise<{ sessionId: string; workDir: string } | null> {
return await restoreStoredSessionBinding({
chatId,
bridge,
sessionStore,
httpClient,
onServerMessage: (msg) => handleServerMessage(chatId, msg),
logPrefix: '[WhatsApp]',
clearTransientState: () => clearTransientChatState(chatId),
})
}
async function buildStatusText(chatId: string): Promise<string> {
const stored = await ensureExistingSession(chatId)
if (!stored) return formatImStatus(null)
const runtime = getRuntimeState(chatId)
let projectName = path.basename(stored.workDir) || stored.workDir
let branch: string | null = null
try {
const gitInfo = await httpClient.getGitInfo(stored.sessionId)
projectName = gitInfo.repoName || path.basename(gitInfo.workDir) || projectName
branch = gitInfo.branch
} catch {
// best-effort status
}
return formatImStatus({
sessionId: stored.sessionId,
projectName,
branch,
model: runtime.model,
state: runtime.state,
verb: runtime.verb,
pendingPermissionCount: runtime.pendingPermissionCount,
})
}
async function ensureSession(chatId: string): Promise<boolean> {
const stored = await ensureExistingSession(chatId)
if (stored) return true
const workDir = defaultWorkDir
if (workDir) {
return await createSessionForChat(chatId, workDir)
}
await showProjectPicker(chatId)
return false
}
async function createSessionForChat(chatId: string, workDir: string): Promise<boolean> {
try {
bridge.resetSession(chatId)
const sessionId = await httpClient.createSession(workDir)
sessionStore.set(chatId, sessionId, workDir)
bridge.connectSession(chatId, sessionId)
bridge.onServerMessage(chatId, (msg) => handleServerMessage(chatId, msg))
const opened = await bridge.waitForOpen(chatId)
if (!opened) {
await sendWhatsAppText(chatId, '连接服务器超时,请重试。')
return false
}
return true
} catch (err) {
await sendWhatsAppText(chatId, `无法创建会话: ${err instanceof Error ? err.message : String(err)}`)
return false
}
}
async function showProjectPicker(chatId: string): Promise<void> {
try {
const projects = await httpClient.listRecentProjects()
if (projects.length === 0) {
await sendWhatsAppText(
chatId,
`没有找到最近的项目。发送 /new 会使用默认工作目录:${defaultWorkDir}\n也可以发送 /new /path/to/project 指定项目。`,
)
return
}
const lines = projects.slice(0, 10).map((p, i) =>
`${i + 1}. ${p.projectName}${p.branch ? ` (${p.branch})` : ''}\n ${p.realPath}`,
)
pendingProjectSelection.set(chatId, true)
await sendWhatsAppText(
chatId,
`选择项目(回复编号):\n\n${lines.join('\n\n')}\n\n下次可直接 /new <编号、名称或绝对路径> 快速新建会话`,
)
} catch (err) {
await sendWhatsAppText(chatId, `无法获取项目列表: ${err instanceof Error ? err.message : String(err)}`)
}
}
async function dispatchOutboundMedia(chatId: string, pending: PendingUpload): Promise<void> {
try {
let buffer: Buffer
let mime = 'image/png'
switch (pending.source.kind) {
case 'base64':
buffer = Buffer.from(pending.source.data, 'base64')
mime = pending.source.mime
break
case 'path':
buffer = await fs.readFile(pending.source.path)
mime = pending.source.mime ?? 'image/png'
break
case 'url': {
const resp = await fetch(pending.source.url)
if (!resp.ok) throw new Error(`fetch ${pending.source.url} -> ${resp.status}`)
buffer = Buffer.from(await resp.arrayBuffer())
mime = pending.source.mime ?? resp.headers.get('content-type') ?? 'image/png'
break
}
}
const check = checkAttachmentLimit('image', buffer.length, mime)
if (!check.ok) {
console.warn('[WhatsApp] Outbound image rejected:', check.hint)
return
}
await media.sendMedia(chatId, buffer, mime, pending.alt)
} catch (err) {
console.error('[WhatsApp] dispatchOutboundMedia failed:', err instanceof Error ? err.message : err)
}
}
async function flushAccumulatedText(chatId: string): Promise<void> {
const text = accumulatedText.get(chatId)
if (!text?.trim()) {
accumulatedText.delete(chatId)
return
}
await sendWhatsAppText(chatId, formatWhatsAppOutboundText(text))
accumulatedText.delete(chatId)
}
async function handleServerMessage(chatId: string, msg: ServerMessage): Promise<void> {
const runtime = getRuntimeState(chatId)
switch (msg.type) {
case 'connected':
break
case 'status':
runtime.state = msg.state
runtime.verb = typeof msg.verb === 'string' ? msg.verb : undefined
if (msg.state === 'thinking' && !thinkingNotices.has(chatId)) {
thinkingNotices.add(chatId)
await sendWhatsAppText(chatId, '思考中...')
}
break
case 'content_start':
if (msg.blockType === 'text') {
accumulatedThinkingText.delete(chatId)
}
break
case 'content_delta':
if (msg.text) {
accumulatedThinkingText.delete(chatId)
accumulatedText.set(chatId, (accumulatedText.get(chatId) ?? '') + msg.text)
const newUploads = getImageWatcher(chatId).feed(msg.text)
for (const pending of newUploads) {
void dispatchOutboundMedia(chatId, pending)
}
}
break
case 'thinking': {
const update = buildWhatsAppThinkingPreview(
accumulatedThinkingText.get(chatId) ?? '',
msg.text ?? '',
)
accumulatedThinkingText.set(chatId, update.fullText)
break
}
case 'permission_request': {
runtime.pendingPermissionCount += 1
runtime.state = 'permission_pending'
const pending = pendingPermissions.get(chatId) ?? new Set<string>()
pending.add(msg.requestId)
pendingPermissions.set(chatId, pending)
const text = `${formatPermissionRequest(msg.toolName, msg.input, msg.requestId)}\n\n${formatPermissionInstructions(msg.requestId)}`
await sendWhatsAppText(chatId, text)
break
}
case 'message_complete':
runtime.state = 'idle'
runtime.verb = undefined
thinkingNotices.delete(chatId)
accumulatedThinkingText.delete(chatId)
await flushAccumulatedText(chatId)
break
case 'error':
runtime.state = 'idle'
runtime.verb = undefined
thinkingNotices.delete(chatId)
accumulatedThinkingText.delete(chatId)
if (msg.message && /Invalid.*signature.*thinking/i.test(msg.message)) {
const stored = sessionStore.get(chatId)
const workDir = stored?.workDir || defaultWorkDir
if (workDir) {
await sendWhatsAppText(chatId, '会话上下文已失效,正在自动重建...')
clearTransientChatState(chatId)
bridge.resetSession(chatId)
sessionStore.delete(chatId)
const ok = await createSessionForChat(chatId, workDir)
await sendWhatsAppText(chatId, ok ? '已重建会话,请重新发送消息。' : '重建会话失败,请发送 /new 手动新建。')
} else {
await sendWhatsAppText(chatId, '会话上下文已失效,请发送 /new 新建会话。')
}
} else {
await sendWhatsAppText(chatId, `错误: ${msg.message}`)
}
break
case 'system_notification':
if (msg.subtype === 'init' && msg.data && typeof msg.data === 'object') {
const model = (msg.data as Record<string, unknown>).model
if (typeof model === 'string' && model.trim()) {
runtime.model = model
}
}
break
}
}
async function startNewSession(chatId: string, query?: string): Promise<void> {
bridge.resetSession(chatId)
sessionStore.delete(chatId)
accumulatedText.delete(chatId)
accumulatedThinkingText.delete(chatId)
thinkingNotices.delete(chatId)
pendingProjectSelection.delete(chatId)
pendingPermissions.delete(chatId)
runtimeStates.delete(chatId)
imageWatchers.delete(chatId)
if (query) {
try {
const { project, ambiguous } = await httpClient.matchProject(query)
if (project) {
const ok = await createSessionForChat(chatId, project.realPath)
if (ok) {
await sendWhatsAppText(chatId, `已新建会话:${project.projectName}${project.branch ? ` (${project.branch})` : ''}`)
}
return
}
if (ambiguous) {
const list = ambiguous.map((p, i) => `${i + 1}. ${p.projectName} - ${p.realPath}`).join('\n')
await sendWhatsAppText(chatId, `匹配到多个项目,请更精确:\n\n${list}`)
return
}
await sendWhatsAppText(chatId, `未找到匹配 "${query}" 的项目。发送 /projects 查看完整列表。`)
} catch (err) {
await sendWhatsAppText(chatId, `错误: ${err instanceof Error ? err.message : String(err)}`)
}
} else {
const workDir = defaultWorkDir
if (workDir) {
const ok = await createSessionForChat(chatId, workDir)
if (ok) await sendWhatsAppText(chatId, '已新建会话,可以开始对话了。')
} else {
await showProjectPicker(chatId)
}
}
}
async function routeUserMessage(
chatId: string,
userId: string,
displayName: string,
text: string,
attachments: AttachmentRef[],
): Promise<void> {
if (!isAllowedUser('whatsapp', userId)) {
const success = attachments.length === 0
? tryPair(text.trim(), { userId, displayName }, 'whatsapp')
: false
await sendWhatsAppText(
chatId,
success
? '配对成功!现在可以开始聊天了。\n\n发送消息即可与 Claude 对话。'
: '未授权。请在 Claude Code 桌面端生成配对码后发送给我。',
)
return
}
enqueue(chatId, async () => {
const command = text.trim()
if (command === '/start' || command === '/help') {
await sendHelp(chatId)
return
}
if (command.startsWith('/new')) {
await startNewSession(chatId, command.slice('/new'.length).trim() || undefined)
return
}
if (command === '/projects') {
await showProjectPicker(chatId)
return
}
if (command === '/stop') {
const stored = await ensureExistingSession(chatId)
if (!stored) {
await sendWhatsAppText(chatId, formatImStatus(null))
return
}
bridge.sendStopGeneration(chatId)
await sendWhatsAppText(chatId, '已发送停止信号。')
return
}
if (command === '/status') {
await sendWhatsAppText(chatId, await buildStatusText(chatId))
return
}
if (command === '/clear') {
const stored = await ensureExistingSession(chatId)
if (!stored) {
await sendWhatsAppText(chatId, formatImStatus(null))
return
}
clearTransientChatState(chatId)
const sent = bridge.sendUserMessage(chatId, '/clear')
await sendWhatsAppText(chatId, sent ? '已清空当前会话上下文。' : '无法发送 /clear请先发送 /new 重新连接会话。')
return
}
const permissionDecision = attachments.length === 0
? parsePermissionCommand(text, pendingPermissions.get(chatId))
: null
if (permissionDecision) {
await handlePermissionDecision(chatId, permissionDecision)
return
}
if (pendingProjectSelection.has(chatId)) {
if (text.trim()) await startNewSession(chatId, text.trim())
return
}
const ready = await ensureSession(chatId)
if (!ready) return
const effective = text || (attachments.length > 0 ? '(用户发送了附件)' : '')
if (!effective && attachments.length === 0) return
const sent = bridge.sendUserMessage(chatId, effective, attachments.length ? attachments : undefined)
if (!sent) {
await sendWhatsAppText(chatId, '消息发送失败,连接可能已断开。请发送 /new 重新开始。')
}
})
}
async function collectAttachments(
message: proto.IWebMessageInfo,
chatId: string,
): Promise<{ attachments: AttachmentRef[]; rejections: string[] }> {
const sessionId = sessionStore.get(chatId)?.sessionId ?? chatId
const attachments: AttachmentRef[] = []
const rejections: string[] = []
try {
const local = await media.downloadMessageMedia(message, sessionId)
if (!local) return { attachments, rejections }
const check = checkAttachmentLimit(local.kind, local.size, local.mimeType)
if (!check.ok) {
rejections.push(check.hint)
return { attachments, rejections }
}
if (local.kind === 'image') {
attachments.push({
type: 'image',
name: local.name,
data: local.buffer.toString('base64'),
mimeType: local.mimeType,
})
} else {
attachments.push({
type: 'file',
name: local.name,
path: local.path,
mimeType: local.mimeType,
})
}
} catch (err) {
console.error('[WhatsApp] download media failed:', err instanceof Error ? err.message : err)
rejections.push('附件下载失败,请稍后重试。')
}
return { attachments, rejections }
}
function extractText(raw: proto.IMessage | undefined): string {
const msg = normalizeMessageContent(raw)
if (!msg) return ''
return msg.conversation
?? msg.extendedTextMessage?.text
?? msg.imageMessage?.caption
?? msg.videoMessage?.caption
?? msg.documentMessage?.caption
?? ''
}
function isDirectChat(jid: string): boolean {
return jid.endsWith('@s.whatsapp.net') || jid.endsWith('@lid')
}
async function handleIncomingMessage(message: proto.IWebMessageInfo): Promise<void> {
const key = message.key
if (!key) return
const chatId = key.remoteJid
const messageId = key.id
if (!chatId || !messageId || key.fromMe) return
if (chatId === 'status@broadcast' || chatId.endsWith('@broadcast') || !isDirectChat(chatId)) return
if (!dedup.tryRecord(`${chatId}:${messageId}`)) return
const text = extractText(message.message as proto.IMessage | undefined)
const { attachments, rejections } = await collectAttachments(message, chatId)
for (const rejection of rejections) {
await sendWhatsAppText(chatId, rejection).catch(() => {})
}
if (!text.trim() && attachments.length === 0) return
await sock.readMessages([{ remoteJid: chatId, id: messageId, fromMe: false }]).catch(() => {})
await sock.sendPresenceUpdate('composing', chatId).catch(() => {})
await routeUserMessage(
chatId,
chatId,
message.pushName || chatId,
text,
attachments,
)
}
async function startSocket(): Promise<void> {
if (reconnectTimer) {
clearTimeout(reconnectTimer)
reconnectTimer = null
}
sock = await createWhatsAppSocket({ authDir })
media = new WhatsAppMediaService(sock, attachmentStore)
sock.ev.on('messages.upsert', ({ type, messages }) => {
if (type !== 'notify') return
for (const message of messages) {
void handleIncomingMessage(message)
}
})
sock.ev.on('connection.update', (update) => {
if (update.connection === 'open') {
reconnectAttempts = 0
const connectedId = sock.user?.id ?? config.whatsapp.accountJid ?? 'unknown'
console.log(`[WhatsApp] Connected as ${connectedId}`)
return
}
if (update.connection !== 'close' || shuttingDown) return
if (isWhatsAppLoggedOut(update.lastDisconnect?.error)) {
console.error('[WhatsApp] Account logged out. Rebind with QR in Desktop Settings.')
process.exit(1)
}
scheduleReconnect()
})
}
function scheduleReconnect(): void {
if (reconnectTimer) return
const delay = Math.min(RECONNECT_MAX_MS, RECONNECT_BASE_MS * 2 ** reconnectAttempts)
reconnectAttempts += 1
console.warn(`[WhatsApp] Connection closed. Reconnecting in ${delay}ms...`)
reconnectTimer = setTimeout(() => {
reconnectTimer = null
startSocket().catch((err) => {
console.error('[WhatsApp] Reconnect failed:', err instanceof Error ? err.message : err)
scheduleReconnect()
})
}, delay)
}
console.log('[WhatsApp] Starting adapter...')
console.log(`[WhatsApp] Server: ${config.serverUrl}`)
console.log(`[WhatsApp] Auth dir: ${authDir}`)
console.log(`[WhatsApp] Allowed users: ${config.whatsapp.allowedUsers.length === 0 ? 'paired users only' : config.whatsapp.allowedUsers.join(', ')}`)
await startSocket()
process.on('SIGINT', () => {
console.log('[WhatsApp] Shutting down...')
shuttingDown = true
if (reconnectTimer) clearTimeout(reconnectTimer)
closeWhatsAppSocket(sock, 'SIGINT')
bridge.destroy()
dedup.destroy()
process.exit(0)
})

125
adapters/whatsapp/media.ts Normal file
View File

@ -0,0 +1,125 @@
import * as path from 'node:path'
import {
downloadMediaMessage,
normalizeMessageContent,
type WAMessage,
type proto,
} from '@whiskeysockets/baileys'
import type { WhatsAppSocket } from './session.js'
import { AttachmentStore } from '../common/attachment/attachment-store.js'
import type { LocalAttachment } from '../common/attachment/attachment-types.js'
function unwrapMessage(message: proto.IMessage | undefined): proto.IMessage | undefined {
return normalizeMessageContent(message)
}
function resolveMediaMime(message: proto.IMessage): string | undefined {
return message.imageMessage?.mimetype
?? message.videoMessage?.mimetype
?? message.documentMessage?.mimetype
?? message.audioMessage?.mimetype
?? message.stickerMessage?.mimetype
?? (message.audioMessage ? 'audio/ogg; codecs=opus' : undefined)
?? (message.imageMessage ? 'image/jpeg' : undefined)
?? (message.videoMessage ? 'video/mp4' : undefined)
?? (message.stickerMessage ? 'image/webp' : undefined)
}
function resolveMediaName(message: proto.IMessage, mimeType: string): string {
const explicit = message.documentMessage?.fileName
if (explicit?.trim()) return path.basename(explicit)
if (message.imageMessage) return `whatsapp-image.${extensionForMime(mimeType, 'jpg')}`
if (message.videoMessage) return `whatsapp-video.${extensionForMime(mimeType, 'mp4')}`
if (message.audioMessage) return `whatsapp-audio.${extensionForMime(mimeType, 'ogg')}`
if (message.stickerMessage) return `whatsapp-sticker.${extensionForMime(mimeType, 'webp')}`
return `whatsapp-file.${extensionForMime(mimeType, 'bin')}`
}
function extensionForMime(mimeType: string, fallback: string): string {
const normalized = mimeType.split(';')[0]!.trim().toLowerCase()
switch (normalized) {
case 'image/jpeg':
return 'jpg'
case 'image/png':
return 'png'
case 'image/gif':
return 'gif'
case 'image/webp':
return 'webp'
case 'video/mp4':
return 'mp4'
case 'audio/ogg':
return 'ogg'
case 'audio/mpeg':
return 'mp3'
case 'application/pdf':
return 'pdf'
default:
return fallback
}
}
export class WhatsAppMediaService {
constructor(
private sock: WhatsAppSocket,
private store: AttachmentStore,
) {}
async downloadMessageMedia(message: proto.IWebMessageInfo, sessionId: string): Promise<LocalAttachment | null> {
const content = unwrapMessage(message.message as proto.IMessage | undefined)
if (!content) return null
const hasMedia = Boolean(
content.imageMessage ||
content.videoMessage ||
content.documentMessage ||
content.audioMessage ||
content.stickerMessage,
)
if (!hasMedia) return null
const mimeType = resolveMediaMime(content) ?? 'application/octet-stream'
const buffer = await downloadMediaMessage(
message as WAMessage,
'buffer',
{},
{
reuploadRequest: this.sock.updateMediaMessage,
logger: this.sock.logger,
},
) as Buffer
const kind = mimeType.startsWith('image/') ? 'image' : 'file'
const name = resolveMediaName(content, mimeType)
const target = this.store.resolvePath('whatsapp', sessionId, name)
await this.store.write(target, buffer)
return {
kind,
name,
path: target,
size: buffer.length,
mimeType,
buffer,
}
}
async sendMedia(jid: string, buffer: Buffer, mimeType: string, caption?: string, fileName?: string): Promise<void> {
if (mimeType.startsWith('image/')) {
await this.sock.sendMessage(jid, { image: buffer, caption, mimetype: mimeType })
return
}
if (mimeType.startsWith('video/')) {
await this.sock.sendMessage(jid, { video: buffer, caption, mimetype: mimeType })
return
}
if (mimeType.startsWith('audio/')) {
const effective = mimeType === 'audio/ogg' ? 'audio/ogg; codecs=opus' : mimeType
await this.sock.sendMessage(jid, { audio: buffer, ptt: true, mimetype: effective })
return
}
await this.sock.sendMessage(jid, {
document: buffer,
fileName: fileName || 'file',
caption,
mimetype: mimeType || 'application/octet-stream',
})
}
}

View File

@ -0,0 +1,180 @@
import * as crypto from 'node:crypto'
import * as path from 'node:path'
import {
clearWhatsAppAuth,
closeWhatsAppSocket,
createWhatsAppSocket,
isWhatsAppLoggedOut,
waitForWhatsAppCredsSave,
type WhatsAppSocket,
} from './session.js'
export type WhatsAppLoginStartResult = {
sessionKey: string
qr?: string
message: string
}
export type WhatsAppLoginPollResult =
| {
connected: true
accountJid: string
authDir: string
message: string
}
| {
connected: false
status: 'waiting' | 'expired' | 'error'
qr?: string
message: string
}
type LoginSession = {
authDir: string
sock: WhatsAppSocket
qr?: string
connected: boolean
accountJid?: string
error?: string
createdAt: number
}
const LOGIN_TTL_MS = 2 * 60 * 1000
const WAIT_FOR_QR_MS = 20_000
const sessions = new Map<string, LoginSession>()
export async function startWhatsAppLoginWithQr(options: {
authDir: string
force?: boolean
}): Promise<WhatsAppLoginStartResult> {
cleanupExpiredSessions()
const authDir = path.resolve(options.authDir)
if (options.force) {
closeSessionsForAuthDir(authDir)
clearWhatsAppAuth(authDir)
}
const sessionKey = crypto.randomUUID()
const session: LoginSession = {
authDir,
sock: undefined as unknown as WhatsAppSocket,
connected: false,
createdAt: Date.now(),
}
const qrPromise = waitForQr(session)
const sock = await createWhatsAppSocket({
authDir,
onQr: (qr) => {
session.qr = qr
},
})
session.sock = sock
sessions.set(sessionKey, session)
sock.ev.on('connection.update', async (update) => {
if (update.qr) {
session.qr = update.qr
}
if (update.connection === 'open') {
await waitForWhatsAppCredsSave(authDir)
session.connected = true
session.accountJid = sock.user?.id ?? ''
}
if (update.connection === 'close') {
if (isWhatsAppLoggedOut(update.lastDisconnect?.error)) {
session.error = 'WhatsApp session logged out. Please scan again.'
} else if (!session.connected) {
session.error = 'WhatsApp login connection closed. Please retry.'
}
}
})
await qrPromise
return {
sessionKey,
qr: session.qr,
message: session.qr
? 'Scan this QR in WhatsApp > Linked devices.'
: 'Waiting for WhatsApp QR code...',
}
}
export async function pollWhatsAppLoginWithQr(options: {
sessionKey: string
}): Promise<WhatsAppLoginPollResult> {
cleanupExpiredSessions()
const session = sessions.get(options.sessionKey)
if (!session) {
return {
connected: false,
status: 'expired',
message: 'WhatsApp login session expired. Generate a new QR code.',
}
}
if (session.connected) {
await waitForWhatsAppCredsSave(session.authDir)
const accountJid = session.accountJid || session.sock.user?.id || ''
closeWhatsAppSocket(session.sock, 'WhatsApp login complete')
sessions.delete(options.sessionKey)
return {
connected: true,
accountJid,
authDir: session.authDir,
message: 'WhatsApp linked successfully.',
}
}
if (session.error) {
closeWhatsAppSocket(session.sock, 'WhatsApp login error')
sessions.delete(options.sessionKey)
return {
connected: false,
status: 'error',
message: session.error,
}
}
return {
connected: false,
status: 'waiting',
qr: session.qr,
message: session.qr
? 'Waiting for WhatsApp scan confirmation...'
: 'Waiting for WhatsApp QR code...',
}
}
export async function logoutWhatsAppAuth(authDir: string): Promise<void> {
closeSessionsForAuthDir(path.resolve(authDir))
clearWhatsAppAuth(authDir)
}
function waitForQr(session: LoginSession): Promise<void> {
return new Promise((resolve) => {
const started = Date.now()
const timer = setInterval(() => {
if (session.qr || session.connected || Date.now() - started > WAIT_FOR_QR_MS) {
clearInterval(timer)
resolve()
}
}, 250)
})
}
function cleanupExpiredSessions(): void {
const now = Date.now()
for (const [sessionKey, session] of sessions) {
if (now - session.createdAt <= LOGIN_TTL_MS) continue
closeWhatsAppSocket(session.sock, 'WhatsApp login expired')
sessions.delete(sessionKey)
}
}
function closeSessionsForAuthDir(authDir: string): void {
for (const [sessionKey, session] of sessions) {
if (path.resolve(session.authDir) !== authDir) continue
closeWhatsAppSocket(session.sock, 'WhatsApp login superseded')
sessions.delete(sessionKey)
}
}

View File

@ -0,0 +1,168 @@
import * as fs from 'node:fs'
import * as path from 'node:path'
import {
DisconnectReason,
fetchLatestBaileysVersion,
makeCacheableSignalKeyStore,
makeWASocket,
useMultiFileAuthState,
} from '@whiskeysockets/baileys'
export type WhatsAppSocket = ReturnType<typeof makeWASocket>
const CREDS_FILE = 'creds.json'
const CREDS_BACKUP_FILE = 'creds.json.bak'
const LOGGED_OUT_STATUS = DisconnectReason?.loggedOut ?? 401
const credsSaveQueues = new Map<string, Promise<void>>()
export function hasWhatsAppAuth(authDir: string): boolean {
return fs.existsSync(resolveCredsPath(authDir))
}
export function clearWhatsAppAuth(authDir: string): void {
fs.rmSync(authDir, { recursive: true, force: true })
}
export function getWhatsAppDisconnectStatus(error: unknown): number | undefined {
const candidate = error as {
output?: { statusCode?: unknown }
statusCode?: unknown
} | null
const status = candidate?.output?.statusCode ?? candidate?.statusCode
return typeof status === 'number' ? status : undefined
}
export function isWhatsAppLoggedOut(error: unknown): boolean {
return getWhatsAppDisconnectStatus(error) === LOGGED_OUT_STATUS
}
export async function createWhatsAppSocket(options: {
authDir: string
onQr?: (qr: string) => void
verbose?: boolean
}): Promise<WhatsAppSocket> {
const authDir = path.resolve(options.authDir)
fs.mkdirSync(authDir, { recursive: true, mode: 0o700 })
maybeRestoreCredsFromBackup(authDir)
const logger = makeBaileysLogger(options.verbose ? 'info' : 'silent')
const { state, saveCreds } = await useMultiFileAuthState(authDir)
const { version } = await fetchLatestBaileysVersion()
const sock = makeWASocket({
auth: {
creds: state.creds,
keys: makeCacheableSignalKeyStore(state.keys, logger),
},
version,
logger,
printQRInTerminal: false,
browser: ['cc-haha', 'desktop', '1.0'],
syncFullHistory: false,
markOnlineOnConnect: false,
})
sock.ev.on('creds.update', () => enqueueSaveCreds(authDir, saveCreds))
if (options.onQr) {
sock.ev.on('connection.update', (update) => {
if (update.qr) options.onQr?.(update.qr)
})
}
const ws = sock.ws as unknown as { on?: (event: string, handler: (err: Error) => void) => void }
ws.on?.('error', (err) => {
console.error('[WhatsApp] WebSocket error:', err.message)
})
return sock
}
export function closeWhatsAppSocket(sock: WhatsAppSocket, reason = 'closed'): void {
try {
const closable = sock as unknown as { end?: (error?: Error) => void }
closable.end?.(new Error(reason))
} catch {
// best-effort shutdown
}
}
export async function waitForWhatsAppCredsSave(authDir: string): Promise<void> {
await (credsSaveQueues.get(path.resolve(authDir)) ?? Promise.resolve())
}
function resolveCredsPath(authDir: string): string {
return path.join(path.resolve(authDir), CREDS_FILE)
}
function resolveCredsBackupPath(authDir: string): string {
return path.join(path.resolve(authDir), CREDS_BACKUP_FILE)
}
function maybeRestoreCredsFromBackup(authDir: string): void {
const credsPath = resolveCredsPath(authDir)
const backupPath = resolveCredsBackupPath(authDir)
if (isValidJsonFile(credsPath) || !isValidJsonFile(backupPath)) return
fs.copyFileSync(backupPath, credsPath)
fs.chmodSync(credsPath, 0o600)
}
function enqueueSaveCreds(authDir: string, saveCreds: () => Promise<void> | void): void {
const resolved = path.resolve(authDir)
const prev = credsSaveQueues.get(resolved) ?? Promise.resolve()
const next = prev
.then(() => safeSaveCreds(resolved, saveCreds))
.catch((err) => {
console.warn('[WhatsApp] Failed to save credentials:', err instanceof Error ? err.message : err)
})
.finally(() => {
if (credsSaveQueues.get(resolved) === next) {
credsSaveQueues.delete(resolved)
}
})
credsSaveQueues.set(resolved, next)
}
async function safeSaveCreds(authDir: string, saveCreds: () => Promise<void> | void): Promise<void> {
backupCreds(authDir)
await Promise.resolve(saveCreds())
fs.chmodSync(resolveCredsPath(authDir), 0o600)
}
function backupCreds(authDir: string): void {
const credsPath = resolveCredsPath(authDir)
if (!isValidJsonFile(credsPath)) return
try {
const backupPath = resolveCredsBackupPath(authDir)
fs.copyFileSync(credsPath, backupPath)
fs.chmodSync(backupPath, 0o600)
} catch {
// keep the previous backup
}
}
function isValidJsonFile(filePath: string): boolean {
try {
JSON.parse(fs.readFileSync(filePath, 'utf-8'))
return true
} catch {
return false
}
}
function makeBaileysLogger(level: 'silent' | 'info'): any {
const noop = () => {}
const sink = level === 'silent'
? { trace: noop, debug: noop, info: noop, warn: noop, error: noop, fatal: noop }
: {
trace: console.debug.bind(console),
debug: console.debug.bind(console),
info: console.info.bind(console),
warn: console.warn.bind(console),
error: console.error.bind(console),
fatal: console.error.bind(console),
}
return {
level,
child: () => makeBaileysLogger(level),
...sink,
}
}

View File

@ -110,6 +110,7 @@ export class ElectronServerRuntime {
['telegram', '--telegram'],
['wechat', '--wechat'],
['dingtalk', '--dingtalk'],
['whatsapp', '--whatsapp'],
] as const) {
try {
const child = spawnSidecar(createAdapterPlan({

View File

@ -79,6 +79,15 @@ describe('Electron sidecar manager', () => {
})
expect(adapter.env.ADAPTER_SERVER_URL).toBe('ws://127.0.0.1:4567')
expect(adapter.args).toEqual(['adapters', '--app-root', '/app', '--telegram'])
const whatsappAdapter = createAdapterPlan({
desktopRoot: '/app/desktop',
appRoot: '/app',
serverUrl: 'http://127.0.0.1:4567',
flag: '--whatsapp',
env: { CLAUDE_CONFIG_DIR: configDir },
})
expect(whatsappAdapter.args).toEqual(['adapters', '--app-root', '/app', '--whatsapp'])
} finally {
rmSync(configDir, { recursive: true, force: true })
}

View File

@ -211,7 +211,7 @@ export function createAdapterPlan({
desktopRoot: string
appRoot: string
serverUrl: string
flag: '--feishu' | '--telegram' | '--wechat' | '--dingtalk'
flag: '--feishu' | '--telegram' | '--wechat' | '--dingtalk' | '--whatsapp'
h5DistDir?: string
env?: NodeJS.ProcessEnv
}): SidecarPlan {

View File

@ -8,7 +8,7 @@
*
* claude-sidecar server --app-root <path> --host 127.0.0.1 --port 12345
* claude-sidecar cli --app-root <path> [ CLI ...]
* claude-sidecar adapters --app-root <path> [--feishu] [--telegram] [--wechat] [--dingtalk]
* claude-sidecar adapters --app-root <path> [--feishu] [--telegram] [--wechat] [--dingtalk] [--whatsapp]
*
* process.env / process.argv await
* src/server/index.tssrc/entrypoints/cli.tsx
@ -52,13 +52,14 @@ if (mode === 'adapters') {
async function runAdapters(rawArgs: string[]): Promise<void> {
// adapters 模式的参数解析独立于 server/cli —— 这里只接受 --feishu /
// --telegram / --wechat / --dingtalk 选择启用哪个适配器,再加可选的 --app-root透传给
// --telegram / --wechat / --dingtalk / --whatsapp 选择启用哪个适配器,再加可选的 --app-root透传给
// adapters/common/config.ts 内的 process.env 读取)。
let appRoot: string | null = process.env.CLAUDE_APP_ROOT ?? null
let enableFeishu = false
let enableTelegram = false
let enableWechat = false
let enableDingtalk = false
let enableWhatsApp = false
for (let i = 0; i < rawArgs.length; i++) {
const arg = rawArgs[i]
@ -83,12 +84,16 @@ async function runAdapters(rawArgs: string[]): Promise<void> {
enableDingtalk = true
continue
}
if (arg === '--whatsapp') {
enableWhatsApp = true
continue
}
console.warn(`claude-sidecar adapters: ignoring unknown arg "${arg}"`)
}
if (!enableFeishu && !enableTelegram && !enableWechat && !enableDingtalk) {
if (!enableFeishu && !enableTelegram && !enableWechat && !enableDingtalk && !enableWhatsApp) {
console.error(
'claude-sidecar adapters: must enable at least one of --feishu / --telegram / --wechat / --dingtalk',
'claude-sidecar adapters: must enable at least one of --feishu / --telegram / --wechat / --dingtalk / --whatsapp',
)
process.exit(2)
}
@ -159,6 +164,19 @@ async function runAdapters(rawArgs: string[]): Promise<void> {
}
}
if (enableWhatsApp) {
const { hasWhatsAppAuth } = await import('../../adapters/whatsapp/session.ts')
if (!hasWhatsAppAuth(config.whatsapp.authDir)) {
console.warn(
'[claude-sidecar] --whatsapp requested but no QR-linked WhatsApp account found in env or ~/.claude/adapters.json — skipping',
)
} else {
console.log('[claude-sidecar] starting WhatsApp adapter')
await import('../../adapters/whatsapp/index.ts')
started += 1
}
}
if (started === 0) {
console.error(
'[claude-sidecar] no adapter could be started — check credentials in env or ~/.claude/adapters.json',

View File

@ -1766,6 +1766,7 @@ fn start_adapters_sidecars(app: &AppHandle) -> Result<Vec<CommandChild>, String>
("telegram", "--telegram"),
("wechat", "--wechat"),
("dingtalk", "--dingtalk"),
("whatsapp", "--whatsapp"),
] {
let mut sidecar = app
.shell()

View File

@ -17,6 +17,21 @@ export type DingtalkRegistrationPoll = {
config?: AdapterFileConfig
}
export type WhatsAppLoginStart = {
sessionKey: string
qrDataUrl?: string
message: string
}
export type WhatsAppLoginPoll =
| AdapterFileConfig
| {
connected: false
status: 'waiting' | 'expired' | 'error'
qrDataUrl?: string
message: string
}
export const adaptersApi = {
getConfig() {
return api.get<AdapterFileConfig>('/api/adapters')
@ -52,4 +67,16 @@ export const adaptersApi = {
pollDingtalkRegistration(deviceCode: string) {
return api.post<DingtalkRegistrationPoll>('/api/adapters/dingtalk/registration/poll', { deviceCode })
},
startWhatsAppLogin() {
return api.post<WhatsAppLoginStart>('/api/adapters/whatsapp/login/start', {})
},
pollWhatsAppLogin(sessionKey: string) {
return api.post<WhatsAppLoginPoll>('/api/adapters/whatsapp/login/poll', { sessionKey }, { timeout: 45_000 })
},
unbindWhatsApp() {
return api.post<AdapterFileConfig>('/api/adapters/whatsapp/unbind', {})
},
}

View File

@ -442,11 +442,12 @@ export const en = {
// Settings > Adapters
'settings.tab.adapters': 'IM Adapters',
'settings.adapters.description': 'Configure IM adapters to chat with Claude Code via WeChat, DingTalk, Telegram, or Feishu.',
'settings.adapters.description': 'Configure IM adapters to chat with Claude Code via WeChat, DingTalk, WhatsApp, Telegram, or Feishu.',
'settings.adapters.telegram': 'Telegram',
'settings.adapters.feishu': 'Feishu',
'settings.adapters.wechat': 'WeChat',
'settings.adapters.dingtalk': 'DingTalk',
'settings.adapters.whatsapp': 'WhatsApp',
'settings.adapters.botToken': 'Bot Token',
'settings.adapters.botTokenPlaceholder': 'Paste token from @BotFather',
'settings.adapters.appId': 'App ID',
@ -468,6 +469,7 @@ export const en = {
'settings.adapters.fsAllowedUsersPlaceholder': 'e.g. ou_xxx, ou_yyy',
'settings.adapters.wcAllowedUsersPlaceholder': 'e.g. wx_user_id_1, wx_user_id_2',
'settings.adapters.dtAllowedUsersPlaceholder': 'e.g. manager001, staff_yyy',
'settings.adapters.waAllowedUsersPlaceholder': 'e.g. 15551234567@s.whatsapp.net',
'settings.adapters.defaultProject': 'Default Project',
'settings.adapters.defaultProjectHint': 'Default working directory for new IM sessions. If empty, the bot uses the current user working directory.',
'settings.adapters.streamingCard': 'Streaming Card Mode',
@ -485,7 +487,7 @@ export const en = {
'settings.adapters.codeExpired': 'Expired',
'settings.adapters.codeExpiresIn': 'Expires in',
'settings.adapters.minutes': 'min',
'settings.adapters.pairingCodeHint': 'Send this code to the Bot in DingTalk, Feishu, WeChat, or Telegram private chat. WeChat requires QR binding first.',
'settings.adapters.pairingCodeHint': 'Send this code to the Bot in DingTalk, Feishu, WeChat, WhatsApp, or Telegram private chat. WeChat and WhatsApp require QR binding first.',
'settings.adapters.pairedUsers': 'Paired Users',
'settings.adapters.noPairedUsers': 'No paired users yet',
'settings.adapters.unbind': 'Unbind',
@ -494,6 +496,7 @@ export const en = {
'settings.adapters.platform.feishu': 'Feishu',
'settings.adapters.platform.wechat': 'WeChat',
'settings.adapters.platform.dingtalk': 'DingTalk',
'settings.adapters.platform.whatsapp': 'WhatsApp',
'settings.adapters.dingtalkQrTitle': 'Bind DingTalk bot with QR',
'settings.adapters.dingtalkQrDesc': 'Scan with the DingTalk mobile app to create and authorize the bot. Client ID / Secret are saved automatically when it succeeds.',
'settings.adapters.dingtalkStartAuth': 'Scan to bind',
@ -527,6 +530,20 @@ export const en = {
'settings.adapters.wechatUnbound': 'WeChat account unbound.',
'settings.adapters.wechatUnbindFailed': 'Failed to unbind WeChat.',
'settings.adapters.wechatAllowedUsersHint': 'QR binding enables the account only. Users still need to send a pairing code, or you can add allowed WeChat user IDs here.',
'settings.adapters.whatsappBind': 'Scan to Bind',
'settings.adapters.whatsappRebind': 'Rescan',
'settings.adapters.whatsappConnected': 'WhatsApp is bound',
'settings.adapters.whatsappNotConnected': 'WhatsApp is not bound',
'settings.adapters.whatsappQrHint': 'Generate a QR code, then scan it from WhatsApp > Linked devices. The adapter restarts after binding.',
'settings.adapters.whatsappQrAlt': 'WhatsApp binding QR code',
'settings.adapters.whatsappWaiting': 'Waiting for WhatsApp scan confirmation...',
'settings.adapters.whatsappBindSuccess': 'WhatsApp bound successfully.',
'settings.adapters.whatsappBindFailed': 'Failed to bind WhatsApp.',
'settings.adapters.whatsappUnbindAccount': 'Unbind WhatsApp account',
'settings.adapters.whatsappUnbindAccountConfirm': 'Unbind this WhatsApp account? You will need to scan again before WhatsApp can receive or send messages.',
'settings.adapters.whatsappUnbound': 'WhatsApp account unbound.',
'settings.adapters.whatsappUnbindFailed': 'Failed to unbind WhatsApp.',
'settings.adapters.whatsappAllowedUsersHint': 'QR binding links the WhatsApp Web account only. Users still need to send a pairing code, or you can add allowed WhatsApp JIDs here.',
// Settings > MCP
'settings.mcp.title': 'MCP servers',

View File

@ -444,11 +444,12 @@ export const jp: Record<TranslationKey, string> = {
// Settings > Adapters
'settings.tab.adapters': 'IM アダプター',
'settings.adapters.description': 'WeChat、DingTalk、Telegram、Feishu 経由で Claude Code とチャットできるよう IM アダプターを設定します。',
'settings.adapters.description': 'WeChat、DingTalk、WhatsApp、Telegram、Feishu 経由で Claude Code とチャットできるよう IM アダプターを設定します。',
'settings.adapters.telegram': 'Telegram',
'settings.adapters.feishu': 'Feishu',
'settings.adapters.wechat': 'WeChat',
'settings.adapters.dingtalk': 'DingTalk',
'settings.adapters.whatsapp': 'WhatsApp',
'settings.adapters.botToken': 'Bot トークン',
'settings.adapters.botTokenPlaceholder': '@BotFather から取得したトークンを貼り付け',
'settings.adapters.appId': 'App ID',
@ -470,6 +471,7 @@ export const jp: Record<TranslationKey, string> = {
'settings.adapters.fsAllowedUsersPlaceholder': '例: ou_xxx, ou_yyy',
'settings.adapters.wcAllowedUsersPlaceholder': '例: wx_user_id_1, wx_user_id_2',
'settings.adapters.dtAllowedUsersPlaceholder': '例: manager001, staff_yyy',
'settings.adapters.waAllowedUsersPlaceholder': '例: 15551234567@s.whatsapp.net',
'settings.adapters.defaultProject': 'デフォルトプロジェクト',
'settings.adapters.defaultProjectHint': '新しい IM セッションのデフォルト作業ディレクトリ。空欄の場合、ボットは現在のユーザーの作業ディレクトリを使用します。',
'settings.adapters.streamingCard': 'ストリーミングカードモード',
@ -487,7 +489,7 @@ export const jp: Record<TranslationKey, string> = {
'settings.adapters.codeExpired': '期限切れ',
'settings.adapters.codeExpiresIn': '有効期限',
'settings.adapters.minutes': '分',
'settings.adapters.pairingCodeHint': 'このコードを DingTalk、Feishu、WeChat、Telegram の個別チャットでボットに送信してください。WeChat の場合は先に QR コードでのひも付けが必要です。',
'settings.adapters.pairingCodeHint': 'このコードを DingTalk、Feishu、WeChat、WhatsApp、Telegram の個別チャットでボットに送信してください。WeChat と WhatsApp は先に QR コードでのひも付けが必要です。',
'settings.adapters.pairedUsers': 'ペア済みユーザー',
'settings.adapters.noPairedUsers': 'ペア済みユーザーはまだいません',
'settings.adapters.unbind': 'ひも付け解除',
@ -496,6 +498,7 @@ export const jp: Record<TranslationKey, string> = {
'settings.adapters.platform.feishu': 'Feishu',
'settings.adapters.platform.wechat': 'WeChat',
'settings.adapters.platform.dingtalk': 'DingTalk',
'settings.adapters.platform.whatsapp': 'WhatsApp',
'settings.adapters.dingtalkQrTitle': 'QR コードで DingTalk ボットをひも付け',
'settings.adapters.dingtalkQrDesc': 'DingTalk モバイルアプリでスキャンしてボットを作成・承認します。成功すると Client ID / Secret が自動的に保存されます。',
'settings.adapters.dingtalkStartAuth': 'スキャンしてひも付け',
@ -529,6 +532,20 @@ export const jp: Record<TranslationKey, string> = {
'settings.adapters.wechatUnbound': 'WeChat アカウントのひも付けを解除しました。',
'settings.adapters.wechatUnbindFailed': 'WeChat のひも付け解除に失敗しました。',
'settings.adapters.wechatAllowedUsersHint': 'QR コードのひも付けはアカウントを有効化するだけです。ユーザーは引き続きペアリングコードを送信する必要があります。または、ここに許可する WeChat ユーザー ID を追加できます。',
'settings.adapters.whatsappBind': 'スキャンしてひも付け',
'settings.adapters.whatsappRebind': '再スキャン',
'settings.adapters.whatsappConnected': 'WhatsApp はひも付け済みです',
'settings.adapters.whatsappNotConnected': 'WhatsApp はひも付けされていません',
'settings.adapters.whatsappQrHint': 'QR コードを生成し、WhatsApp の「リンク済みデバイス」からスキャンして確認します。ひも付け後にアダプターが再起動します。',
'settings.adapters.whatsappQrAlt': 'WhatsApp ひも付け用 QR コード',
'settings.adapters.whatsappWaiting': 'WhatsApp のスキャン確認を待っています...',
'settings.adapters.whatsappBindSuccess': 'WhatsApp のひも付けに成功しました。',
'settings.adapters.whatsappBindFailed': 'WhatsApp のひも付けに失敗しました。',
'settings.adapters.whatsappUnbindAccount': 'WhatsApp アカウントのひも付けを解除',
'settings.adapters.whatsappUnbindAccountConfirm': 'この WhatsApp アカウントのひも付けを解除しますかWhatsApp がメッセージを送受信できるようにするには、再度スキャンが必要になります。',
'settings.adapters.whatsappUnbound': 'WhatsApp アカウントのひも付けを解除しました。',
'settings.adapters.whatsappUnbindFailed': 'WhatsApp のひも付け解除に失敗しました。',
'settings.adapters.whatsappAllowedUsersHint': 'QR コードのひも付けは WhatsApp Web アカウントを有効化するだけです。ユーザーは引き続きペアリングコードを送信する必要があります。または、ここに許可する WhatsApp JID を追加できます。',
// Settings > MCP
'settings.mcp.title': 'MCP サーバー',

View File

@ -444,11 +444,12 @@ export const kr: Record<TranslationKey, string> = {
// Settings > Adapters
'settings.tab.adapters': 'IM 어댑터',
'settings.adapters.description': 'WeChat, DingTalk, Telegram, Feishu를 통해 Claude Code와 채팅할 수 있도록 IM 어댑터를 구성합니다.',
'settings.adapters.description': 'WeChat, DingTalk, WhatsApp, Telegram, Feishu를 통해 Claude Code와 채팅할 수 있도록 IM 어댑터를 구성합니다.',
'settings.adapters.telegram': 'Telegram',
'settings.adapters.feishu': 'Feishu',
'settings.adapters.wechat': 'WeChat',
'settings.adapters.dingtalk': 'DingTalk',
'settings.adapters.whatsapp': 'WhatsApp',
'settings.adapters.botToken': '봇 토큰',
'settings.adapters.botTokenPlaceholder': '@BotFather에서 받은 토큰 붙여넣기',
'settings.adapters.appId': 'App ID',
@ -470,6 +471,7 @@ export const kr: Record<TranslationKey, string> = {
'settings.adapters.fsAllowedUsersPlaceholder': '예: ou_xxx, ou_yyy',
'settings.adapters.wcAllowedUsersPlaceholder': '예: wx_user_id_1, wx_user_id_2',
'settings.adapters.dtAllowedUsersPlaceholder': '예: manager001, staff_yyy',
'settings.adapters.waAllowedUsersPlaceholder': '예: 15551234567@s.whatsapp.net',
'settings.adapters.defaultProject': '기본 프로젝트',
'settings.adapters.defaultProjectHint': '새 IM 세션의 기본 작업 디렉터리입니다. 비어 있으면 봇은 현재 사용자 작업 디렉터리를 사용합니다.',
'settings.adapters.streamingCard': '스트리밍 카드 모드',
@ -487,7 +489,7 @@ export const kr: Record<TranslationKey, string> = {
'settings.adapters.codeExpired': '만료됨',
'settings.adapters.codeExpiresIn': '만료까지',
'settings.adapters.minutes': '분',
'settings.adapters.pairingCodeHint': '이 코드를 DingTalk, Feishu, WeChat 또는 Telegram 개인 채팅에서 봇에 보내세요. WeChat은 먼저 QR 연결이 필요합니다.',
'settings.adapters.pairingCodeHint': '이 코드를 DingTalk, Feishu, WeChat, WhatsApp 또는 Telegram 개인 채팅에서 봇에 보내세요. WeChat과 WhatsApp은 먼저 QR 연결이 필요합니다.',
'settings.adapters.pairedUsers': '페어링된 사용자',
'settings.adapters.noPairedUsers': '아직 페어링된 사용자가 없습니다',
'settings.adapters.unbind': '연결 해제',
@ -496,6 +498,7 @@ export const kr: Record<TranslationKey, string> = {
'settings.adapters.platform.feishu': 'Feishu',
'settings.adapters.platform.wechat': 'WeChat',
'settings.adapters.platform.dingtalk': 'DingTalk',
'settings.adapters.platform.whatsapp': 'WhatsApp',
'settings.adapters.dingtalkQrTitle': 'QR로 DingTalk 봇 연결',
'settings.adapters.dingtalkQrDesc': 'DingTalk 모바일 앱으로 스캔하여 봇을 만들고 승인합니다. 성공하면 Client ID / Secret이 자동으로 저장됩니다.',
'settings.adapters.dingtalkStartAuth': '스캔하여 연결',
@ -529,6 +532,20 @@ export const kr: Record<TranslationKey, string> = {
'settings.adapters.wechatUnbound': 'WeChat 계정 연결을 해제했습니다.',
'settings.adapters.wechatUnbindFailed': 'WeChat 연결 해제에 실패했습니다.',
'settings.adapters.wechatAllowedUsersHint': 'QR 연결은 계정만 활성화합니다. 사용자는 여전히 페어링 코드를 보내야 하거나, 여기에 허용할 WeChat 사용자 ID를 추가할 수 있습니다.',
'settings.adapters.whatsappBind': '스캔하여 연결',
'settings.adapters.whatsappRebind': '다시 스캔',
'settings.adapters.whatsappConnected': 'WhatsApp이 연결되었습니다',
'settings.adapters.whatsappNotConnected': 'WhatsApp이 연결되지 않았습니다',
'settings.adapters.whatsappQrHint': 'QR 코드를 생성한 뒤 WhatsApp의 연결된 기기에서 스캔하세요. 연결 후 어댑터가 다시 시작됩니다.',
'settings.adapters.whatsappQrAlt': 'WhatsApp 연결 QR 코드',
'settings.adapters.whatsappWaiting': 'WhatsApp 스캔 확인을 기다리는 중...',
'settings.adapters.whatsappBindSuccess': 'WhatsApp 연결에 성공했습니다.',
'settings.adapters.whatsappBindFailed': 'WhatsApp 연결에 실패했습니다.',
'settings.adapters.whatsappUnbindAccount': 'WhatsApp 계정 연결 해제',
'settings.adapters.whatsappUnbindAccountConfirm': '이 WhatsApp 계정의 연결을 해제하시겠습니까? WhatsApp이 메시지를 주고받을 수 있으려면 다시 스캔해야 합니다.',
'settings.adapters.whatsappUnbound': 'WhatsApp 계정 연결을 해제했습니다.',
'settings.adapters.whatsappUnbindFailed': 'WhatsApp 연결 해제에 실패했습니다.',
'settings.adapters.whatsappAllowedUsersHint': 'QR 연결은 WhatsApp Web 계정만 활성화합니다. 사용자는 여전히 페어링 코드를 보내야 하거나, 여기에 허용할 WhatsApp JID를 추가할 수 있습니다.',
// Settings > MCP
'settings.mcp.title': 'MCP 서버',

View File

@ -444,11 +444,12 @@ export const zh: Record<TranslationKey, string> = {
// Settings > Adapters
'settings.tab.adapters': 'IM 接入',
'settings.adapters.description': '配置即時通訊介面卡,透過微信、釘釘、Telegram 或飛書與 Claude Code 對話。',
'settings.adapters.description': '配置即時通訊介面卡,透過微信、釘釘、WhatsApp、Telegram 或飛書與 Claude Code 對話。',
'settings.adapters.telegram': 'Telegram',
'settings.adapters.feishu': '飛書',
'settings.adapters.wechat': '微信',
'settings.adapters.dingtalk': '釘釘',
'settings.adapters.whatsapp': 'WhatsApp',
'settings.adapters.botToken': 'Bot Token',
'settings.adapters.botTokenPlaceholder': '貼上從 @BotFather 獲取的 Token',
'settings.adapters.appId': 'App ID',
@ -470,6 +471,7 @@ export const zh: Record<TranslationKey, string> = {
'settings.adapters.fsAllowedUsersPlaceholder': '如 ou_xxx, ou_yyy',
'settings.adapters.wcAllowedUsersPlaceholder': '如 wx_user_id_1, wx_user_id_2',
'settings.adapters.dtAllowedUsersPlaceholder': '如 manager001, staff_yyy',
'settings.adapters.waAllowedUsersPlaceholder': '如 15551234567@s.whatsapp.net',
'settings.adapters.defaultProject': '預設專案',
'settings.adapters.defaultProjectHint': '新 IM 會話的預設工作目錄。留空則使用當前使用者工作目錄。',
'settings.adapters.streamingCard': '流式卡片模式',
@ -487,7 +489,7 @@ export const zh: Record<TranslationKey, string> = {
'settings.adapters.codeExpired': '已過期',
'settings.adapters.codeExpiresIn': '有效期剩餘',
'settings.adapters.minutes': '分鐘',
'settings.adapters.pairingCodeHint': '請在釘釘、飛書、微信或 Telegram 私聊中傳送此配對碼給 Bot。微信需要先完成掃碼繫結。',
'settings.adapters.pairingCodeHint': '請在釘釘、飛書、微信、WhatsApp 或 Telegram 私聊中傳送此配對碼給 Bot。微信和 WhatsApp 需要先完成掃碼繫結。',
'settings.adapters.pairedUsers': '已配對使用者',
'settings.adapters.noPairedUsers': '暫無已配對使用者',
'settings.adapters.unbind': '解綁',
@ -496,6 +498,7 @@ export const zh: Record<TranslationKey, string> = {
'settings.adapters.platform.feishu': '飛書',
'settings.adapters.platform.wechat': '微信',
'settings.adapters.platform.dingtalk': '釘釘',
'settings.adapters.platform.whatsapp': 'WhatsApp',
'settings.adapters.dingtalkQrTitle': '掃碼繫結釘釘機器人',
'settings.adapters.dingtalkQrDesc': '使用釘釘手機 App 掃碼,一鍵建立並授權機器人。成功後會自動儲存 Client ID / Secret 並重啟介面卡。',
'settings.adapters.dingtalkStartAuth': '掃碼繫結',
@ -529,6 +532,20 @@ export const zh: Record<TranslationKey, string> = {
'settings.adapters.wechatUnbound': '微信繫結已解除。',
'settings.adapters.wechatUnbindFailed': '微信解綁失敗。',
'settings.adapters.wechatAllowedUsersHint': '微信掃碼只繫結賬號能力;使用者仍需傳送配對碼,或在這裡額外填寫允許的微信使用者 ID。',
'settings.adapters.whatsappBind': '掃碼繫結',
'settings.adapters.whatsappRebind': '重新掃碼',
'settings.adapters.whatsappConnected': 'WhatsApp 已繫結',
'settings.adapters.whatsappNotConnected': 'WhatsApp 未繫結',
'settings.adapters.whatsappQrHint': '生成二維碼後,在 WhatsApp 的「已連結裝置」中掃碼確認。繫結完成後介面卡會自動重啟。',
'settings.adapters.whatsappQrAlt': 'WhatsApp 繫結二維碼',
'settings.adapters.whatsappWaiting': '等待 WhatsApp 掃碼確認...',
'settings.adapters.whatsappBindSuccess': 'WhatsApp 繫結成功。',
'settings.adapters.whatsappBindFailed': 'WhatsApp 繫結失敗。',
'settings.adapters.whatsappUnbindAccount': '解除 WhatsApp 繫結',
'settings.adapters.whatsappUnbindAccountConfirm': '確定解除這個 WhatsApp 帳號繫結嗎解除後需要重新掃碼WhatsApp 才能繼續收發訊息。',
'settings.adapters.whatsappUnbound': 'WhatsApp 繫結已解除。',
'settings.adapters.whatsappUnbindFailed': 'WhatsApp 解綁失敗。',
'settings.adapters.whatsappAllowedUsersHint': 'WhatsApp 掃碼只繫結 Web 帳號能力;使用者仍需傳送配對碼,或在這裡額外填寫允許的 WhatsApp JID。',
// Settings > MCP
'settings.mcp.title': 'MCP 服務',

View File

@ -444,11 +444,12 @@ export const zh: Record<TranslationKey, string> = {
// Settings > Adapters
'settings.tab.adapters': 'IM 接入',
'settings.adapters.description': '配置即时通讯适配器,通过微信、钉钉、Telegram 或飞书与 Claude Code 对话。',
'settings.adapters.description': '配置即时通讯适配器,通过微信、钉钉、WhatsApp、Telegram 或飞书与 Claude Code 对话。',
'settings.adapters.telegram': 'Telegram',
'settings.adapters.feishu': '飞书',
'settings.adapters.wechat': '微信',
'settings.adapters.dingtalk': '钉钉',
'settings.adapters.whatsapp': 'WhatsApp',
'settings.adapters.botToken': 'Bot Token',
'settings.adapters.botTokenPlaceholder': '粘贴从 @BotFather 获取的 Token',
'settings.adapters.appId': 'App ID',
@ -470,6 +471,7 @@ export const zh: Record<TranslationKey, string> = {
'settings.adapters.fsAllowedUsersPlaceholder': '如 ou_xxx, ou_yyy',
'settings.adapters.wcAllowedUsersPlaceholder': '如 wx_user_id_1, wx_user_id_2',
'settings.adapters.dtAllowedUsersPlaceholder': '如 manager001, staff_yyy',
'settings.adapters.waAllowedUsersPlaceholder': '如 15551234567@s.whatsapp.net',
'settings.adapters.defaultProject': '默认项目',
'settings.adapters.defaultProjectHint': '新 IM 会话的默认工作目录。留空则使用当前用户工作目录。',
'settings.adapters.streamingCard': '流式卡片模式',
@ -487,7 +489,7 @@ export const zh: Record<TranslationKey, string> = {
'settings.adapters.codeExpired': '已过期',
'settings.adapters.codeExpiresIn': '有效期剩余',
'settings.adapters.minutes': '分钟',
'settings.adapters.pairingCodeHint': '请在钉钉、飞书、微信或 Telegram 私聊中发送此配对码给 Bot。微信需要先完成扫码绑定。',
'settings.adapters.pairingCodeHint': '请在钉钉、飞书、微信、WhatsApp 或 Telegram 私聊中发送此配对码给 Bot。微信和 WhatsApp 需要先完成扫码绑定。',
'settings.adapters.pairedUsers': '已配对用户',
'settings.adapters.noPairedUsers': '暂无已配对用户',
'settings.adapters.unbind': '解绑',
@ -496,6 +498,7 @@ export const zh: Record<TranslationKey, string> = {
'settings.adapters.platform.feishu': '飞书',
'settings.adapters.platform.wechat': '微信',
'settings.adapters.platform.dingtalk': '钉钉',
'settings.adapters.platform.whatsapp': 'WhatsApp',
'settings.adapters.dingtalkQrTitle': '扫码绑定钉钉机器人',
'settings.adapters.dingtalkQrDesc': '使用钉钉手机 App 扫码,一键创建并授权机器人。成功后会自动保存 Client ID / Secret 并重启适配器。',
'settings.adapters.dingtalkStartAuth': '扫码绑定',
@ -529,6 +532,20 @@ export const zh: Record<TranslationKey, string> = {
'settings.adapters.wechatUnbound': '微信绑定已解除。',
'settings.adapters.wechatUnbindFailed': '微信解绑失败。',
'settings.adapters.wechatAllowedUsersHint': '微信扫码只绑定账号能力;用户仍需发送配对码,或在这里额外填写允许的微信用户 ID。',
'settings.adapters.whatsappBind': '扫码绑定',
'settings.adapters.whatsappRebind': '重新扫码',
'settings.adapters.whatsappConnected': 'WhatsApp 已绑定',
'settings.adapters.whatsappNotConnected': 'WhatsApp 未绑定',
'settings.adapters.whatsappQrHint': '生成二维码后,在 WhatsApp 的「已关联设备」中扫码确认。绑定完成后适配器会自动重启。',
'settings.adapters.whatsappQrAlt': 'WhatsApp 绑定二维码',
'settings.adapters.whatsappWaiting': '等待 WhatsApp 扫码确认...',
'settings.adapters.whatsappBindSuccess': 'WhatsApp 绑定成功。',
'settings.adapters.whatsappBindFailed': 'WhatsApp 绑定失败。',
'settings.adapters.whatsappUnbindAccount': '解除 WhatsApp 绑定',
'settings.adapters.whatsappUnbindAccountConfirm': '确定解除这个 WhatsApp 账号绑定吗解除后需要重新扫码WhatsApp 才能继续收发消息。',
'settings.adapters.whatsappUnbound': 'WhatsApp 绑定已解除。',
'settings.adapters.whatsappUnbindFailed': 'WhatsApp 解绑失败。',
'settings.adapters.whatsappAllowedUsersHint': 'WhatsApp 扫码只绑定 Web 账号能力;用户仍需发送配对码,或在这里额外填写允许的 WhatsApp JID。',
// Settings > MCP
'settings.mcp.title': 'MCP 服务',

View File

@ -19,7 +19,10 @@ function renderAdapterSettings(
isLoading: false,
fetchConfig: vi.fn(async () => {}),
updateConfig: vi.fn(async () => {}),
startWhatsAppLogin: vi.fn(async () => ({ message: 'ok', sessionKey: 'whatsapp-session' })),
pollWhatsAppLogin: vi.fn(async () => ({ connected: false })),
unbindWechatAccount: vi.fn(async () => {}),
unbindWhatsAppAccount: vi.fn(async () => {}),
unbindDingtalkBot: vi.fn(async () => {}),
removePairedUser: vi.fn(async () => {}),
beginDingtalkRegistration: vi.fn(async () => ({
@ -114,4 +117,14 @@ describe('AdapterSettings account unbind confirmation', () => {
expect(unbindDingtalkBot).toHaveBeenCalledTimes(1)
})
})
it('shows WhatsApp QR binding controls', () => {
renderAdapterSettings({})
fireEvent.click(screen.getByRole('tab', { name: 'WhatsApp' }))
expect(screen.getByText('WhatsApp is not bound')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Scan to Bind' })).toBeInTheDocument()
expect(screen.getByPlaceholderText('e.g. 15551234567@s.whatsapp.net')).toBeInTheDocument()
})
})

View File

@ -7,9 +7,9 @@ import { DirectoryPicker } from '../components/shared/DirectoryPicker'
import { ConfirmDialog } from '../components/shared/ConfirmDialog'
import QRCode from 'qrcode'
type ImTab = 'feishu' | 'wechat' | 'dingtalk' | 'telegram'
type ImPlatform = 'telegram' | 'feishu' | 'wechat' | 'dingtalk'
type AdapterUnbindTarget = 'wechatAccount' | 'dingtalkBot'
type ImTab = 'feishu' | 'wechat' | 'dingtalk' | 'whatsapp' | 'telegram'
type ImPlatform = 'telegram' | 'feishu' | 'wechat' | 'dingtalk' | 'whatsapp'
type AdapterUnbindTarget = 'wechatAccount' | 'dingtalkBot' | 'whatsappAccount'
const FEISHU_CREATE_BOT_URL = 'https://open.feishu.cn/page/openclaw?form=multiAgent'
@ -23,11 +23,14 @@ export function AdapterSettings() {
generatePairingCode,
startWechatLogin,
pollWechatLogin,
startWhatsAppLogin,
pollWhatsAppLogin,
removePairedUser,
beginDingtalkRegistration,
pollDingtalkRegistration,
unbindWechatAccount,
unbindDingtalkBot,
unbindWhatsAppAccount,
} = useAdapterStore()
// Active IM tab —— Feishu 默认展示,在前
@ -57,6 +60,14 @@ export function AdapterSettings() {
const [isWechatBinding, setIsWechatBinding] = useState(false)
const [isUnbindingWechatAccount, setIsUnbindingWechatAccount] = useState(false)
// WhatsApp
const [waAllowedUsers, setWaAllowedUsers] = useState('')
const [whatsappQrUrl, setWhatsappQrUrl] = useState<string | null>(null)
const [whatsappSessionKey, setWhatsappSessionKey] = useState<string | null>(null)
const [whatsappStatus, setWhatsappStatus] = useState('')
const [isWhatsAppBinding, setIsWhatsAppBinding] = useState(false)
const [isUnbindingWhatsAppAccount, setIsUnbindingWhatsAppAccount] = useState(false)
// DingTalk
const [dtClientId, setDtClientId] = useState('')
const [dtClientSecret, setDtClientSecret] = useState('')
@ -102,6 +113,7 @@ export function AdapterSettings() {
setFsAllowedUsers(config.feishu?.allowedUsers?.join(', ') ?? '')
setFsStreamingCard(config.feishu?.streamingCard ?? false)
setWcAllowedUsers(config.wechat?.allowedUsers?.join(', ') ?? '')
setWaAllowedUsers(config.whatsapp?.allowedUsers?.join(', ') ?? '')
setDtClientId(config.dingtalk?.clientId ?? '')
setDtClientSecret(config.dingtalk?.clientSecret ?? '')
setDtAllowedUsers(config.dingtalk?.allowedUsers?.join(', ') ?? '')
@ -152,6 +164,51 @@ export function AdapterSettings() {
}
}, [wechatSessionKey, pollWechatLogin, t])
useEffect(() => {
if (!whatsappSessionKey) return
let cancelled = false
let timer: number | null = null
const poll = async () => {
try {
const result = await pollWhatsAppLogin(whatsappSessionKey)
if (cancelled) return
if (result.connected) {
setWhatsappStatus(t('settings.adapters.whatsappBindSuccess'))
setWhatsappQrUrl(null)
setWhatsappSessionKey(null)
setIsWhatsAppBinding(false)
return
}
if (result.qrDataUrl) {
setWhatsappQrUrl(result.qrDataUrl)
}
if (result.message) {
setWhatsappStatus(result.message)
}
if (result.status === 'expired' || result.status === 'error') {
setWhatsappSessionKey(null)
setIsWhatsAppBinding(false)
return
}
} catch (err) {
if (!cancelled) setWhatsappStatus(err instanceof Error ? err.message : t('settings.adapters.whatsappBindFailed'))
}
if (!cancelled) {
timer = window.setTimeout(() => void poll(), 1200)
}
}
timer = window.setTimeout(() => void poll(), 1200)
return () => {
cancelled = true
if (timer != null) window.clearTimeout(timer)
}
}, [whatsappSessionKey, pollWhatsAppLogin, t])
useEffect(() => {
if (!dtRegistration || dtAuthStatus !== 'waiting') return
@ -238,6 +295,16 @@ export function AdapterSettings() {
allowedUsers: wcUsers.length ? wcUsers : [],
}
const waUsers = waAllowedUsers
.split(',')
.map((s) => s.trim())
.filter(Boolean)
patch.whatsapp = {
...config.whatsapp,
allowedUsers: waUsers.length ? waUsers : [],
}
const dtUsers = dtAllowedUsers
.split(',')
.map((s) => s.trim())
@ -296,6 +363,22 @@ export function AdapterSettings() {
}
}, [startWechatLogin])
const handleWhatsAppBind = useCallback(async () => {
setIsWhatsAppBinding(true)
setWhatsappStatus('')
try {
const result = await startWhatsAppLogin()
if (result.qrDataUrl) {
setWhatsappQrUrl(result.qrDataUrl)
}
setWhatsappSessionKey(result.sessionKey)
setWhatsappStatus(result.message)
} catch (err) {
setWhatsappStatus(err instanceof Error ? err.message : t('settings.adapters.whatsappBindFailed'))
setIsWhatsAppBinding(false)
}
}, [startWhatsAppLogin, t])
const handleStartDingtalkAuth = useCallback(async () => {
setIsStartingDtAuth(true)
setDtAuthStatus('idle')
@ -353,6 +436,24 @@ export function AdapterSettings() {
}
}, [unbindDingtalkBot, fetchConfig, t])
const handleUnbindWhatsAppAccount = useCallback(async () => {
setIsUnbindingWhatsAppAccount(true)
setWhatsappStatus('')
try {
await unbindWhatsAppAccount()
await fetchConfig()
setWhatsappQrUrl(null)
setWhatsappSessionKey(null)
setWhatsappStatus(t('settings.adapters.whatsappUnbound'))
} catch (err) {
setWhatsappStatus(err instanceof Error ? err.message : t('settings.adapters.whatsappUnbindFailed'))
} finally {
setIsUnbindingWhatsAppAccount(false)
setIsWhatsAppBinding(false)
setPendingAdapterUnbind(null)
}
}, [unbindWhatsAppAccount, fetchConfig, t])
const handleUnbind = useCallback(async (platform: ImPlatform, userId: string | number) => {
setPendingUnbind({ platform, userId })
}, [])
@ -375,6 +476,7 @@ export function AdapterSettings() {
...(config.feishu?.pairedUsers ?? []).map((u) => ({ ...u, platform: 'feishu' as const })),
...(config.wechat?.pairedUsers ?? []).map((u) => ({ ...u, platform: 'wechat' as const })),
...(config.dingtalk?.pairedUsers ?? []).map((u) => ({ ...u, platform: 'dingtalk' as const })),
...(config.whatsapp?.pairedUsers ?? []).map((u) => ({ ...u, platform: 'whatsapp' as const })),
]
// Check pairing expiry
@ -503,6 +605,11 @@ export function AdapterSettings() {
active={activeIm === 'dingtalk'}
onClick={() => setActiveIm('dingtalk')}
/>
<ImTabButton
label={t('settings.adapters.whatsapp')}
active={activeIm === 'whatsapp'}
onClick={() => setActiveIm('whatsapp')}
/>
<ImTabButton
label={t('settings.adapters.telegram')}
active={activeIm === 'telegram'}
@ -760,6 +867,64 @@ export function AdapterSettings() {
</div>
</div>
)}
{activeIm === 'whatsapp' && (
<div className="p-4 space-y-4">
<div className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] p-4 space-y-3">
<div className="flex items-center justify-between gap-4">
<div>
<div className="text-sm font-medium text-[var(--color-text-primary)]">
{config.whatsapp?.accountJid ? t('settings.adapters.whatsappConnected') : t('settings.adapters.whatsappNotConnected')}
</div>
<p className="text-xs text-[var(--color-text-tertiary)]">
{t('settings.adapters.whatsappQrHint')}
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<Button onClick={handleWhatsAppBind} loading={isWhatsAppBinding && !whatsappQrUrl} size="sm">
{config.whatsapp?.accountJid ? t('settings.adapters.whatsappRebind') : t('settings.adapters.whatsappBind')}
</Button>
{config.whatsapp?.accountJid && (
<Button onClick={() => setPendingAdapterUnbind('whatsappAccount')} loading={isUnbindingWhatsAppAccount} size="sm" variant="danger">
{t('settings.adapters.whatsappUnbindAccount')}
</Button>
)}
</div>
</div>
{config.whatsapp?.accountJid && (
<p className="text-xs text-[var(--color-text-tertiary)]">{config.whatsapp.accountJid}</p>
)}
{whatsappQrUrl && (
<div className="flex items-start gap-4">
<img
src={whatsappQrUrl}
alt={t('settings.adapters.whatsappQrAlt')}
className="h-40 w-40 rounded-lg border border-[var(--color-border)] bg-white object-contain p-2"
/>
<div className="pt-2 text-sm text-[var(--color-text-secondary)]">
{whatsappStatus || t('settings.adapters.whatsappWaiting')}
</div>
</div>
)}
{!whatsappQrUrl && whatsappStatus && (
<p className="text-sm text-[var(--color-text-secondary)]">{whatsappStatus}</p>
)}
</div>
<div className="flex flex-col gap-1">
<Input
label={t('settings.adapters.allowedUsers')}
value={waAllowedUsers}
onChange={(e) => setWaAllowedUsers(e.target.value)}
placeholder={t('settings.adapters.waAllowedUsersPlaceholder')}
/>
<p className="text-xs text-[var(--color-text-tertiary)]">{t('settings.adapters.whatsappAllowedUsersHint')}</p>
</div>
</div>
)}
</section>
{/* Save */}
@ -798,22 +963,32 @@ export function AdapterSettings() {
<ConfirmDialog
open={pendingAdapterUnbind !== null}
onClose={() => {
if (isUnbindingWechatAccount || isUnbindingDtBot) return
if (isUnbindingWechatAccount || isUnbindingDtBot || isUnbindingWhatsAppAccount) return
setPendingAdapterUnbind(null)
}}
onConfirm={pendingAdapterUnbind === 'wechatAccount' ? handleUnbindWechatAccount : handleUnbindDingtalkBot}
onConfirm={pendingAdapterUnbind === 'wechatAccount'
? handleUnbindWechatAccount
: pendingAdapterUnbind === 'whatsappAccount'
? handleUnbindWhatsAppAccount
: handleUnbindDingtalkBot}
title={pendingAdapterUnbind === 'wechatAccount'
? t('settings.adapters.wechatUnbindAccount')
: t('settings.adapters.dingtalkUnbindBot')}
: pendingAdapterUnbind === 'whatsappAccount'
? t('settings.adapters.whatsappUnbindAccount')
: t('settings.adapters.dingtalkUnbindBot')}
body={pendingAdapterUnbind === 'wechatAccount'
? t('settings.adapters.wechatUnbindAccountConfirm')
: t('settings.adapters.dingtalkUnbindBotConfirm')}
: pendingAdapterUnbind === 'whatsappAccount'
? t('settings.adapters.whatsappUnbindAccountConfirm')
: t('settings.adapters.dingtalkUnbindBotConfirm')}
confirmLabel={pendingAdapterUnbind === 'wechatAccount'
? t('settings.adapters.wechatUnbindAccount')
: t('settings.adapters.dingtalkUnbindBot')}
: pendingAdapterUnbind === 'whatsappAccount'
? t('settings.adapters.whatsappUnbindAccount')
: t('settings.adapters.dingtalkUnbindBot')}
cancelLabel={t('common.cancel')}
confirmVariant="danger"
loading={isUnbindingWechatAccount || isUnbindingDtBot}
loading={isUnbindingWechatAccount || isUnbindingDtBot || isUnbindingWhatsAppAccount}
/>
</div>
)

View File

@ -7,8 +7,11 @@ describe('adapterStore IM pairing behavior', () => {
updateConfig: vi.fn(),
startWechatLogin: vi.fn(),
pollWechatLogin: vi.fn(),
startWhatsAppLogin: vi.fn(),
pollWhatsAppLogin: vi.fn(),
unbindWechat: vi.fn(),
unbindDingtalk: vi.fn(),
unbindWhatsApp: vi.fn(),
beginDingtalkRegistration: vi.fn(),
pollDingtalkRegistration: vi.fn(),
}
@ -128,4 +131,16 @@ describe('adapterStore IM pairing behavior', () => {
expect(adaptersApi.unbindDingtalk).toHaveBeenCalledTimes(1)
expect(useAdapterStore.getState().config).toBe(nextConfig)
})
it('unbinds the WhatsApp account only through the explicit account action', async () => {
const nextConfig = { whatsapp: { pairedUsers: [], allowedUsers: [] } }
adaptersApi.unbindWhatsApp.mockResolvedValue(nextConfig)
const { useAdapterStore } = await import('./adapterStore')
await useAdapterStore.getState().unbindWhatsAppAccount()
expect(adaptersApi.unbindWhatsApp).toHaveBeenCalledTimes(1)
expect(useAdapterStore.getState().config).toBe(nextConfig)
})
})

View File

@ -6,7 +6,7 @@ import { getDesktopHost } from '../lib/desktopHost'
/**
* Desktop host trigger: let the app process kill + respawn adapter sidecar,
* ~/.claude/adapters.json / Telegram / /
* ~/.claude/adapters.json / Telegram / / / WhatsApp
* WebSocket
*
* /
@ -53,11 +53,14 @@ type AdapterStore = {
generatePairingCode: () => Promise<string>
startWechatLogin: () => Promise<{ qrcodeUrl?: string; message: string; sessionKey: string }>
pollWechatLogin: (sessionKey: string) => Promise<{ connected: boolean; status?: string; message?: string }>
removePairedUser: (platform: 'telegram' | 'feishu' | 'wechat' | 'dingtalk', userId: string | number) => Promise<void>
startWhatsAppLogin: () => Promise<{ qrDataUrl?: string; message: string; sessionKey: string }>
pollWhatsAppLogin: (sessionKey: string) => Promise<{ connected: boolean; status?: string; message?: string; qrDataUrl?: string }>
removePairedUser: (platform: 'telegram' | 'feishu' | 'wechat' | 'dingtalk' | 'whatsapp', userId: string | number) => Promise<void>
beginDingtalkRegistration: () => Promise<DingtalkRegistrationBegin>
pollDingtalkRegistration: (deviceCode: string) => Promise<DingtalkRegistrationPoll>
unbindWechatAccount: () => Promise<void>
unbindDingtalkBot: () => Promise<void>
unbindWhatsAppAccount: () => Promise<void>
}
export const useAdapterStore = create<AdapterStore>((set, get) => ({
@ -80,7 +83,7 @@ export const useAdapterStore = create<AdapterStore>((set, get) => ({
const config = await adaptersApi.updateConfig(patch)
set({ config })
// 配置文件已写入磁盘,让 Tauri 主进程 kill + respawn adapter sidecar
// 触发飞书 / Telegram WebSocket 用新凭据重连。pairing code / paired users
// 触发各 IM adapter 用新凭据重连。pairing code / paired users
// 这种轻量更新也会触发重启 —— 这是个有意为之的简化:保证"任何配置变更
// 都立刻生效",比起精细判断哪些字段值得重启更可靠。
void notifyDesktopRestartAdapters()
@ -116,6 +119,28 @@ export const useAdapterStore = create<AdapterStore>((set, get) => ({
return { connected: false }
},
startWhatsAppLogin: async () => {
return adaptersApi.startWhatsAppLogin()
},
pollWhatsAppLogin: async (sessionKey) => {
const result = await adaptersApi.pollWhatsAppLogin(sessionKey)
if ('connected' in result && result.connected === false) {
return {
connected: false,
status: result.status,
message: result.message,
qrDataUrl: result.qrDataUrl,
}
}
if ('whatsapp' in result || 'wechat' in result || 'telegram' in result || 'feishu' in result || 'dingtalk' in result) {
set({ config: result })
void notifyDesktopRestartAdapters()
return { connected: true }
}
return { connected: false }
},
beginDingtalkRegistration: () => adaptersApi.beginDingtalkRegistration(),
pollDingtalkRegistration: async (deviceCode) => {
@ -139,6 +164,12 @@ export const useAdapterStore = create<AdapterStore>((set, get) => ({
void notifyDesktopRestartAdapters()
},
unbindWhatsAppAccount: async () => {
const config = await adaptersApi.unbindWhatsApp()
set({ config })
void notifyDesktopRestartAdapters()
},
removePairedUser: async (platform, userId) => {
const { config } = get()
const platformConfig = config[platform]

View File

@ -48,4 +48,11 @@ export type AdapterFileConfig = {
endpoint?: string
permissionCardTemplateId?: string
}
whatsapp?: {
accountJid?: string
authDir?: string
allowedUsers?: string[]
pairedUsers?: PairedUser[]
defaultWorkDir?: string
}
}

View File

@ -1,7 +1,7 @@
# IM 接入
> 当前可用的 IM 接入方案总览。
> 如果你只是想把微信、钉钉、Telegram 或飞书接进来,从这篇开始。
> 如果你只是想把微信、钉钉、WhatsApp、Telegram 或飞书接进来,从这篇开始。
## 当前方案是什么
@ -16,7 +16,7 @@ flowchart TD
C --> D["本地配置持久化"]
D --> E["~/.claude/adapters.json"]
E --> F["微信 / 钉钉 / Telegram / 飞书 Adapter 进程"]
E --> F["微信 / 钉钉 / WhatsApp / Telegram / 飞书 Adapter 进程"]
F --> G["加载平台配置"]
F --> H["校验配对与授权"]
F --> I["读取 / 写入会话映射"]
@ -38,14 +38,14 @@ flowchart TD
U --> V["流式消息 / 权限请求 / 状态事件"]
V --> S
S --> F
F --> W["微信 / 钉钉 / Telegram / 飞书用户"]
F --> W["微信 / 钉钉 / WhatsApp / Telegram / 飞书用户"]
```
可以把这条链路理解成四层:
- 配置层:桌面端 webapp 负责填写平台凭据、默认项目和配对码管理
- 存储层:本地服务端把配置写入 `~/.claude/adapters.json`
- 适配层:微信 / 钉钉 / Telegram / 飞书 adapter 进程负责接 IM 平台、做授权检查、恢复或创建会话
- 适配层:微信 / 钉钉 / WhatsApp / Telegram / 飞书 adapter 进程负责接 IM 平台、做授权检查、恢复或创建会话
- 会话层adapter 通过 HTTP 创建 session再通过 WebSocket 把 IM 消息桥接到 Claude Code 会话
## 用户怎么用
@ -56,7 +56,7 @@ flowchart TD
- `serverUrl`
- `defaultProjectDir`
- 微信 / 钉钉扫码绑定,或填写 Telegram / 飞书各自的凭据
- 微信 / 钉钉 / WhatsApp 扫码绑定,或填写 Telegram / 飞书各自的凭据
- 可选 `allowedUsers`
这里的配置会通过 `GET /api/adapters``PUT /api/adapters` 读写到 `~/.claude/adapters.json`
@ -70,9 +70,9 @@ flowchart TD
- 码有效期 60 分钟
- 配对成功后立即失效
配对码是平台无关的同一个码可以在微信、钉钉、Telegram 或飞书私聊里使用一次。
配对码是平台无关的,同一个码可以在微信、钉钉、WhatsApp、Telegram 或飞书私聊里使用一次。
微信和钉钉的“扫码绑定”只负责把机器人账号凭据写到本机;具体 IM 用户仍然需要发送配对码,或被加入 `allowedUsers`
微信、钉钉和 WhatsApp 的“扫码绑定”只负责把机器人或账号凭据写到本机;具体 IM 用户仍然需要发送配对码,或被加入 `allowedUsers`
### 3. 启动对应 Adapter 进程
@ -88,6 +88,8 @@ bun run feishu
bun run wechat
# 或
bun run dingtalk
# 或
bun run whatsapp
```
### 4. 在 IM 里私聊 Bot
@ -110,6 +112,7 @@ bun run dingtalk
- `feishu`
- `wechat`
- `dingtalk`
- `whatsapp`
其中:
@ -159,11 +162,13 @@ Adapter 不是直接把消息丢给一个全局 Claude 进程,而是:
- 飞书:`@larksuiteoapi/node-sdk`,长连接事件订阅,交互卡片审批,当前只处理 `p2p`
- 微信:扫码绑定账号,`getupdates` 长轮询接收消息,文本命令审批使用 `/allow <requestId>` / `/deny <requestId>`
- 钉钉:扫码给本机写入 Stream 凭据,`dingtalk-stream` 长连接收发消息,配对码绑定用户后开始聊天
- WhatsApp`@whiskeysockets/baileys` 连接 WhatsApp Web扫码写入本机 auth state当前只处理个人私聊文本命令审批
分别看:
- [微信接入](./wechat.md)
- [钉钉接入](./dingtalk.md)
- [WhatsApp 接入](./whatsapp.md)
- [Telegram 接入](./telegram.md)
- [飞书接入](./feishu.md)

147
docs/im/whatsapp.md Normal file
View File

@ -0,0 +1,147 @@
# WhatsApp 接入
> WhatsApp Adapter 使用 WhatsApp Web linked device 方式接入个人号。实现基于 `@whiskeysockets/baileys`
## 适用场景
WhatsApp 方案适合用自己的 WhatsApp 个人号在私聊里远程驱动 Claude Code Haha。当前实现只处理个人私聊不处理群聊、频道、状态广播。
实现入口:`adapters/whatsapp/index.ts`
## 它不是“创建机器人”
当前实现走的是 WhatsApp Web 的 linked device 方式,不是官方 WhatsApp Business Platform / Cloud API。
这意味着:
- 不需要在 Meta for Developers 创建 App
- 不需要 WhatsApp Business AccountWABA
- 不需要 Phone Number ID、WABA ID、Access Token
- 不需要配置 Webhook callback URL
- 不需要申请或审核 message template
它的工作方式更接近“把 Claude Code Haha 作为一台已登录的 WhatsApp Web 设备挂到你的个人 WhatsApp 账号上”:
1. 桌面端用 Baileys 生成 WhatsApp Web 登录二维码
2. 你用手机 WhatsApp 的 `Linked devices` 扫码
3. 本机保存 WhatsApp Web auth state
4. adapter 监听这个账号收到的个人私聊消息
5. 已授权用户发来的消息会被转成 Claude Code Haha session 输入
6. Claude 的回复再由这个 WhatsApp 账号发回同一个私聊
因此WhatsApp 这里没有 Telegram BotFather、飞书机器人、钉钉 Stream 机器人那种“后台创建 bot”的概念。对用户来说对话对象就是你扫码绑定的那个 WhatsApp 账号本身。
如果后续要做面向客户服务、模板消息、官方 SLA、Webhook、规模化群发或合规商业账号接入那是另一套官方 Cloud API 方案,需要单独实现,至少会涉及 Meta App、WABA、业务手机号、永久 access token、Webhook 和模板消息。参考官方资料:
- [WhatsApp Linked Devices 帮助](https://faq.whatsapp.com/1317564962315842/)
- [Meta WhatsApp Cloud API Overview](https://developers.facebook.com/docs/whatsapp/cloud-api/overview)
- [Meta WhatsApp Cloud API Get Started](https://developers.facebook.com/docs/whatsapp/cloud-api/get-started)
## 1. 在桌面端扫码绑定
打开桌面端 `设置 -> IM 接入 -> WhatsApp`
1. 点击「扫码绑定」
2. 在手机 WhatsApp 打开 `Settings -> Linked devices`
3. 扫描桌面端显示的二维码
4. 等待页面提示绑定成功
绑定成功后,桌面端会把 Baileys auth state 保存到本机,并重启 adapter sidecar。
默认 auth 目录:
```text
~/.claude/whatsapp-auth/default
```
配置记录写在:
```json
{
"whatsapp": {
"accountJid": "15551234567@s.whatsapp.net",
"authDir": "~/.claude/whatsapp-auth/default",
"allowedUsers": [],
"pairedUsers": []
}
}
```
## 2. 生成配对码
扫码只绑定 WhatsApp Web 账号能力,不等于授权所有聊天用户。
`IM 接入` 页点击「生成配对码」,然后用需要授权的 WhatsApp 私聊给当前账号发送该配对码。配对成功后,这个 WhatsApp JID 会写入 `whatsapp.pairedUsers`
也可以直接在 `Allowed Users` 里填写允许的 WhatsApp JID例如
```text
15551234567@s.whatsapp.net
```
## 3. 启动 adapter
发布版桌面端会自动拉起 adapter sidecar。本地开发或单独调试时
```bash
cd adapters
bun install
bun run whatsapp
```
## 支持的命令
- `/start``/help` — 显示帮助和可用命令
- `/projects` — 切换项目,重新显示最近项目列表
- `/status` — 查看当前会话的项目、模型和运行状态
- `/clear` — 清空当前会话上下文,保留项目绑定
- `/new [项目]` — 新建会话或切换项目
- `/stop` — 向当前 session 发送 `stop_generation`
## 权限审批
WhatsApp 没有使用按钮审批。收到权限请求后,按消息提示回复:
- `1``/allow <requestId>` — 允许一次
- `2``/always <requestId>` — 永久允许
- `3``/deny <requestId>` — 拒绝
## 返回消息的表现
WhatsApp adapter 不依赖 WhatsApp message edit 做 token 级流式更新。它会:
- thinking 时发送一个简短状态提示
- 完成后把正文按约 4000 字分片发送
- 识别 Agent 输出里的 markdown 图片引用并作为图片消息发送
## 环境变量覆盖(可选)
```bash
export WHATSAPP_AUTH_DIR="$HOME/.claude/whatsapp-auth/default"
export WHATSAPP_ACCOUNT_JID="15551234567@s.whatsapp.net"
export ADAPTER_SERVER_URL="ws://127.0.0.1:3456"
```
## 常见问题
### adapter 启动时报没有绑定账号
先在桌面端 WhatsApp 标签页扫码绑定。`bun run whatsapp` 不会单独弹出二维码。
### 绑定后发消息提示未授权
扫码绑定的是 WhatsApp Web 账号,不是用户授权。还需要在桌面端生成配对码,并用 WhatsApp 私聊发送给当前账号。
### WhatsApp 提示已登出
在桌面端 WhatsApp 标签页解除绑定后重新扫码。解除绑定会删除本机 WhatsApp auth state。
## 源码入口
- `adapters/whatsapp/index.ts`
- `adapters/whatsapp/protocol.ts`
- `adapters/whatsapp/session.ts`
- `adapters/whatsapp/media.ts`
- `adapters/common/pairing.ts`
- `adapters/common/session-store.ts`
- `adapters/common/ws-bridge.ts`

View File

@ -152,4 +152,34 @@ describe('Adapters API', () => {
expect(json.dingtalk.permissionCardTemplateId).toBeUndefined()
expect(json.dingtalk.pairedUsers).toEqual([])
})
it('stores and clears WhatsApp account binding', async () => {
const authDir = path.join(tmpDir, 'whatsapp-auth', 'default')
await fs.mkdir(authDir, { recursive: true })
await fs.writeFile(path.join(authDir, 'creds.json'), '{}')
const put = makeRequest('PUT', '/api/adapters', {
whatsapp: {
accountJid: '15551234567@s.whatsapp.net',
authDir,
allowedUsers: ['15550000000@s.whatsapp.net'],
pairedUsers: [{ userId: '15551234567@s.whatsapp.net', displayName: 'WhatsApp User', pairedAt: 1 }],
},
})
await handleAdaptersApi(put.req, put.url, put.segments)
const get = makeRequest('GET', '/api/adapters')
const getRes = await handleAdaptersApi(get.req, get.url, get.segments)
const before = await getRes.json() as any
expect(before.whatsapp.accountJid).toBe('15551234567@s.whatsapp.net')
expect(before.whatsapp.authDir).toBe(authDir)
const unbind = makeRequest('POST', '/api/adapters/whatsapp/unbind')
const res = await handleAdaptersApi(unbind.req, unbind.url, unbind.segments)
expect(res.status).toBe(200)
const json = await res.json() as any
expect(json.whatsapp.accountJid).toBeUndefined()
expect(json.whatsapp.allowedUsers).toEqual([])
expect(json.whatsapp.pairedUsers).toEqual([])
await expect(fs.stat(path.join(authDir, 'creds.json'))).rejects.toThrow()
})
})

View File

@ -12,8 +12,14 @@ import {
startWechatLoginWithQr,
WECHAT_DEFAULT_BASE_URL,
} from '../../../adapters/wechat/protocol.js'
import {
logoutWhatsAppAuth,
pollWhatsAppLoginWithQr,
startWhatsAppLoginWithQr,
} from '../../../adapters/whatsapp/protocol.js'
import { loadConfig } from '../../../adapters/common/config.js'
const ALLOWED_TOP_KEYS = new Set(['serverUrl', 'defaultProjectDir', 'telegram', 'feishu', 'wechat', 'dingtalk', 'pairing'])
const ALLOWED_TOP_KEYS = new Set(['serverUrl', 'defaultProjectDir', 'telegram', 'feishu', 'wechat', 'dingtalk', 'whatsapp', 'pairing'])
type RegistrationApiResponse<T extends Record<string, unknown>> = T & {
errcode: number
@ -143,6 +149,9 @@ export async function handleAdaptersApi(
if (tail[0] === 'wechat') {
return handleWechatAdaptersApi(req, tail.slice(1))
}
if (tail[0] === 'whatsapp') {
return handleWhatsAppAdaptersApi(req, tail.slice(1))
}
if (tail[0] === 'dingtalk' && req.method === 'POST' && tail[1] === 'unbind') {
await adapterService.updateConfig({
dingtalk: {
@ -229,3 +238,52 @@ async function handleWechatAdaptersApi(req: Request, tail: string[]): Promise<Re
throw new ApiError(404, 'Unknown WeChat adapter endpoint', 'NOT_FOUND')
}
async function handleWhatsAppAdaptersApi(req: Request, tail: string[]): Promise<Response> {
if (req.method === 'POST' && tail[0] === 'login' && tail[1] === 'start') {
const config = loadConfig()
const result = await startWhatsAppLoginWithQr({
authDir: config.whatsapp.authDir,
force: true,
})
return Response.json({
...result,
qrDataUrl: result.qr ? await createQrDataUrl(result.qr) : undefined,
})
}
if (req.method === 'POST' && tail[0] === 'login' && tail[1] === 'poll') {
const body = (await req.json()) as { sessionKey?: string }
if (!body.sessionKey) throw ApiError.badRequest('Missing sessionKey')
const result = await pollWhatsAppLoginWithQr({ sessionKey: body.sessionKey })
if (result.connected) {
await adapterService.updateConfig({
whatsapp: {
accountJid: result.accountJid,
authDir: result.authDir,
pairedUsers: [],
},
})
return Response.json(await adapterService.getConfig())
}
return Response.json({
...result,
qrDataUrl: result.qr ? await createQrDataUrl(result.qr) : undefined,
})
}
if (req.method === 'POST' && tail[0] === 'unbind') {
const config = loadConfig()
await logoutWhatsAppAuth(config.whatsapp.authDir)
await adapterService.updateConfig({
whatsapp: {
accountJid: undefined,
pairedUsers: [],
allowedUsers: [],
},
})
return Response.json(await adapterService.getConfig())
}
throw new ApiError(404, 'Unknown WhatsApp adapter endpoint', 'NOT_FOUND')
}

View File

@ -60,6 +60,13 @@ export type AdapterFileConfig = {
endpoint?: string
permissionCardTemplateId?: string
}
whatsapp?: {
accountJid?: string
authDir?: string
allowedUsers?: string[]
pairedUsers?: PairedUser[]
defaultWorkDir?: string
}
}
function getConfigPath(): string {
@ -144,6 +151,7 @@ class AdapterService {
feishu: patch.feishu ? { ...current.feishu, ...patch.feishu } : current.feishu,
wechat: patch.wechat ? { ...current.wechat, ...patch.wechat } : current.wechat,
dingtalk: patch.dingtalk ? { ...current.dingtalk, ...patch.dingtalk } : current.dingtalk,
whatsapp: patch.whatsapp ? { ...current.whatsapp, ...patch.whatsapp } : current.whatsapp,
pairing: patch.pairing !== undefined ? { ...current.pairing, ...patch.pairing } : current.pairing,
}