From ddcfa5faaec955f50e445ff25070ff4671897924 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Tue, 9 Jun 2026 21:31:37 +0800 Subject: [PATCH] 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 --- adapters/README.md | 10 + adapters/bun.lock | 153 +++++ adapters/common/__tests__/config.test.ts | 26 + adapters/common/__tests__/pairing.test.ts | 9 + .../common/attachment/attachment-types.ts | 2 +- adapters/common/config.ts | 37 + adapters/common/pairing.ts | 2 +- adapters/package.json | 5 +- adapters/whatsapp/__tests__/format.test.ts | 36 + adapters/whatsapp/format.ts | 24 + adapters/whatsapp/index.ts | 649 ++++++++++++++++++ adapters/whatsapp/media.ts | 125 ++++ adapters/whatsapp/protocol.ts | 180 +++++ adapters/whatsapp/session.ts | 168 +++++ desktop/electron/services/serverRuntime.ts | 1 + .../electron/services/sidecarManager.test.ts | 9 + desktop/electron/services/sidecarManager.ts | 2 +- desktop/sidecars/claude-sidecar.ts | 26 +- desktop/src-tauri/src/lib.rs | 1 + desktop/src/api/adapters.ts | 27 + desktop/src/i18n/locales/en.ts | 21 +- desktop/src/i18n/locales/jp.ts | 21 +- desktop/src/i18n/locales/kr.ts | 21 +- desktop/src/i18n/locales/zh-TW.ts | 21 +- desktop/src/i18n/locales/zh.ts | 21 +- desktop/src/pages/AdapterSettings.test.tsx | 13 + desktop/src/pages/AdapterSettings.tsx | 193 +++++- desktop/src/stores/adapterStore.test.ts | 15 + desktop/src/stores/adapterStore.ts | 37 +- desktop/src/types/adapter.ts | 7 + docs/im/index.md | 19 +- docs/im/whatsapp.md | 147 ++++ src/server/__tests__/adapters.test.ts | 30 + src/server/api/adapters.ts | 60 +- src/server/services/adapterService.ts | 8 + 35 files changed, 2088 insertions(+), 38 deletions(-) create mode 100644 adapters/whatsapp/__tests__/format.test.ts create mode 100644 adapters/whatsapp/format.ts create mode 100644 adapters/whatsapp/index.ts create mode 100644 adapters/whatsapp/media.ts create mode 100644 adapters/whatsapp/protocol.ts create mode 100644 adapters/whatsapp/session.ts create mode 100644 docs/im/whatsapp.md diff --git a/adapters/README.md b/adapters/README.md index 4f34711a..4b34082e 100644 --- a/adapters/README.md +++ b/adapters/README.md @@ -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 等)暂不支持。 diff --git a/adapters/bun.lock b/adapters/bun.lock index afad9705..74549920 100644 --- a/adapters/bun.lock +++ b/adapters/bun.lock @@ -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=="], } } diff --git a/adapters/common/__tests__/config.test.ts b/adapters/common/__tests__/config.test.ts index 6fa34847..3f8d0f20 100644 --- a/adapters/common/__tests__/config.test.ts +++ b/adapters/common/__tests__/config.test.ts @@ -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 { diff --git a/adapters/common/__tests__/pairing.test.ts b/adapters/common/__tests__/pairing.test.ts index 7f455478..0a2cfb29 100644 --- a/adapters/common/__tests__/pairing.test.ts +++ b/adapters/common/__tests__/pairing.test.ts @@ -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) + }) }) diff --git a/adapters/common/attachment/attachment-types.ts b/adapters/common/attachment/attachment-types.ts index 9574695b..7815816c 100644 --- a/adapters/common/attachment/attachment-types.ts +++ b/adapters/common/attachment/attachment-types.ts @@ -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 { diff --git a/adapters/common/config.ts b/adapters/common/config.ts index 095504d5..5b095a7b 100644 --- a/adapters/common/config.ts +++ b/adapters/common/config.ts @@ -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) +} diff --git a/adapters/common/pairing.ts b/adapters/common/pairing.ts index 5616044e..430ea063 100644 --- a/adapters/common/pairing.ts +++ b/adapters/common/pairing.ts @@ -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 diff --git a/adapters/package.json b/adapters/package.json index 785b80e8..5ae9495b 100644 --- a/adapters/package.json +++ b/adapters/package.json @@ -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" diff --git a/adapters/whatsapp/__tests__/format.test.ts b/adapters/whatsapp/__tests__/format.test.ts new file mode 100644 index 00000000..2af66385 --- /dev/null +++ b/adapters/whatsapp/__tests__/format.test.ts @@ -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') + }) +}) diff --git a/adapters/whatsapp/format.ts b/adapters/whatsapp/format.ts new file mode 100644 index 00000000..8240d04a --- /dev/null +++ b/adapters/whatsapp/format.ts @@ -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()) +} diff --git a/adapters/whatsapp/index.ts b/adapters/whatsapp/index.ts new file mode 100644 index 00000000..10bd81c1 --- /dev/null +++ b/adapters/whatsapp/index.ts @@ -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 | null = null +let reconnectAttempts = 0 +let shuttingDown = false + +const accumulatedText = new Map() +const accumulatedThinkingText = new Map() +const thinkingNotices = new Set() +const pendingProjectSelection = new Map() +const runtimeStates = new Map() +const pendingPermissions = new Map>() +const imageWatchers = new Map() + +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 { + const chunks = splitWhatsAppText(text, WHATSAPP_TEXT_LIMIT) + for (const chunk of chunks) { + await sock.sendMessage(jid, { text: chunk }) + } +} + +async function sendHelp(jid: string): Promise { + await sendWhatsAppText(jid, `Claude Code WhatsApp 已就绪。\n\n${formatImHelp()}`) +} + +async function handlePermissionDecision(chatId: string, decision: PermissionDecision): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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() + 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).model + if (typeof model === 'string' && model.trim()) { + runtime.model = model + } + } + break + } +} + +async function startNewSession(chatId: string, query?: string): Promise { + 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 { + 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 { + 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 { + 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) +}) diff --git a/adapters/whatsapp/media.ts b/adapters/whatsapp/media.ts new file mode 100644 index 00000000..99358038 --- /dev/null +++ b/adapters/whatsapp/media.ts @@ -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 { + 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 { + 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', + }) + } +} diff --git a/adapters/whatsapp/protocol.ts b/adapters/whatsapp/protocol.ts new file mode 100644 index 00000000..090dab61 --- /dev/null +++ b/adapters/whatsapp/protocol.ts @@ -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() + +export async function startWhatsAppLoginWithQr(options: { + authDir: string + force?: boolean +}): Promise { + 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 { + 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 { + closeSessionsForAuthDir(path.resolve(authDir)) + clearWhatsAppAuth(authDir) +} + +function waitForQr(session: LoginSession): Promise { + 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) + } +} diff --git a/adapters/whatsapp/session.ts b/adapters/whatsapp/session.ts new file mode 100644 index 00000000..9dd5ee5b --- /dev/null +++ b/adapters/whatsapp/session.ts @@ -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 + +const CREDS_FILE = 'creds.json' +const CREDS_BACKUP_FILE = 'creds.json.bak' +const LOGGED_OUT_STATUS = DisconnectReason?.loggedOut ?? 401 + +const credsSaveQueues = new Map>() + +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 { + 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 { + 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 { + 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): Promise { + 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, + } +} diff --git a/desktop/electron/services/serverRuntime.ts b/desktop/electron/services/serverRuntime.ts index b4a6475e..42cbbc17 100644 --- a/desktop/electron/services/serverRuntime.ts +++ b/desktop/electron/services/serverRuntime.ts @@ -110,6 +110,7 @@ export class ElectronServerRuntime { ['telegram', '--telegram'], ['wechat', '--wechat'], ['dingtalk', '--dingtalk'], + ['whatsapp', '--whatsapp'], ] as const) { try { const child = spawnSidecar(createAdapterPlan({ diff --git a/desktop/electron/services/sidecarManager.test.ts b/desktop/electron/services/sidecarManager.test.ts index 6c1f5150..42b1449b 100644 --- a/desktop/electron/services/sidecarManager.test.ts +++ b/desktop/electron/services/sidecarManager.test.ts @@ -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 }) } diff --git a/desktop/electron/services/sidecarManager.ts b/desktop/electron/services/sidecarManager.ts index 3e8f9b0f..cefade33 100644 --- a/desktop/electron/services/sidecarManager.ts +++ b/desktop/electron/services/sidecarManager.ts @@ -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 { diff --git a/desktop/sidecars/claude-sidecar.ts b/desktop/sidecars/claude-sidecar.ts index 0263b408..874be656 100644 --- a/desktop/sidecars/claude-sidecar.ts +++ b/desktop/sidecars/claude-sidecar.ts @@ -8,7 +8,7 @@ * * claude-sidecar server --app-root --host 127.0.0.1 --port 12345 * claude-sidecar cli --app-root [其它 CLI 参数...] - * claude-sidecar adapters --app-root [--feishu] [--telegram] [--wechat] [--dingtalk] + * claude-sidecar adapters --app-root [--feishu] [--telegram] [--wechat] [--dingtalk] [--whatsapp] * * 任何模式都必须先做 process.env / process.argv 设置,再 await 进入相应的 * 子模块树。原因:src/server/index.ts、src/entrypoints/cli.tsx、以及 @@ -52,13 +52,14 @@ if (mode === 'adapters') { async function runAdapters(rawArgs: string[]): Promise { // 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 { 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 { } } + 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', diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 3c508931..a4b4ba94 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -1766,6 +1766,7 @@ fn start_adapters_sidecars(app: &AppHandle) -> Result, String> ("telegram", "--telegram"), ("wechat", "--wechat"), ("dingtalk", "--dingtalk"), + ("whatsapp", "--whatsapp"), ] { let mut sidecar = app .shell() diff --git a/desktop/src/api/adapters.ts b/desktop/src/api/adapters.ts index 8bac289b..9d9d8753 100644 --- a/desktop/src/api/adapters.ts +++ b/desktop/src/api/adapters.ts @@ -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('/api/adapters') @@ -52,4 +67,16 @@ export const adaptersApi = { pollDingtalkRegistration(deviceCode: string) { return api.post('/api/adapters/dingtalk/registration/poll', { deviceCode }) }, + + startWhatsAppLogin() { + return api.post('/api/adapters/whatsapp/login/start', {}) + }, + + pollWhatsAppLogin(sessionKey: string) { + return api.post('/api/adapters/whatsapp/login/poll', { sessionKey }, { timeout: 45_000 }) + }, + + unbindWhatsApp() { + return api.post('/api/adapters/whatsapp/unbind', {}) + }, } diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index d1c070ba..94b574f5 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -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', diff --git a/desktop/src/i18n/locales/jp.ts b/desktop/src/i18n/locales/jp.ts index ce927824..4b91e5a5 100644 --- a/desktop/src/i18n/locales/jp.ts +++ b/desktop/src/i18n/locales/jp.ts @@ -444,11 +444,12 @@ export const jp: Record = { // 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 = { '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 = { '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 = { '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 = { '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 サーバー', diff --git a/desktop/src/i18n/locales/kr.ts b/desktop/src/i18n/locales/kr.ts index 78dafd16..f90aed74 100644 --- a/desktop/src/i18n/locales/kr.ts +++ b/desktop/src/i18n/locales/kr.ts @@ -444,11 +444,12 @@ export const kr: Record = { // 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 = { '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 = { '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 = { '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 = { '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 서버', diff --git a/desktop/src/i18n/locales/zh-TW.ts b/desktop/src/i18n/locales/zh-TW.ts index 385e172a..778c739f 100644 --- a/desktop/src/i18n/locales/zh-TW.ts +++ b/desktop/src/i18n/locales/zh-TW.ts @@ -444,11 +444,12 @@ export const zh: Record = { // 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 = { '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 = { '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 = { '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 = { '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 服務', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 672a50b2..e1de6996 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -444,11 +444,12 @@ export const zh: Record = { // 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 = { '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 = { '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 = { '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 = { '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 服务', diff --git a/desktop/src/pages/AdapterSettings.test.tsx b/desktop/src/pages/AdapterSettings.test.tsx index b0f31028..ac137ee3 100644 --- a/desktop/src/pages/AdapterSettings.test.tsx +++ b/desktop/src/pages/AdapterSettings.test.tsx @@ -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() + }) }) diff --git a/desktop/src/pages/AdapterSettings.tsx b/desktop/src/pages/AdapterSettings.tsx index 560144d7..c181635f 100644 --- a/desktop/src/pages/AdapterSettings.tsx +++ b/desktop/src/pages/AdapterSettings.tsx @@ -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(null) + const [whatsappSessionKey, setWhatsappSessionKey] = useState(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')} /> + setActiveIm('whatsapp')} + /> )} + + {activeIm === 'whatsapp' && ( +
+
+
+
+
+ {config.whatsapp?.accountJid ? t('settings.adapters.whatsappConnected') : t('settings.adapters.whatsappNotConnected')} +
+

+ {t('settings.adapters.whatsappQrHint')} +

+
+
+ + {config.whatsapp?.accountJid && ( + + )} +
+
+ + {config.whatsapp?.accountJid && ( +

{config.whatsapp.accountJid}

+ )} + + {whatsappQrUrl && ( +
+ {t('settings.adapters.whatsappQrAlt')} +
+ {whatsappStatus || t('settings.adapters.whatsappWaiting')} +
+
+ )} + + {!whatsappQrUrl && whatsappStatus && ( +

{whatsappStatus}

+ )} +
+ +
+ setWaAllowedUsers(e.target.value)} + placeholder={t('settings.adapters.waAllowedUsersPlaceholder')} + /> +

{t('settings.adapters.whatsappAllowedUsersHint')}

+
+
+ )} {/* Save */} @@ -798,22 +963,32 @@ export function AdapterSettings() { { - 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} /> ) diff --git a/desktop/src/stores/adapterStore.test.ts b/desktop/src/stores/adapterStore.test.ts index bd4e4831..b0522d2e 100644 --- a/desktop/src/stores/adapterStore.test.ts +++ b/desktop/src/stores/adapterStore.test.ts @@ -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) + }) }) diff --git a/desktop/src/stores/adapterStore.ts b/desktop/src/stores/adapterStore.ts index 1d807160..3f664351 100644 --- a/desktop/src/stores/adapterStore.ts +++ b/desktop/src/stores/adapterStore.ts @@ -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 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 + 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 beginDingtalkRegistration: () => Promise pollDingtalkRegistration: (deviceCode: string) => Promise unbindWechatAccount: () => Promise unbindDingtalkBot: () => Promise + unbindWhatsAppAccount: () => Promise } export const useAdapterStore = create((set, get) => ({ @@ -80,7 +83,7 @@ export const useAdapterStore = create((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((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((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] diff --git a/desktop/src/types/adapter.ts b/desktop/src/types/adapter.ts index 73210584..34a4ad3d 100644 --- a/desktop/src/types/adapter.ts +++ b/desktop/src/types/adapter.ts @@ -48,4 +48,11 @@ export type AdapterFileConfig = { endpoint?: string permissionCardTemplateId?: string } + whatsapp?: { + accountJid?: string + authDir?: string + allowedUsers?: string[] + pairedUsers?: PairedUser[] + defaultWorkDir?: string + } } diff --git a/docs/im/index.md b/docs/im/index.md index 4ef0d512..26d0d2e9 100644 --- a/docs/im/index.md +++ b/docs/im/index.md @@ -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 ` / `/deny ` - 钉钉:扫码给本机写入 Stream 凭据,`dingtalk-stream` 长连接收发消息,配对码绑定用户后开始聊天 +- WhatsApp:`@whiskeysockets/baileys` 连接 WhatsApp Web,扫码写入本机 auth state,当前只处理个人私聊,文本命令审批 分别看: - [微信接入](./wechat.md) - [钉钉接入](./dingtalk.md) +- [WhatsApp 接入](./whatsapp.md) - [Telegram 接入](./telegram.md) - [飞书接入](./feishu.md) diff --git a/docs/im/whatsapp.md b/docs/im/whatsapp.md new file mode 100644 index 00000000..c3149760 --- /dev/null +++ b/docs/im/whatsapp.md @@ -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 Account(WABA) +- 不需要 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 ` — 允许一次 +- `2` 或 `/always ` — 永久允许 +- `3` 或 `/deny ` — 拒绝 + +## 返回消息的表现 + +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` diff --git a/src/server/__tests__/adapters.test.ts b/src/server/__tests__/adapters.test.ts index 7cdf3ea6..e9159820 100644 --- a/src/server/__tests__/adapters.test.ts +++ b/src/server/__tests__/adapters.test.ts @@ -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() + }) }) diff --git a/src/server/api/adapters.ts b/src/server/api/adapters.ts index c4e67cae..70efab8a 100644 --- a/src/server/api/adapters.ts +++ b/src/server/api/adapters.ts @@ -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 & { 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 { + 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') +} diff --git a/src/server/services/adapterService.ts b/src/server/services/adapterService.ts index 6d727fd1..0d22584d 100644 --- a/src/server/services/adapterService.ts +++ b/src/server/services/adapterService.ts @@ -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, }