mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 10:23:17 +08:00
feat: add WhatsApp QR channel
This commit is contained in:
parent
f8841da76f
commit
c54463843f
29 changed files with 4565 additions and 224 deletions
30
README.md
30
README.md
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
## 中文
|
||||
|
||||
通过扫码或已有机器人凭据把 IM 机器人接入 DeepSeek Harness。一个插件、一个设置入口,统一管理飞书、微信、钉钉、企业微信、QQ、Telegram 和 Discord 机器人。
|
||||
通过扫码或已有机器人凭据把 IM 机器人接入 DeepSeek Harness。一个插件、一个设置入口,统一管理飞书、微信、钉钉、企业微信、QQ、Telegram、Discord 和 WhatsApp 机器人。
|
||||
|
||||
## 界面
|
||||
|
||||
|
|
@ -17,6 +17,7 @@
|
|||
- QQ:使用手机 QQ 扫码创建机器人,或使用已有 AppID + AppSecret 绑定机器人,通过 WebSocket 长连接收消息;私聊支持原生“正在输入”和流式回答,群聊在机器人被 @ 后回复。
|
||||
- Telegram:使用 @BotFather 生成的 Bot Token 接入机器人,通过官方 Bot API 长轮询收消息;私聊直接回复,群聊仅在机器人被提及或收到对机器人消息的回复时响应,并通过编辑消息流式显示 Harness 回答。
|
||||
- Discord:使用 Developer Portal 生成的 Bot Token 接入机器人,通过 Gateway v10 长连接收消息;私信直接回复,服务器频道仅在机器人被提及时响应,并通过编辑消息流式显示 Harness 回答。
|
||||
- WhatsApp:使用手机 WhatsApp 扫码关联设备,通过 WhatsApp Web 长连接收消息;收到消息后显示已读和“正在输入”,再发送 Harness 的最终回答。
|
||||
|
||||
其他 IM 平台可继续按同一渠道适配器结构接入。
|
||||
|
||||
|
|
@ -38,6 +39,10 @@ dsh plugin --profile web add @xmanrui/dsh-im
|
|||
|
||||
Telegram 和 Discord 没有官方扫码创建机器人流程,因此页面只显示带钥匙图标的「手动接入」入口,并只要求 Bot Token。Telegram Token 由 @BotFather 生成;若该机器人已经配置 Webhook,需要先由原服务移除 Webhook,Bot API 长轮询才能接管消息。Discord Token 来自 Developer Portal 的 Bot 页面;还需把机器人邀请到目标服务器,并授予查看频道、发送消息和读取历史消息权限。本插件只读取私信和明确提及机器人的服务器消息,因此不要求 Message Content 特权 Intent。
|
||||
|
||||
WhatsApp 页面只显示「扫码接入机器人」。打开手机 WhatsApp 的「设置 → 已关联设备 → 关联设备」,扫描 Harness 页面中的二维码即可,不需要 Meta 控制台、Cloud API、Webhook、Phone Number ID 或 Access Token。关联设备状态只保存在本机 `~/.dsh/integrations/dsh-whatsapp/auth`,浏览器只会收到一次性二维码和脱敏后的账号状态。个人账号可在 WhatsApp 的「给自己发消息」会话中直接使用;插件按消息 ID 过滤自己的回复,避免形成回复循环。
|
||||
|
||||
建议为机器人准备独立 WhatsApp 号码。关联个人常用账号会让发给该账号的私聊消息成为 Harness 输入;群聊只有明确提及该账号或回复该账号消息时才会触发。请只把机器人号码开放给可信联系人,并在不再使用时同时从 Harness 和手机「已关联设备」中移除。
|
||||
|
||||
钉钉扫码接入时,请使用已加入企业/组织且有权创建机器人的钉钉账号扫描页面二维码,再在钉钉授权页点击「一键创建新机器人」。若提示“该账号还未加入组织”,请先创建组织或换用已加入组织的账号后重新扫码。插件不设置本机二次批准流程,钉钉中的机器人可见范围就是入站访问范围,请只开放给信任的组织、群或成员。
|
||||
|
||||
企业微信扫码接入时,请使用已加入企业且具有机器人创建或管理权限的企业微信账号,并在手机端确认创建智能机器人。扫码创建的是企业微信智能机器人,不是让插件直接登录个人微信账号。无论扫码还是凭据绑定,企业微信中的机器人可见范围就是入站访问范围,请只开放给信任的企业成员和群聊。
|
||||
|
|
@ -49,10 +54,10 @@ QQ 扫码接入使用腾讯 QQBot v2 官方流程。默认腾讯授权页会把
|
|||
## 设计
|
||||
|
||||
- Harness 中只注册一个「IM机器人」设置页;
|
||||
- 七个渠道的 Host、客户端与运行时源码都在本仓库维护,不依赖外部独立渠道插件;
|
||||
- 左侧使用渠道 Logo 切换微信、飞书、钉钉、企业微信、QQ、Telegram 和 Discord,不使用启用/停用开关;
|
||||
- 七个渠道保持独立的 RPC、凭据、连接监督和会话映射;
|
||||
- 浏览器只获得二维码和脱敏状态;手动输入的 Secret 或 Bot Token 仅单向提交给本机 Host,任何 RPC 响应都不会返回 App Secret、`bot_token`、钉钉 `client_secret`、企业微信 Secret、QQ `app_secret`、Telegram/Discord Bot Token 或原始用户标识。
|
||||
- 八个渠道的 Host、客户端与运行时源码都在本仓库维护,不依赖外部独立渠道插件;
|
||||
- 左侧使用渠道 Logo 切换微信、飞书、钉钉、企业微信、QQ、Telegram、Discord 和 WhatsApp,不使用启用/停用开关;
|
||||
- 八个渠道保持独立的 RPC、凭据、连接监督和会话映射;
|
||||
- 浏览器只获得二维码和脱敏状态;手动输入的 Secret 或 Bot Token 仅单向提交给本机 Host,任何 RPC 响应都不会返回 App Secret、`bot_token`、钉钉 `client_secret`、企业微信 Secret、QQ `app_secret`、Telegram/Discord Bot Token、WhatsApp 关联设备密钥或原始用户标识。
|
||||
|
||||
## 本地开发
|
||||
|
||||
|
|
@ -78,9 +83,9 @@ IM 管理 RPC 默认仅接受回环浏览器。如果 Web profile 在受信任
|
|||
|
||||
## English
|
||||
|
||||
Connect IM bots to DeepSeek Harness by scanning a QR code or entering existing bot credentials. One plugin and one settings entry provide unified management for Feishu, WeChat, DingTalk, WeCom, QQ, Telegram, and Discord bots.
|
||||
Connect IM bots to DeepSeek Harness by scanning a QR code or entering existing bot credentials. One plugin and one settings entry provide unified management for Feishu, WeChat, DingTalk, WeCom, QQ, Telegram, Discord, and WhatsApp bots.
|
||||
|
||||
> GitHub description: Connect IM bots to DeepSeek Harness by QR code or bot credentials (supports Feishu, WeChat, DingTalk, WeCom, QQ, Telegram, and Discord).
|
||||
> GitHub description: Connect IM bots to DeepSeek Harness by QR code or bot credentials (supports Feishu, WeChat, DingTalk, WeCom, QQ, Telegram, Discord, and WhatsApp).
|
||||
|
||||
## Interface
|
||||
|
||||
|
|
@ -95,6 +100,7 @@ Connect IM bots to DeepSeek Harness by scanning a QR code or entering existing b
|
|||
- QQ: create a bot by QR code or bind an existing bot with AppID + AppSecret, receive messages over a WebSocket connection, stream private-chat replies with a native typing indicator, and reply in groups when mentioned.
|
||||
- Telegram: bind a BotFather-created bot with its Bot Token, receive messages through Bot API long polling, reply directly in private chats, require a mention or reply in groups, and stream Harness output by editing the reply.
|
||||
- Discord: bind a Developer Portal bot with its Bot Token, receive events through Gateway v10, reply directly in DMs, require a mention in server channels, and stream Harness output by editing the reply.
|
||||
- WhatsApp: scan a QR code to link a WhatsApp device, receive messages over WhatsApp Web, show a native read receipt and typing indicator, and then send the final Harness answer.
|
||||
|
||||
Other IM platforms can be added through the same channel-adapter structure.
|
||||
|
||||
|
|
@ -116,6 +122,10 @@ Feishu, QQ, DingTalk, and WeCom each provide two entry points. The blue **QR acc
|
|||
|
||||
Telegram and Discord do not provide an official QR flow for creating bots, so their pages expose only the key-marked **Manual access** action and request a Bot Token. Generate the Telegram token with BotFather; an existing webhook must be removed by its current service before Bot API long polling can receive updates. Generate the Discord token on the Developer Portal's Bot page, invite the bot to the target server, and grant View Channel, Send Messages, and Read Message History. The plugin reads DMs and server messages that explicitly mention the bot, so it does not request the privileged Message Content intent.
|
||||
|
||||
WhatsApp exposes only **QR access**. On the phone, open **WhatsApp → Settings → Linked devices → Link a device**, then scan the QR code shown by Harness. No Meta console, Cloud API, Webhook, Phone Number ID, or Access Token is required. Linked-device state stays under `~/.dsh/integrations/dsh-whatsapp/auth`; the browser receives only the one-time QR code and redacted account status. Personal accounts can use WhatsApp's **Message yourself** chat directly; the plugin suppresses only its own exact reply message IDs to prevent reply loops.
|
||||
|
||||
Use a dedicated WhatsApp number for the bot when possible. Linking a personal account makes DMs sent to that account eligible Harness input; group messages trigger only when they mention or reply to the linked account. Limit the number to trusted contacts, and remove the device from both Harness and the phone's **Linked devices** list when it is no longer used.
|
||||
|
||||
For DingTalk QR binding, scan with an account that belongs to an enterprise or organization and can create bots, then choose **Create a new bot** on the authorization page. If DingTalk reports that the account has not joined an organization, create one or switch to an account that has, then scan again. There is no second local sender-approval flow: the bot's DingTalk visibility is its inbound access scope, so restrict it to trusted organizations, groups, or members.
|
||||
|
||||
For WeCom QR binding, scan with an account that belongs to an enterprise and can create or manage bots, then confirm creation of the intelligent bot in the mobile app. This creates a WeCom intelligent bot; it does not sign the plugin into a personal WeChat account. For both QR and credential binding, restrict the bot's WeCom visibility to trusted enterprise members and group chats.
|
||||
|
|
@ -127,10 +137,10 @@ Feishu QR binding records the scanner as an allowed user. Manual credentials can
|
|||
## Design
|
||||
|
||||
- Registers a single **IM Bot** settings page in Harness.
|
||||
- Maintains all seven channel Host, client, and runtime sources in this repository without external standalone channel plugins.
|
||||
- Uses channel logos for WeChat, Feishu, DingTalk, WeCom, QQ, Telegram, and Discord navigation without enable/disable switches.
|
||||
- Maintains all eight channel Host, client, and runtime sources in this repository without external standalone channel plugins.
|
||||
- Uses channel logos for WeChat, Feishu, DingTalk, WeCom, QQ, Telegram, Discord, and WhatsApp navigation without enable/disable switches.
|
||||
- Keeps RPC endpoints, credentials, connection supervision, and session mappings isolated by channel.
|
||||
- Returns only QR codes and redacted status data to the browser. Manually entered secrets and Bot Tokens travel one way to the local Host; no RPC response returns App Secrets, `bot_token`, DingTalk `client_secret`, WeCom Secrets, QQ `app_secret`, Telegram/Discord Bot Tokens, or raw user identifiers.
|
||||
- Returns only QR codes and redacted status data to the browser. Manually entered secrets and Bot Tokens travel one way to the local Host; no RPC response returns App Secrets, `bot_token`, DingTalk `client_secret`, WeCom Secrets, QQ `app_secret`, Telegram/Discord Bot Tokens, WhatsApp linked-device keys, or raw user identifiers.
|
||||
|
||||
## Local development
|
||||
|
||||
|
|
|
|||
|
|
@ -8,13 +8,15 @@ The WeCom QR-authorization request sequence is adapted from the official [`@weco
|
|||
|
||||
The Host bundle includes [`@larksuiteoapi/node-sdk`](https://github.com/larksuite/node-sdk) 1.73.0 and its [`protobufjs`](https://github.com/protobufjs/protobuf.js) 7.6.5 runtime. The Lark SDK is licensed under the MIT License. protobufjs is licensed under the BSD 3-Clause License; both license texts are reproduced below.
|
||||
|
||||
This package depends at runtime on [`dingtalk-stream`](https://github.com/open-dingtalk/dingtalk-stream-sdk-nodejs) 2.1.4, [`@wecom/aibot-node-sdk`](https://github.com/WecomTeam/aibot-node-sdk) 1.0.7, [`@tencent-connect/qqbot-nodejs`](https://github.com/tencent-connect/qqbot) 1.0.4, and [`qrcode`](https://github.com/soldair/node-qrcode) 1.5.4. These packages are licensed under the MIT License; `dingtalk-stream` is copyright 2023 钉钉开放平台团队.
|
||||
This package depends at runtime on [`dingtalk-stream`](https://github.com/open-dingtalk/dingtalk-stream-sdk-nodejs) 2.1.4, [`@wecom/aibot-node-sdk`](https://github.com/WecomTeam/aibot-node-sdk) 1.0.7, [`@tencent-connect/qqbot-nodejs`](https://github.com/tencent-connect/qqbot) 1.0.4, [`@whiskeysockets/baileys`](https://github.com/WhiskeySockets/Baileys) 7.0.0-rc14, and [`qrcode`](https://github.com/soldair/node-qrcode) 1.5.4. These packages are licensed under the MIT License; `dingtalk-stream` is copyright 2023 钉钉开放平台团队.
|
||||
|
||||
QQ QR binding uses Tencent Connect's official [`@tencent-connect/qqbot-connector`](https://www.npmjs.com/package/@tencent-connect/qqbot-connector) 1.2.0 package as an external runtime dependency. Its npm metadata declares `UNLICENSED`; no connector source is copied into this project.
|
||||
|
||||
This project is an independent DeepSeek Harness integration. It does not bundle OpenClaw and is not endorsed by Tencent, WeCom, Feishu, DingTalk, QQ, Telegram, or Discord.
|
||||
The WhatsApp channel uses Baileys to implement WhatsApp Web linked-device QR login and messaging. This is an unofficial WhatsApp Web integration; users should use a dedicated bot number and understand that WhatsApp protocol changes can require connector updates.
|
||||
|
||||
The WeChat, QQ, Telegram, and Discord marks use path data published by Simple Icons under the CC0 1.0 Universal license. The Feishu, DingTalk, and WeCom marks are inline vectors used for channel identification. Product names and logos remain trademarks of their respective owners.
|
||||
This project is an independent DeepSeek Harness integration. It does not bundle OpenClaw and is not endorsed by Tencent, WeCom, Feishu, DingTalk, QQ, Telegram, Discord, Meta, or WhatsApp.
|
||||
|
||||
The WeChat, QQ, Telegram, Discord, and WhatsApp marks use path data published by Simple Icons under the CC0 1.0 Universal license. The Feishu, DingTalk, and WeCom marks are inline vectors used for channel identification. Product names and logos remain trademarks of their respective owners.
|
||||
|
||||
## Lark Node SDK license
|
||||
|
||||
|
|
|
|||
840
lib/client.js
840
lib/client.js
File diff suppressed because it is too large
Load diff
280
lib/index.js
280
lib/index.js
File diff suppressed because one or more lines are too long
1149
package-lock.json
generated
1149
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "@xmanrui/dsh-im",
|
||||
"version": "0.1.1",
|
||||
"description": "通过扫码或机器人凭据把IM机器人接入DeepSeek Harness(支持飞书、微信、钉钉、企业微信、QQ、Telegram和Discord)。",
|
||||
"description": "通过扫码或机器人凭据把IM机器人接入DeepSeek Harness(支持飞书、微信、钉钉、企业微信、QQ、Telegram、Discord和WhatsApp)。",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"repository": {
|
||||
|
|
@ -60,6 +60,7 @@
|
|||
"@tencent-connect/qqbot-connector": "1.2.0",
|
||||
"@tencent-connect/qqbot-nodejs": "1.0.4",
|
||||
"@wecom/aibot-node-sdk": "1.0.7",
|
||||
"@whiskeysockets/baileys": "7.0.0-rc14",
|
||||
"dingtalk-stream": "2.1.4",
|
||||
"qrcode": "1.5.4"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -105,3 +105,16 @@ export function DiscordLogoGlyph({ size } = {}) {
|
|||
d: 'M20.32 4.37a19.8 19.8 0 0 0-4.89-1.51c-.21.38-.46.89-.63 1.29a18.4 18.4 0 0 0-5.59 0 13 13 0 0 0-.64-1.29c-1.71.29-3.36.8-4.89 1.52C.59 9.09-.25 13.68.17 18.2a19.9 19.9 0 0 0 6 3.04c.48-.66.91-1.36 1.28-2.1-.7-.26-1.37-.58-2-.96.17-.12.33-.25.49-.38 3.86 1.79 8.04 1.79 11.86 0 .16.13.32.26.49.38-.64.38-1.31.7-2.01.97.37.73.8 1.44 1.28 2.09a19.8 19.8 0 0 0 6-3.04c.49-5.24-.84-9.79-3.24-13.83ZM8.02 15.42c-1.16 0-2.11-1.07-2.11-2.38s.93-2.38 2.11-2.38c1.18 0 2.13 1.08 2.11 2.38 0 1.31-.93 2.38-2.11 2.38Zm7.95 0c-1.16 0-2.11-1.07-2.11-2.38s.93-2.38 2.11-2.38c1.18 0 2.13 1.08 2.11 2.38 0 1.31-.93 2.38-2.11 2.38Z',
|
||||
}));
|
||||
}
|
||||
|
||||
export function WhatsappLogoGlyph({ size } = {}) {
|
||||
return h('svg', {
|
||||
...dimensions(size),
|
||||
viewBox: '0 0 24 24',
|
||||
focusable: 'false',
|
||||
'aria-hidden': 'true',
|
||||
'data-im-channel-logo': 'whatsapp',
|
||||
}, h('path', {
|
||||
fill: 'currentColor',
|
||||
d: 'M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.074-.297-.149-1.255-.462-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.521.149-.173.198-.297.298-.495.099-.198.05-.372-.025-.521-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51a12.8 12.8 0 0 0-.57-.01c-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479s1.065 2.875 1.213 3.074c.149.198 2.095 3.2 5.077 4.487.709.306 1.262.489 1.694.626.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.29.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 0 1-5.031-1.378l-.361-.214-3.741.981.999-3.648-.235-.374a9.86 9.86 0 0 1-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.83 9.83 0 0 1 2.893 6.991c-.003 5.45-4.437 9.884-9.886 9.884m8.413-18.297A11.8 11.8 0 0 0 12.055 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.9 11.9 0 0 0 5.688 1.448h.005c6.557 0 11.892-5.335 11.895-11.893a11.82 11.82 0 0 0-3.486-8.413',
|
||||
}));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,6 +43,13 @@ export function createTokenChannelSettings(definition) {
|
|||
emptyTitle,
|
||||
emptyDescription,
|
||||
platformLabel,
|
||||
CredentialPanel = null,
|
||||
credentialPayload = ({ secret }) => ({ token: secret }),
|
||||
credentialAriaLabel = `使用 Bot Token 接入 ${channel} 机器人`,
|
||||
credentialOpenLabel = '手动接入',
|
||||
credentialCloseLabel = '收起凭据',
|
||||
credentialNoun = 'Bot Token',
|
||||
emptyActionLabel = '填写 Bot Token',
|
||||
} = definition;
|
||||
|
||||
function AccountCard({ account, busy, removing, onReconnect, onRequestRemove, onConfirmRemove, onCancelRemove }) {
|
||||
|
|
@ -83,7 +90,7 @@ export function createTokenChannelSettings(definition) {
|
|||
}, '移除接入')))),
|
||||
removing ? h('div', { className: 'ddt-confirm dim-confirm', role: 'alertdialog' },
|
||||
h('strong', null, `从 DeepSeek Harness 移除“${account.bot.name}”?`),
|
||||
h('p', null, `这会停止消息连接,并删除本机保存的 Bot Token、机器人配置及会话映射。${platformLabel}中的机器人不会被自动删除。`),
|
||||
h('p', null, `这会停止消息连接,并删除本机保存的 ${credentialNoun}、机器人配置及会话映射。${platformLabel}中的机器人不会被自动删除。`),
|
||||
h('div', { className: 'ddt-actions dim-viewActions' },
|
||||
h(Button, { onClick: onCancelRemove, disabled: Boolean(busy) }, '保留机器人'),
|
||||
h(Button, { kind: 'danger', onClick: onConfirmRemove, disabled: Boolean(busy) },
|
||||
|
|
@ -153,11 +160,14 @@ export function createTokenChannelSettings(definition) {
|
|||
};
|
||||
}, [loadStatus, model.phase]);
|
||||
|
||||
const bindCredentials = React.useCallback(async ({ secret }) => {
|
||||
const bindCredentials = React.useCallback(async (values) => {
|
||||
setBusy(true);
|
||||
setCredentialError(null);
|
||||
try {
|
||||
const snapshot = api.normalizeSnapshot(await invoke(endpoints.bindCredentials, { token: secret }));
|
||||
const snapshot = api.normalizeSnapshot(await invoke(
|
||||
endpoints.bindCredentials,
|
||||
credentialPayload(values),
|
||||
));
|
||||
if (!mounted.current) return;
|
||||
setModel({ phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null });
|
||||
setCredentialOpen(false);
|
||||
|
|
@ -224,8 +234,8 @@ export function createTokenChannelSettings(definition) {
|
|||
onClick: () => { setCredentialOpen((value) => !value); setCredentialError(null); },
|
||||
disabled: busy,
|
||||
'aria-pressed': credentialOpen,
|
||||
'aria-label': `使用 Bot Token 接入 ${channel} 机器人`,
|
||||
}, h(CredentialActionIcon), credentialOpen ? '收起凭据' : '手动接入')),
|
||||
'aria-label': credentialAriaLabel,
|
||||
}, h(CredentialActionIcon), credentialOpen ? credentialCloseLabel : credentialOpenLabel)),
|
||||
model.totals.configured > 0
|
||||
? h('div', { className: 'ddt-badge dim-onlineBadge' },
|
||||
h('span', null, `${model.totals.connected} / ${model.totals.configured} 在线`))
|
||||
|
|
@ -242,15 +252,22 @@ export function createTokenChannelSettings(definition) {
|
|||
h('p', null, model.error?.message),
|
||||
h(Button, { onClick: () => void loadStatus() }, '重新读取')))
|
||||
: h(React.Fragment, null,
|
||||
credentialOpen ? h(CredentialBindingPanel, {
|
||||
channel,
|
||||
secretLabel: 'Bot Token',
|
||||
secretPlaceholder: tokenPlaceholder,
|
||||
busy,
|
||||
error: credentialError,
|
||||
onSubmit: bindCredentials,
|
||||
onCancel: () => { setCredentialOpen(false); setCredentialError(null); },
|
||||
}) : null,
|
||||
credentialOpen ? (CredentialPanel
|
||||
? h(CredentialPanel, {
|
||||
busy,
|
||||
error: credentialError,
|
||||
onSubmit: bindCredentials,
|
||||
onCancel: () => { setCredentialOpen(false); setCredentialError(null); },
|
||||
})
|
||||
: h(CredentialBindingPanel, {
|
||||
channel,
|
||||
secretLabel: 'Bot Token',
|
||||
secretPlaceholder: tokenPlaceholder,
|
||||
busy,
|
||||
error: credentialError,
|
||||
onSubmit: bindCredentials,
|
||||
onCancel: () => { setCredentialOpen(false); setCredentialError(null); },
|
||||
})) : null,
|
||||
model.bots.length === 0 && !credentialOpen
|
||||
? h('div', { className: 'ddt-card dim-surfaceCard' },
|
||||
h('div', { className: 'ddt-cardBody ddt-empty dim-surfaceBody dim-emptyView' },
|
||||
|
|
@ -264,7 +281,7 @@ export function createTokenChannelSettings(definition) {
|
|||
h(Button, {
|
||||
kind: 'primary',
|
||||
onClick: () => setCredentialOpen(true),
|
||||
}, '填写 Bot Token'))),
|
||||
}, emptyActionLabel))),
|
||||
h('div', {
|
||||
className: `ddt-brandMark dim-emptyBrand ${avatarClass}`,
|
||||
'aria-hidden': 'true',
|
||||
|
|
|
|||
123
plugin-src/client/channels/whatsapp/api.js
Normal file
123
plugin-src/client/channels/whatsapp/api.js
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
export const WHATSAPP_RPC_CHANNEL = '/whatsapp';
|
||||
|
||||
export const WHATSAPP_ENDPOINTS = Object.freeze({
|
||||
status: 'connection.status',
|
||||
beginProvisioning: 'provision.begin',
|
||||
pollProvisioning: 'provision.poll',
|
||||
cancelProvisioning: 'provision.cancel',
|
||||
reconnectBot: 'bot.reconnect',
|
||||
deleteBot: 'bot.delete',
|
||||
});
|
||||
|
||||
const PROVISION_STATES = new Set(['starting', 'pending', 'connecting', 'connected', 'failed', 'cancelled']);
|
||||
const BOT_STATES = new Set(['connected', 'connecting', 'offline', 'error']);
|
||||
const QR_DATA_URL = /^data:image\/(?:png|webp);base64,[a-z\d+/]+={0,2}$/i;
|
||||
|
||||
function isRecord(value) {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function text(value, fallback, max = 240) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim().slice(0, max) : fallback;
|
||||
}
|
||||
|
||||
function id(value) {
|
||||
const result = text(value, '', 128);
|
||||
return /^[a-z\d_-]+$/i.test(result) ? result : undefined;
|
||||
}
|
||||
|
||||
function timestamp(value) {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
const parsed = typeof value === 'string' ? Date.parse(value) : Number.NaN;
|
||||
return Number.isNaN(parsed) ? undefined : parsed;
|
||||
}
|
||||
|
||||
export function unwrapRpcResult(result) {
|
||||
if (!isRecord(result) || typeof result.ok !== 'boolean') {
|
||||
throw new Error('WhatsApp 服务返回了无法识别的响应');
|
||||
}
|
||||
if (!result.ok) {
|
||||
const error = new Error(text(result.error?.message, 'WhatsApp 操作失败'));
|
||||
error.code = text(result.error?.code, 'WHATSAPP_RPC_ERROR', 80);
|
||||
throw error;
|
||||
}
|
||||
return result.value;
|
||||
}
|
||||
|
||||
export function safeQrSource(value) {
|
||||
return typeof value === 'string' && value.length <= 2 * 1024 * 1024 && QR_DATA_URL.test(value)
|
||||
? value : undefined;
|
||||
}
|
||||
|
||||
export function normalizeProvisioning(value, now = Date.now()) {
|
||||
const source = isRecord(value?.provisioning) ? value.provisioning : value;
|
||||
if (!isRecord(source)) throw new Error('WhatsApp 服务没有返回扫码进度');
|
||||
const attemptId = id(source.attemptId);
|
||||
if (!attemptId) throw new Error('WhatsApp 服务没有返回有效的扫码任务');
|
||||
const reported = text(source.status, 'failed', 32);
|
||||
const result = {
|
||||
attemptId,
|
||||
status: PROVISION_STATES.has(reported) ? reported : 'failed',
|
||||
expiresAt: timestamp(source.expiresAt) ?? now + 60_000,
|
||||
pollIntervalMs: Math.min(5_000, Math.max(500, Number(source.pollIntervalMs) || 1_000)),
|
||||
qrRevision: Number.isSafeInteger(source.qrRevision) ? source.qrRevision : 0,
|
||||
};
|
||||
const qrCodeDataUrl = safeQrSource(source.qrCodeDataUrl);
|
||||
if (qrCodeDataUrl) result.qrCodeDataUrl = qrCodeDataUrl;
|
||||
if (id(source.botId)) result.botId = id(source.botId);
|
||||
if (isRecord(source.error)) result.error = {
|
||||
code: text(source.error.code, 'WHATSAPP_PROVISION_FAILED', 80),
|
||||
message: text(source.error.message, 'WhatsApp 没有接入完成'),
|
||||
};
|
||||
return result;
|
||||
}
|
||||
|
||||
function normalizeBot(value) {
|
||||
if (!isRecord(value) || !id(value.botId)) return undefined;
|
||||
const connected = value.connected === true;
|
||||
const state = BOT_STATES.has(value.state) ? value.state : 'offline';
|
||||
return {
|
||||
botId: id(value.botId),
|
||||
connected,
|
||||
state: connected ? 'connected' : state,
|
||||
bot: {
|
||||
name: text(value.bot?.name, 'WhatsApp机器人', 100),
|
||||
idMasked: text(value.bot?.idMasked, 'WhatsApp账号', 140),
|
||||
},
|
||||
health: {
|
||||
summary: text(value.health?.summary, connected
|
||||
? 'WhatsApp Web 关联设备运行正常' : 'WhatsApp 连接尚未就绪'),
|
||||
lastCheckedAt: timestamp(value.health?.lastCheckedAt),
|
||||
},
|
||||
error: isRecord(value.error) ? {
|
||||
code: text(value.error.code, 'WHATSAPP_ACCOUNT_ERROR', 80),
|
||||
message: text(value.error.message, 'WhatsApp 连接尚未就绪'),
|
||||
} : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeSnapshot(value) {
|
||||
const source = isRecord(value?.snapshot) ? value.snapshot : value;
|
||||
if (!isRecord(source) || !Array.isArray(source.bots)) {
|
||||
throw new Error('WhatsApp 服务没有返回有效的机器人列表');
|
||||
}
|
||||
const bots = source.bots.map(normalizeBot).filter(Boolean);
|
||||
return {
|
||||
revision: Number.isSafeInteger(source.revision) ? source.revision : 0,
|
||||
bots,
|
||||
totals: { configured: bots.length, connected: bots.filter((bot) => bot.connected).length },
|
||||
provisioning: source.provisioning ? normalizeProvisioning(source.provisioning) : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function presentError(error) {
|
||||
return {
|
||||
code: text(error?.code, 'WHATSAPP_ERROR', 80),
|
||||
message: text(error?.message, 'WhatsApp 操作失败,请稍后重试'),
|
||||
};
|
||||
}
|
||||
|
||||
export function formatRemaining(milliseconds) {
|
||||
const seconds = Math.max(0, Math.ceil(Number(milliseconds) / 1_000) || 0);
|
||||
return `${String(Math.floor(seconds / 60)).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`;
|
||||
}
|
||||
433
plugin-src/client/channels/whatsapp/index.js
Normal file
433
plugin-src/client/channels/whatsapp/index.js
Normal file
|
|
@ -0,0 +1,433 @@
|
|||
import * as React from 'react';
|
||||
|
||||
import { WhatsappLogoGlyph } from '../../channel-logos.js';
|
||||
import { QrActionIcon } from '../../credential-binding.js';
|
||||
import { installDingtalkStyles } from '../dingtalk/styles.js';
|
||||
import {
|
||||
WHATSAPP_ENDPOINTS,
|
||||
formatRemaining,
|
||||
normalizeProvisioning,
|
||||
normalizeSnapshot,
|
||||
presentError,
|
||||
safeQrSource,
|
||||
unwrapRpcResult,
|
||||
} from './api.js';
|
||||
import { installWhatsappStyles } from './styles.js';
|
||||
|
||||
const h = React.createElement;
|
||||
const ACTIVE_STATES = new Set(['pending', 'connecting']);
|
||||
|
||||
const Button = React.forwardRef(function Button(
|
||||
{ children, kind = 'secondary', className = '', ...props },
|
||||
ref,
|
||||
) {
|
||||
return h('button', {
|
||||
...props,
|
||||
ref,
|
||||
type: 'button',
|
||||
className: `ddt-button ${className}`.trim(),
|
||||
'data-kind': kind,
|
||||
}, children);
|
||||
});
|
||||
|
||||
function checkedTime(value) {
|
||||
if (!value) return '尚未检查';
|
||||
try {
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||
}).format(new Date(value));
|
||||
} catch {
|
||||
return '刚刚';
|
||||
}
|
||||
}
|
||||
|
||||
function Heading({ totals, busy, onAdd, addButtonRef }) {
|
||||
return h('div', { className: 'ddt-heading' },
|
||||
h('div', { className: 'ddt-tools' },
|
||||
h('div', { className: 'dim-bindActions' },
|
||||
h(Button, {
|
||||
kind: 'primary',
|
||||
className: 'dim-scanButton',
|
||||
onClick: onAdd,
|
||||
disabled: busy,
|
||||
ref: addButtonRef,
|
||||
'aria-label': '扫码接入 WhatsApp 机器人',
|
||||
}, h(QrActionIcon), busy ? '正在接入' : '扫码接入机器人')),
|
||||
totals.configured > 0
|
||||
? h('div', { className: 'ddt-badge dim-onlineBadge' },
|
||||
h('span', null, `${totals.connected} / ${totals.configured} 在线`))
|
||||
: null));
|
||||
}
|
||||
|
||||
function LoadingView() {
|
||||
return h('div', {
|
||||
className: 'ddt-card ddt-loading dim-surfaceCard dim-loadingView',
|
||||
'aria-busy': 'true',
|
||||
}, h('div', { className: 'ddt-spinner dim-spinner' }), '正在读取 WhatsApp 机器人状态…');
|
||||
}
|
||||
|
||||
export function EmptyView({ busy, onStart }) {
|
||||
return h('div', { className: 'ddt-card dim-surfaceCard' },
|
||||
h('div', { className: 'ddt-cardBody ddt-empty dim-surfaceBody dim-emptyView' },
|
||||
h('div', { className: 'dim-emptyCopy' },
|
||||
h('div', { className: 'ddt-stateLabel dim-stateLabel' },
|
||||
h('span', { className: 'ddt-dot dim-stateDot' }),
|
||||
h('span', null, '尚未接入 WhatsApp 机器人')),
|
||||
h('h3', null, '扫码绑定 WhatsApp 机器人'),
|
||||
h('p', null, '使用手机 WhatsApp 扫描二维码即可接入。'),
|
||||
h('div', { className: 'ddt-actions dim-viewActions' },
|
||||
h(Button, { kind: 'primary', onClick: onStart, disabled: busy },
|
||||
busy ? '正在生成二维码…' : '生成二维码'))),
|
||||
h('div', {
|
||||
className: 'ddt-brandMark dim-emptyBrand dwa-avatar',
|
||||
'aria-hidden': 'true',
|
||||
}, h(WhatsappLogoGlyph, { size: 64 }))));
|
||||
}
|
||||
|
||||
export function QrPanel({ provision, now, busy, onRefresh, onCancel }) {
|
||||
const source = safeQrSource(provision.qrCodeDataUrl);
|
||||
const remaining = Math.max(0, provision.expiresAt - now);
|
||||
const duration = Math.max(1, provision.durationMs ?? 60_000);
|
||||
const progress = Math.round(Math.min(1, remaining / duration) * 100);
|
||||
return h('div', { className: 'ddt-card dim-surfaceCard' },
|
||||
h('div', { className: 'ddt-cardBody ddt-qrLayout dim-surfaceBody dim-qrLayout' },
|
||||
h('div', { className: 'ddt-qrColumn dim-qrColumn' },
|
||||
h('div', { className: 'ddt-qrFrame dim-qrFrame' },
|
||||
source ? h('img', {
|
||||
src: source,
|
||||
alt: '用于关联 WhatsApp 设备的一次性二维码',
|
||||
}) : h('div', { className: 'ddt-qrFallback dim-qrFallback' }, '二维码正在生成…')),
|
||||
h('div', { className: 'ddt-countdown dim-countdown' },
|
||||
h('div', { className: 'ddt-countdownTop dim-countdownTop' },
|
||||
h('span', null, '当前二维码有效时间'),
|
||||
h('strong', null, formatRemaining(remaining))),
|
||||
h('div', {
|
||||
className: 'ddt-progress dim-progress',
|
||||
style: { '--ddt-progress': `${progress}%` },
|
||||
}, h('span')))),
|
||||
h('div', { className: 'ddt-qrCopy dim-qrCopy' },
|
||||
h('div', { className: 'ddt-stateLabel dim-stateLabel' },
|
||||
h('span', { className: 'ddt-dot dim-stateDot', 'data-tone': 'warning' }),
|
||||
h('span', null, '等待 WhatsApp 扫码')),
|
||||
h('h3', null, '用手机 WhatsApp 扫描二维码'),
|
||||
h('ol', { className: 'ddt-steps dim-steps' },
|
||||
h('li', null, '打开 WhatsApp → 设置 → 已关联设备'),
|
||||
h('li', null, '点击“关联设备”并扫描左侧二维码')),
|
||||
h('div', { className: 'ddt-actions dim-viewActions' },
|
||||
h(Button, { onClick: onRefresh, disabled: busy }, '重新生成二维码'),
|
||||
h(Button, { kind: 'quiet', onClick: onCancel, disabled: busy }, '取消')))));
|
||||
}
|
||||
|
||||
export function ProvisionView({ provision, busy, onRetry, onClose }) {
|
||||
if (provision.status === 'starting' || provision.status === 'connecting') {
|
||||
const starting = provision.status === 'starting';
|
||||
return h('div', {
|
||||
className: 'ddt-card ddt-loading dim-surfaceCard dim-specialView',
|
||||
'aria-busy': 'true',
|
||||
}, h('div', { className: 'ddt-spinner dim-spinner' }),
|
||||
h('h3', null, starting ? '正在生成 WhatsApp 二维码' : '已扫码,正在连接 WhatsApp'),
|
||||
h('p', null, starting
|
||||
? '正在建立安全的关联设备会话。'
|
||||
: '关联设备正在接入 DeepSeek Harness。'));
|
||||
}
|
||||
const error = provision.error ?? {
|
||||
code: 'WHATSAPP_PROVISION_FAILED',
|
||||
message: 'WhatsApp 没有接入完成',
|
||||
};
|
||||
return h('div', { className: 'ddt-card dim-surfaceCard' },
|
||||
h('div', { className: 'ddt-inlineError dim-inlineError', role: 'alert' },
|
||||
h('h3', null, 'WhatsApp 没有接入完成'),
|
||||
h('p', null, error.message),
|
||||
h('span', { className: 'ddt-errorCode' }, error.code),
|
||||
h('div', { className: 'ddt-actions dim-viewActions' },
|
||||
h(Button, { kind: 'primary', onClick: onRetry, disabled: busy }, '重新生成二维码'),
|
||||
h(Button, { onClick: onClose, disabled: busy }, '关闭'))));
|
||||
}
|
||||
|
||||
function RemoveConfirmation({ account, busy, onConfirm, onCancel }) {
|
||||
return h('div', { className: 'ddt-confirm dim-confirm', role: 'alertdialog' },
|
||||
h('strong', null, `从 DeepSeek Harness 移除“${account.bot.name}”?`),
|
||||
h('p', null, '这会停止消息连接,并删除本机保存的 WhatsApp 关联设备和会话映射。'),
|
||||
h('div', { className: 'ddt-actions dim-viewActions' },
|
||||
h(Button, { onClick: onCancel, disabled: busy }, '保留机器人'),
|
||||
h(Button, { kind: 'danger', onClick: onConfirm, disabled: busy },
|
||||
busy ? '正在移除…' : '确认移除接入')));
|
||||
}
|
||||
|
||||
export function WhatsappAccountCard({
|
||||
account,
|
||||
busy,
|
||||
removing,
|
||||
onReconnect,
|
||||
onRequestRemove,
|
||||
onConfirmRemove,
|
||||
onCancelRemove,
|
||||
}) {
|
||||
const state = busy === 'reconnect' ? 'connecting' : account.state;
|
||||
const tone = account.connected ? 'success' : state === 'error' ? 'error' : 'warning';
|
||||
const stateLabel = account.connected ? '运行正常' : state === 'connecting' ? '正在连接' : '连接未就绪';
|
||||
const summary = account.error?.message ?? (account.connected ? null : account.health.summary);
|
||||
return h('article', { className: 'ddt-card dim-botCard', 'data-bot-id': account.botId },
|
||||
h('div', { className: 'ddt-cardBody dim-botCardBody' },
|
||||
h('div', { className: 'ddt-accountTop dim-botCardTop' },
|
||||
h('div', { className: 'ddt-accountIdentity dim-botIdentity' },
|
||||
h('div', {
|
||||
className: 'ddt-avatar dim-botAvatar dwa-avatar',
|
||||
'aria-hidden': 'true',
|
||||
}, h(WhatsappLogoGlyph, { size: 29 })),
|
||||
h('div', { className: 'dim-botName' },
|
||||
h('h3', null, account.bot.name), h('p', null, account.bot.idMasked))),
|
||||
h('div', { className: 'ddt-health dim-botHealth' },
|
||||
h('span', { className: 'ddt-dot dim-healthDot', 'data-tone': tone }),
|
||||
h('span', null, stateLabel))),
|
||||
h('dl', { className: 'ddt-metrics dim-botMetrics' },
|
||||
h('div', { className: 'ddt-metric dim-botMetric' },
|
||||
h('dt', null, '消息通道'),
|
||||
h('dd', null, account.connected ? 'WhatsApp Web' : '离线')),
|
||||
h('div', { className: 'ddt-metric dim-botMetric' },
|
||||
h('dt', null, '最近检查'), h('dd', null, checkedTime(account.health.lastCheckedAt)))),
|
||||
h('div', { className: 'ddt-accountFooter dim-cardFooter' },
|
||||
summary ? h('div', { className: 'ddt-summary dim-cardSummary' }, summary) : null,
|
||||
h('div', { className: 'ddt-actions dim-cardActions' },
|
||||
h(Button, {
|
||||
className: 'dim-cardAction', onClick: onReconnect, disabled: Boolean(busy),
|
||||
}, busy === 'reconnect' ? '检查中…' : account.connected ? '检查连接' : '重试连接'),
|
||||
h(Button, {
|
||||
className: 'dim-cardAction', kind: 'danger', onClick: onRequestRemove, disabled: Boolean(busy),
|
||||
}, '移除接入')))),
|
||||
removing ? h(RemoveConfirmation, {
|
||||
account,
|
||||
busy: busy === 'delete',
|
||||
onConfirm: onConfirmRemove,
|
||||
onCancel: onCancelRemove,
|
||||
}) : null);
|
||||
}
|
||||
|
||||
export function WhatsappSettingsTab({ rpcCall }) {
|
||||
const [model, setModel] = React.useState({
|
||||
phase: 'loading', bots: [], totals: { configured: 0, connected: 0 }, error: null,
|
||||
});
|
||||
const [provision, setProvision] = React.useState(null);
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
const [busyByBot, setBusyByBot] = React.useState({});
|
||||
const [removeTarget, setRemoveTarget] = React.useState(null);
|
||||
const [now, setNow] = React.useState(Date.now());
|
||||
const mounted = React.useRef(true);
|
||||
const addButtonRef = React.useRef(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
const disposeDingtalk = installDingtalkStyles();
|
||||
const disposeWhatsapp = installWhatsappStyles();
|
||||
mounted.current = true;
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
disposeWhatsapp();
|
||||
disposeDingtalk();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const invoke = React.useCallback(async (endpoint, payload = {}, signal) => {
|
||||
if (typeof rpcCall !== 'function') throw new TypeError('WhatsApp 设置页缺少 RPC 连接');
|
||||
return unwrapRpcResult(await rpcCall(endpoint, payload, signal));
|
||||
}, [rpcCall]);
|
||||
|
||||
const loadStatus = React.useCallback(async ({ signal, silent = false, restore = false } = {}) => {
|
||||
if (!silent && mounted.current) setModel((current) => ({ ...current, phase: 'loading', error: null }));
|
||||
try {
|
||||
const snapshot = normalizeSnapshot(await invoke(WHATSAPP_ENDPOINTS.status, {}, signal));
|
||||
if (!mounted.current || signal?.aborted) return undefined;
|
||||
setModel({ phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null });
|
||||
if (restore && snapshot.provisioning) setProvision({
|
||||
...snapshot.provisioning,
|
||||
durationMs: Math.max(1, snapshot.provisioning.expiresAt - Date.now()),
|
||||
});
|
||||
return snapshot;
|
||||
} catch (error) {
|
||||
if (error?.name !== 'AbortError' && mounted.current && !signal?.aborted) {
|
||||
setModel((current) => ({
|
||||
...current,
|
||||
phase: silent ? current.phase : 'error',
|
||||
error: presentError(error),
|
||||
}));
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}, [invoke]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
void loadStatus({ signal: controller.signal, restore: true });
|
||||
return () => controller.abort();
|
||||
}, [loadStatus]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (model.phase !== 'ready') return undefined;
|
||||
const controller = new AbortController();
|
||||
const timer = window.setInterval(
|
||||
() => void loadStatus({ signal: controller.signal, silent: true }),
|
||||
15_000,
|
||||
);
|
||||
return () => { controller.abort(); window.clearInterval(timer); };
|
||||
}, [loadStatus, model.phase]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!provision || !ACTIVE_STATES.has(provision.status)) return undefined;
|
||||
const timer = window.setInterval(() => mounted.current && setNow(Date.now()), 1_000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [provision?.attemptId, provision?.status]);
|
||||
|
||||
const startProvisioning = React.useCallback(async (replace = false) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
if (replace && provision?.attemptId) {
|
||||
await invoke(WHATSAPP_ENDPOINTS.cancelProvisioning, { attemptId: provision.attemptId });
|
||||
}
|
||||
if (!mounted.current) return;
|
||||
setProvision({ status: 'starting' });
|
||||
const started = normalizeProvisioning(await invoke(WHATSAPP_ENDPOINTS.beginProvisioning, {}));
|
||||
if (!mounted.current) return;
|
||||
setNow(Date.now());
|
||||
setProvision({ ...started, durationMs: Math.max(1, started.expiresAt - Date.now()) });
|
||||
} catch (error) {
|
||||
if (mounted.current) setProvision({ status: 'failed', error: presentError(error) });
|
||||
} finally {
|
||||
if (mounted.current) setBusy(false);
|
||||
}
|
||||
}, [invoke, provision?.attemptId]);
|
||||
|
||||
const closeProvision = React.useCallback(async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
if (provision?.attemptId && ACTIVE_STATES.has(provision.status)) {
|
||||
await invoke(WHATSAPP_ENDPOINTS.cancelProvisioning, { attemptId: provision.attemptId });
|
||||
}
|
||||
if (mounted.current) setProvision(null);
|
||||
} finally {
|
||||
if (mounted.current) setBusy(false);
|
||||
}
|
||||
}, [invoke, provision?.attemptId, provision?.status]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const attemptId = provision?.attemptId;
|
||||
if (!attemptId || !ACTIVE_STATES.has(provision.status)) return undefined;
|
||||
const controller = new AbortController();
|
||||
let disposed = false;
|
||||
let timer;
|
||||
const schedule = (delay) => {
|
||||
if (disposed || controller.signal.aborted) return;
|
||||
timer = window.setTimeout(() => void poll(), delay);
|
||||
};
|
||||
const poll = async () => {
|
||||
try {
|
||||
const current = normalizeProvisioning(await invoke(
|
||||
WHATSAPP_ENDPOINTS.pollProvisioning,
|
||||
{ attemptId },
|
||||
controller.signal,
|
||||
));
|
||||
if (disposed || controller.signal.aborted || !mounted.current) return;
|
||||
if (current.status === 'connected') {
|
||||
setProvision(null);
|
||||
await loadStatus({ signal: controller.signal, silent: true });
|
||||
return;
|
||||
}
|
||||
setProvision((previous) => ({
|
||||
...current,
|
||||
durationMs: previous?.durationMs ?? Math.max(1, current.expiresAt - Date.now()),
|
||||
}));
|
||||
if (ACTIVE_STATES.has(current.status)) schedule(current.pollIntervalMs);
|
||||
} catch (error) {
|
||||
if (!disposed && !controller.signal.aborted && mounted.current) {
|
||||
setProvision({ status: 'failed', error: presentError(error) });
|
||||
}
|
||||
}
|
||||
};
|
||||
schedule(provision.pollIntervalMs ?? 1_000);
|
||||
return () => {
|
||||
disposed = true;
|
||||
controller.abort();
|
||||
if (timer) window.clearTimeout(timer);
|
||||
};
|
||||
}, [invoke, loadStatus, provision?.attemptId, provision?.status]);
|
||||
|
||||
const botAction = React.useCallback(async (account, operation, endpoint, payload) => {
|
||||
setBusyByBot((current) => ({ ...current, [account.botId]: operation }));
|
||||
try {
|
||||
const snapshot = normalizeSnapshot(await invoke(endpoint, payload));
|
||||
if (mounted.current) {
|
||||
setModel({ phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null });
|
||||
}
|
||||
} finally {
|
||||
if (mounted.current) setBusyByBot((current) => {
|
||||
const next = { ...current };
|
||||
delete next[account.botId];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, [invoke]);
|
||||
|
||||
const botList = model.bots.length > 0
|
||||
? h('section', { className: 'dim-listSection' },
|
||||
h('div', { className: 'ddt-listHeading dim-listHeading' },
|
||||
h('h3', null, '已接入的 WhatsApp 机器人')),
|
||||
h('ul', { className: 'ddt-list dim-botList' }, model.bots.map((account) =>
|
||||
h('li', { key: account.botId }, h(WhatsappAccountCard, {
|
||||
account,
|
||||
busy: busyByBot[account.botId],
|
||||
removing: removeTarget === account.botId,
|
||||
onReconnect: () => void botAction(
|
||||
account,
|
||||
'reconnect',
|
||||
WHATSAPP_ENDPOINTS.reconnectBot,
|
||||
{ botId: account.botId },
|
||||
),
|
||||
onRequestRemove: () => setRemoveTarget(account.botId),
|
||||
onCancelRemove: () => setRemoveTarget(null),
|
||||
onConfirmRemove: async () => {
|
||||
await botAction(account, 'delete', WHATSAPP_ENDPOINTS.deleteBot, {
|
||||
botId: account.botId,
|
||||
confirm: true,
|
||||
});
|
||||
if (mounted.current) setRemoveTarget(null);
|
||||
},
|
||||
})))))
|
||||
: null;
|
||||
|
||||
return h('section', {
|
||||
className: 'ddt-page dwa-page dim-channelPage',
|
||||
'aria-label': 'WhatsApp 设置',
|
||||
},
|
||||
h(Heading, {
|
||||
totals: model.totals,
|
||||
busy,
|
||||
onAdd: () => void startProvisioning(false),
|
||||
addButtonRef,
|
||||
}),
|
||||
model.phase === 'loading'
|
||||
? h(LoadingView)
|
||||
: model.phase === 'error'
|
||||
? h('div', { className: 'ddt-card dim-surfaceCard' },
|
||||
h('div', { className: 'ddt-inlineError dim-inlineError' },
|
||||
h('h3', null, '无法读取 WhatsApp 机器人状态'),
|
||||
h('p', null, model.error?.message),
|
||||
h(Button, { onClick: () => void loadStatus() }, '重新读取')))
|
||||
: h(React.Fragment, null,
|
||||
provision?.status === 'pending'
|
||||
? h(QrPanel, {
|
||||
provision,
|
||||
now,
|
||||
busy,
|
||||
onRefresh: () => void startProvisioning(true),
|
||||
onCancel: () => void closeProvision(),
|
||||
})
|
||||
: provision
|
||||
? h(ProvisionView, {
|
||||
provision,
|
||||
busy,
|
||||
onRetry: () => void startProvisioning(true),
|
||||
onClose: () => void closeProvision(),
|
||||
})
|
||||
: model.bots.length === 0
|
||||
? h(EmptyView, { busy, onStart: () => void startProvisioning(false) })
|
||||
: null,
|
||||
botList));
|
||||
}
|
||||
19
plugin-src/client/channels/whatsapp/styles.js
Normal file
19
plugin-src/client/channels/whatsapp/styles.js
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
export const WHATSAPP_STYLE_ID = 'xmanrui-dsh-im-whatsapp-settings';
|
||||
|
||||
const CSS = String.raw`
|
||||
.dwa-page { --ddt-accent: #25d366; --ddt-accent-deep: #128c7e; --ddt-accent-wash: #eafbf0; }
|
||||
.dwa-avatar { color: #fff; background: #25d366; }
|
||||
.dwa-avatar svg { display: block; }
|
||||
`;
|
||||
|
||||
export function installWhatsappStyles() {
|
||||
if (typeof document === 'undefined') return () => {};
|
||||
const existing = document.querySelector(`style[data-plugin-css="${WHATSAPP_STYLE_ID}"]`);
|
||||
if (existing) return () => {};
|
||||
const style = document.createElement('style');
|
||||
style.dataset.plugin = '@xmanrui/dsh-im';
|
||||
style.dataset.pluginCss = WHATSAPP_STYLE_ID;
|
||||
style.textContent = CSS;
|
||||
document.head.appendChild(style);
|
||||
return () => style.remove();
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ import {
|
|||
TelegramLogoGlyph,
|
||||
WecomLogoGlyph,
|
||||
WeixinLogoGlyph,
|
||||
WhatsappLogoGlyph,
|
||||
} from './channel-logos.js';
|
||||
import { DINGTALK_RPC_CHANNEL } from './channels/dingtalk/api.js';
|
||||
import { DingtalkSettingsTab } from './channels/dingtalk/index.js';
|
||||
|
|
@ -29,6 +30,9 @@ import { installWecomStyles } from './channels/wecom/styles.js';
|
|||
import { WeixinSettingsTab } from './channels/weixin/index.js';
|
||||
import { WEIXIN_RPC_CHANNEL } from './channels/weixin/api.js';
|
||||
import { installWeixinStyles } from './channels/weixin/styles.js';
|
||||
import { WHATSAPP_RPC_CHANNEL } from './channels/whatsapp/api.js';
|
||||
import { WhatsappSettingsTab } from './channels/whatsapp/index.js';
|
||||
import { installWhatsappStyles } from './channels/whatsapp/styles.js';
|
||||
import { installImStyles } from './styles.js';
|
||||
|
||||
const h = React.createElement;
|
||||
|
|
@ -44,6 +48,7 @@ const CHANNELS = Object.freeze([
|
|||
{ id: 'qq', label: 'QQ' },
|
||||
{ id: 'telegram', label: 'Telegram' },
|
||||
{ id: 'discord', label: 'Discord' },
|
||||
{ id: 'whatsapp', label: 'WhatsApp' },
|
||||
]);
|
||||
|
||||
function WeixinLogo() {
|
||||
|
|
@ -79,6 +84,11 @@ function DiscordLogo() {
|
|||
h(DiscordLogoGlyph));
|
||||
}
|
||||
|
||||
function WhatsappLogo() {
|
||||
return h('span', { className: 'dim-logo dim-logoWhatsapp', 'aria-hidden': 'true' },
|
||||
h(WhatsappLogoGlyph));
|
||||
}
|
||||
|
||||
function ChannelLogo({ channel }) {
|
||||
if (channel === 'weixin') return h(WeixinLogo);
|
||||
if (channel === 'feishu') return h(FeishuLogo);
|
||||
|
|
@ -86,7 +96,8 @@ function ChannelLogo({ channel }) {
|
|||
if (channel === 'wecom') return h(WecomLogo);
|
||||
if (channel === 'qq') return h(QqLogo);
|
||||
if (channel === 'telegram') return h(TelegramLogo);
|
||||
return h(DiscordLogo);
|
||||
if (channel === 'discord') return h(DiscordLogo);
|
||||
return h(WhatsappLogo);
|
||||
}
|
||||
|
||||
export function IMSettingsTab({
|
||||
|
|
@ -97,6 +108,7 @@ export function IMSettingsTab({
|
|||
telegramRpcCall,
|
||||
wecomRpcCall,
|
||||
weixinRpcCall,
|
||||
whatsappRpcCall,
|
||||
}) {
|
||||
const [selected, setSelected] = React.useState('weixin');
|
||||
const active = CHANNELS.find((channel) => channel.id === selected) ?? CHANNELS[0];
|
||||
|
|
@ -138,7 +150,9 @@ export function IMSettingsTab({
|
|||
? h(QqSettingsTab, { rpcCall: qqRpcCall })
|
||||
: active.id === 'telegram'
|
||||
? h(TelegramSettingsTab, { rpcCall: telegramRpcCall })
|
||||
: h(DiscordSettingsTab, { rpcCall: discordRpcCall })),
|
||||
: active.id === 'discord'
|
||||
? h(DiscordSettingsTab, { rpcCall: discordRpcCall })
|
||||
: h(WhatsappSettingsTab, { rpcCall: whatsappRpcCall })),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -152,6 +166,7 @@ export function apply(ctx) {
|
|||
installQqStyles(),
|
||||
installTelegramStyles(),
|
||||
installDiscordStyles(),
|
||||
installWhatsappStyles(),
|
||||
installImStyles(),
|
||||
];
|
||||
return () => {
|
||||
|
|
@ -173,6 +188,8 @@ export function apply(ctx) {
|
|||
ctx.connection.rpc.call(TELEGRAM_RPC_CHANNEL, endpoint, payload, signal);
|
||||
const discordRpcCall = (endpoint, payload, signal) =>
|
||||
ctx.connection.rpc.call(DISCORD_RPC_CHANNEL, endpoint, payload, signal);
|
||||
const whatsappRpcCall = (endpoint, payload, signal) =>
|
||||
ctx.connection.rpc.call(WHATSAPP_RPC_CHANNEL, endpoint, payload, signal);
|
||||
|
||||
ctx.slots.inject('settings.plugins.tab', () => ctx.slots.register({
|
||||
name: 'settings.plugins.tab',
|
||||
|
|
@ -187,6 +204,7 @@ export function apply(ctx) {
|
|||
telegramRpcCall,
|
||||
wecomRpcCall,
|
||||
weixinRpcCall,
|
||||
whatsappRpcCall,
|
||||
}),
|
||||
}, IMSettingsTab));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,11 +37,13 @@ const CSS = String.raw`
|
|||
.dim-logoTelegram svg { width: 21px; height: 21px; }
|
||||
.dim-logoDiscord { color: white; background: #5865f2; }
|
||||
.dim-logoDiscord svg { width: 21px; height: 21px; }
|
||||
.dim-logoWhatsapp { color: white; background: #25d366; }
|
||||
.dim-logoWhatsapp svg { width: 21px; height: 21px; }
|
||||
.dim-channelCopy { min-width: 0; display: block; }
|
||||
.dim-channelCopy strong { overflow: hidden; color: inherit; font-size: 14px; line-height: 20px; font-weight: 680; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dim-divider { width: 1px; min-height: 520px; background: var(--dsw-alias-line-divider, #eef0f3); }
|
||||
.dim-panel { min-width: 0; container-type: inline-size; }
|
||||
.dim-panel .bxf-page, .dim-panel .dxw-page, .dim-panel .ddt-page, .dim-panel .dqq-page, .dim-panel .dwecom-page { width: 100%; max-width: none; padding: 0 0 24px; }
|
||||
.dim-panel .bxf-page, .dim-panel .dxw-page, .dim-panel .ddt-page, .dim-panel .dqq-page, .dim-panel .dwecom-page, .dim-panel .dwa-page { width: 100%; max-width: none; padding: 0 0 24px; }
|
||||
.dim-panel .bxf-heading, .dim-panel .dxw-heading, .dim-panel .ddt-heading { justify-content: flex-end; }
|
||||
.dim-panel .bxf-headingTools, .dim-panel .dxw-tools, .dim-panel .ddt-tools { width: 100%; justify-content: space-between; }
|
||||
.dim-panel .dim-bindActions { min-width: 0; display: flex; align-items: center; flex-wrap: nowrap; gap: 8px; }
|
||||
|
|
|
|||
35
plugin-src/host/channels/whatsapp/index.mjs
Normal file
35
plugin-src/host/channels/whatsapp/index.mjs
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { createProductionController } from './production.mjs';
|
||||
import { installWhatsappRpc } from './rpc.mjs';
|
||||
|
||||
export const name = 'dsh-im-whatsapp-host';
|
||||
export const inject = ['connection', 'webServer'];
|
||||
|
||||
export async function apply(ctx, config = {}) {
|
||||
if (config?.controller) {
|
||||
return installWhatsappRpc(ctx, config.controller, config.rpcOptions, config.rpcAuthority);
|
||||
}
|
||||
const production = await createProductionController(ctx, config, config.internals ?? {});
|
||||
const disposeRpc = installWhatsappRpc(
|
||||
ctx,
|
||||
production.controller,
|
||||
config.rpcOptions,
|
||||
config.rpcAuthority,
|
||||
);
|
||||
ctx.effect(() => async () => production.close(), 'dsh-im: close WhatsApp Web connections');
|
||||
return disposeRpc;
|
||||
}
|
||||
|
||||
export function createWhatsappHostPlugin(config) {
|
||||
return Object.freeze({ name, inject, apply: (ctx) => apply(ctx, config) });
|
||||
}
|
||||
|
||||
export { createProductionController } from './production.mjs';
|
||||
export {
|
||||
WHATSAPP_ENDPOINTS,
|
||||
WHATSAPP_RPC_CHANNEL,
|
||||
WHATSAPP_RPC_ENDPOINTS,
|
||||
createWhatsappRpcHandler,
|
||||
installWhatsappRpc,
|
||||
} from './rpc.mjs';
|
||||
export { WhatsappController } from '../../../../src/channels/whatsapp/whatsapp-controller.mjs';
|
||||
export { WhatsappRuntime } from '../../../../src/channels/whatsapp/whatsapp-runtime.mjs';
|
||||
121
plugin-src/host/channels/whatsapp/production.mjs
Normal file
121
plugin-src/host/channels/whatsapp/production.mjs
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import { rm, unlink } from 'node:fs/promises';
|
||||
import { homedir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
|
||||
import { WhatsappConfigStore } from '../../../../src/channels/whatsapp/config-store.mjs';
|
||||
import { WhatsappHarnessClient } from '../../../../src/channels/whatsapp/harness-client.mjs';
|
||||
import { WhatsappStateStore } from '../../../../src/channels/whatsapp/state-store.mjs';
|
||||
import { WhatsappController } from '../../../../src/channels/whatsapp/whatsapp-controller.mjs';
|
||||
import { WhatsappRuntime } from '../../../../src/channels/whatsapp/whatsapp-runtime.mjs';
|
||||
import { createWhatsappWebSession } from '../../../../src/channels/whatsapp/whatsapp-web-session.mjs';
|
||||
import { createTokenConnectionSupervisor } from '../shared/connection-supervisor.mjs';
|
||||
|
||||
const AUTH_DIRECTORY_PATTERN = /^[a-f0-9-]{36}$/;
|
||||
|
||||
function harnessOrigin(webServer, configured) {
|
||||
if (configured !== undefined) return new URL(configured);
|
||||
const port = webServer?.port;
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65_535) {
|
||||
throw new Error('dsh-im WhatsApp requires an initialized DSH webServer port');
|
||||
}
|
||||
return new URL(`http://127.0.0.1:${port}`);
|
||||
}
|
||||
|
||||
function pluginPaths(config) {
|
||||
const dshHome = resolve(config.dshHome ?? process.env.DSH_HOME ?? join(homedir(), '.dsh'));
|
||||
const root = resolve(config.dataDir ?? join(dshHome, 'integrations', 'dsh-whatsapp'));
|
||||
const authRoot = resolve(config.authDir ?? join(root, 'auth'));
|
||||
const authPath = (name) => {
|
||||
if (!AUTH_DIRECTORY_PATTERN.test(name ?? '')) throw new TypeError('Invalid WhatsApp auth directory');
|
||||
return resolve(authRoot, name);
|
||||
};
|
||||
return {
|
||||
config: resolve(config.configPath ?? join(root, 'config.json')),
|
||||
bots: resolve(config.botsDir ?? join(root, 'bots')),
|
||||
authPath,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createProductionController(ctx, config = {}, internals = {}) {
|
||||
if (!ctx?.webServer) throw new TypeError('dsh-im WhatsApp requires ctx.webServer');
|
||||
const logger = typeof ctx.logger === 'function'
|
||||
? ctx.logger('dsh-im:whatsapp') : (ctx.logger ?? console);
|
||||
const ConfigStore = internals.ConfigStore ?? WhatsappConfigStore;
|
||||
const StateStore = internals.StateStore ?? WhatsappStateStore;
|
||||
const Harness = internals.HarnessClient ?? WhatsappHarnessClient;
|
||||
const Controller = internals.Controller ?? WhatsappController;
|
||||
const Runtime = internals.Runtime ?? WhatsappRuntime;
|
||||
const createSession = internals.createSession ?? createWhatsappWebSession;
|
||||
const createSupervisor = internals.createConnectionSupervisor ?? createTokenConnectionSupervisor;
|
||||
const paths = pluginPaths(config);
|
||||
const configStore = await new ConfigStore(paths.config).load();
|
||||
const stateStores = new Map();
|
||||
const statePath = (botId) => resolve(paths.bots, botId, 'state.json');
|
||||
const stateFor = async (botId) => {
|
||||
let state = stateStores.get(botId);
|
||||
if (!state) {
|
||||
state = await new StateStore(statePath(botId)).load();
|
||||
stateStores.set(botId, state);
|
||||
}
|
||||
return state;
|
||||
};
|
||||
const harness = new Harness({
|
||||
baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
|
||||
workspace: resolve(config.workspace ?? process.cwd()),
|
||||
agentPreset: config.agentPreset ?? 'standard',
|
||||
autostart: false,
|
||||
dshBin: config.dshBin ?? 'dsh',
|
||||
});
|
||||
const controller = new Controller({
|
||||
configStore,
|
||||
authPath: paths.authPath,
|
||||
createSession,
|
||||
logger,
|
||||
createRuntime: async ({ botId, config: botConfig, authDir }) => new Runtime({
|
||||
config: botConfig,
|
||||
authDir,
|
||||
harness,
|
||||
state: await stateFor(botId),
|
||||
replyTimeoutMs: config.replyTimeoutMs ?? 600_000,
|
||||
connectTimeoutMs: config.connectTimeoutMs ?? 30_000,
|
||||
createSession,
|
||||
logger: {
|
||||
error: (...args) => logger.error?.(`[${botId}]`, ...args),
|
||||
warn: (...args) => logger.warn?.(`[${botId}]`, ...args),
|
||||
info: (...args) => logger.info?.(`[${botId}]`, ...args),
|
||||
debug: (...args) => logger.debug?.(`[${botId}]`, ...args),
|
||||
},
|
||||
}),
|
||||
deleteAuth: (authDirectory) => rm(paths.authPath(authDirectory), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
}),
|
||||
deleteState: async ({ botId }) => {
|
||||
const state = stateStores.get(botId);
|
||||
stateStores.delete(botId);
|
||||
if (state && typeof state.remove === 'function') return state.remove();
|
||||
try {
|
||||
await unlink(statePath(botId));
|
||||
} catch (error) {
|
||||
if (error?.code !== 'ENOENT') throw error;
|
||||
}
|
||||
},
|
||||
});
|
||||
const supervisor = createSupervisor({
|
||||
channel: 'whatsapp',
|
||||
controller,
|
||||
harness,
|
||||
logger,
|
||||
retryDelaysMs: config.retryDelaysMs,
|
||||
healthyIntervalMs: config.healthyIntervalMs,
|
||||
}).start();
|
||||
return {
|
||||
controller,
|
||||
ready: supervisor.ready,
|
||||
async close() {
|
||||
await supervisor.close();
|
||||
await controller.close();
|
||||
harness.stopManagedProcess();
|
||||
},
|
||||
};
|
||||
}
|
||||
140
plugin-src/host/channels/whatsapp/rpc.mjs
Normal file
140
plugin-src/host/channels/whatsapp/rpc.mjs
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
import QRCode from 'qrcode';
|
||||
|
||||
import { resolveRpcAuthority } from '../../rpc-authority.mjs';
|
||||
|
||||
export const WHATSAPP_RPC_CHANNEL = '/whatsapp';
|
||||
export const WHATSAPP_ENDPOINTS = Object.freeze({
|
||||
status: 'connection.status',
|
||||
beginProvisioning: 'provision.begin',
|
||||
pollProvisioning: 'provision.poll',
|
||||
cancelProvisioning: 'provision.cancel',
|
||||
reconnectBot: 'bot.reconnect',
|
||||
deleteBot: 'bot.delete',
|
||||
});
|
||||
export const WHATSAPP_RPC_ENDPOINTS = Object.freeze(Object.values(WHATSAPP_ENDPOINTS));
|
||||
|
||||
const FORBIDDEN_PUBLIC_KEYS = new Set(['qrValue', 'accountJid', 'authDirectory']);
|
||||
|
||||
function isRecord(value) {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function exactKeys(value, allowed) {
|
||||
return isRecord(value) && Object.keys(value).every((key) => allowed.includes(key));
|
||||
}
|
||||
|
||||
function validId(value) {
|
||||
return typeof value === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(value);
|
||||
}
|
||||
|
||||
function payloadFailure(endpoint, payload) {
|
||||
if (!isRecord(payload)) return 'Payload must be an object.';
|
||||
if ([WHATSAPP_ENDPOINTS.status, WHATSAPP_ENDPOINTS.beginProvisioning].includes(endpoint)) {
|
||||
return exactKeys(payload, []) ? null : `${endpoint} does not accept fields.`;
|
||||
}
|
||||
if ([WHATSAPP_ENDPOINTS.pollProvisioning, WHATSAPP_ENDPOINTS.cancelProvisioning].includes(endpoint)) {
|
||||
return exactKeys(payload, ['attemptId']) && validId(payload.attemptId)
|
||||
? null : `${endpoint} requires an attemptId.`;
|
||||
}
|
||||
if (endpoint === WHATSAPP_ENDPOINTS.reconnectBot) {
|
||||
return exactKeys(payload, ['botId']) && validId(payload.botId)
|
||||
? null : 'bot.reconnect requires a botId.';
|
||||
}
|
||||
if (endpoint === WHATSAPP_ENDPOINTS.deleteBot) {
|
||||
return exactKeys(payload, ['botId', 'confirm']) && validId(payload.botId)
|
||||
&& payload.confirm === true ? null : 'bot.delete requires a botId and confirm=true.';
|
||||
}
|
||||
return 'Unknown WhatsApp endpoint.';
|
||||
}
|
||||
|
||||
function sanitizePublic(value) {
|
||||
if (Array.isArray(value)) return value.map(sanitizePublic);
|
||||
if (!isRecord(value)) return value;
|
||||
const safe = {};
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
if (!FORBIDDEN_PUBLIC_KEYS.has(key)) safe[key] = sanitizePublic(child);
|
||||
}
|
||||
return safe;
|
||||
}
|
||||
|
||||
async function qrDataUrl(value) {
|
||||
return QRCode.toDataURL(value, {
|
||||
type: 'image/png',
|
||||
errorCorrectionLevel: 'M',
|
||||
margin: 2,
|
||||
width: 320,
|
||||
});
|
||||
}
|
||||
|
||||
async function encodeAttempt(value, encodeQr) {
|
||||
if (!value || typeof value.qrValue !== 'string') return sanitizePublic(value);
|
||||
return sanitizePublic({ ...value, qrCodeDataUrl: await encodeQr(value.qrValue) });
|
||||
}
|
||||
|
||||
async function publicStatus(value, encodeQr) {
|
||||
const snapshot = structuredClone(value);
|
||||
if (snapshot?.provisioning) snapshot.provisioning = await encodeAttempt(snapshot.provisioning, encodeQr);
|
||||
return sanitizePublic(snapshot);
|
||||
}
|
||||
|
||||
export function createWhatsappRpcHandler(controller, { encodeQr = qrDataUrl } = {}) {
|
||||
for (const method of ['status', 'startProvisioning', 'registrationStatus', 'cancelProvisioning', 'reconnectBot', 'deleteBot']) {
|
||||
if (typeof controller?.[method] !== 'function') {
|
||||
throw new TypeError(`A complete WhatsApp controller is required (${method})`);
|
||||
}
|
||||
}
|
||||
const qrCache = new Map();
|
||||
const cachedEncode = (value) => {
|
||||
let encoded = qrCache.get(value);
|
||||
if (!encoded) {
|
||||
if (qrCache.size >= 16) qrCache.delete(qrCache.keys().next().value);
|
||||
encoded = Promise.resolve().then(() => encodeQr(value));
|
||||
qrCache.set(value, encoded);
|
||||
}
|
||||
return encoded;
|
||||
};
|
||||
return async (endpoint, payload, signal) => {
|
||||
if (signal?.aborted) return { ok: false, error: { code: 'cancelled', message: 'The request was cancelled.' } };
|
||||
if (!WHATSAPP_RPC_ENDPOINTS.includes(endpoint)) {
|
||||
return { ok: false, error: { code: 'bad-request', message: 'Unknown WhatsApp endpoint.' } };
|
||||
}
|
||||
const invalid = payloadFailure(endpoint, payload);
|
||||
if (invalid) return { ok: false, error: { code: 'bad-request', message: invalid } };
|
||||
try {
|
||||
let value;
|
||||
if (endpoint === WHATSAPP_ENDPOINTS.status) {
|
||||
value = await publicStatus(await controller.status(), cachedEncode);
|
||||
} else if (endpoint === WHATSAPP_ENDPOINTS.beginProvisioning) {
|
||||
value = await encodeAttempt(await controller.startProvisioning(), cachedEncode);
|
||||
} else if (endpoint === WHATSAPP_ENDPOINTS.pollProvisioning) {
|
||||
const attempt = await controller.registrationStatus(payload.attemptId);
|
||||
if (!attempt) return { ok: false, error: { code: 'bad-request', message: 'The provisioning attempt no longer exists.' } };
|
||||
value = await encodeAttempt(attempt, cachedEncode);
|
||||
} else if (endpoint === WHATSAPP_ENDPOINTS.cancelProvisioning) {
|
||||
value = sanitizePublic(await controller.cancelProvisioning(payload.attemptId));
|
||||
} else if (endpoint === WHATSAPP_ENDPOINTS.reconnectBot) {
|
||||
value = await publicStatus(await controller.reconnectBot(payload.botId), cachedEncode);
|
||||
} else {
|
||||
value = await publicStatus(await controller.deleteBot(payload.botId), cachedEncode);
|
||||
}
|
||||
return signal?.aborted
|
||||
? { ok: false, error: { code: 'cancelled', message: 'The request was cancelled.' } }
|
||||
: { ok: true, value };
|
||||
} catch {
|
||||
return signal?.aborted
|
||||
? { ok: false, error: { code: 'cancelled', message: 'The request was cancelled.' } }
|
||||
: { ok: false, error: { code: 'whatsapp-operation-failed', message: 'WhatsApp 操作失败,请稍后重试。' } };
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function installWhatsappRpc(ctx, controller, options, authority) {
|
||||
if (!ctx?.connection?.rpc || typeof ctx.connection.rpc.handle !== 'function') {
|
||||
throw new TypeError('DSH Host Connection RPC is required');
|
||||
}
|
||||
return ctx.connection.rpc.handle(
|
||||
WHATSAPP_RPC_CHANNEL,
|
||||
createWhatsappRpcHandler(controller, options),
|
||||
{ authority: resolveRpcAuthority(authority) },
|
||||
);
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import { apply as applyQq } from './channels/qq/index.mjs';
|
|||
import { apply as applyTelegram } from './channels/telegram/index.mjs';
|
||||
import { apply as applyWecom } from './channels/wecom/index.mjs';
|
||||
import { apply as applyWeixin } from './channels/weixin/index.mjs';
|
||||
import { apply as applyWhatsapp } from './channels/whatsapp/index.mjs';
|
||||
|
||||
export const name = 'dsh-im-host';
|
||||
export const inject = ['connection', 'credentials', 'webServer'];
|
||||
|
|
@ -24,6 +25,7 @@ export function createImHostPlugin(internals = {}) {
|
|||
const startQq = internals.applyQq ?? applyQq;
|
||||
const startTelegram = internals.applyTelegram ?? applyTelegram;
|
||||
const startDiscord = internals.applyDiscord ?? applyDiscord;
|
||||
const startWhatsapp = internals.applyWhatsapp ?? applyWhatsapp;
|
||||
return Object.freeze({
|
||||
name,
|
||||
inject,
|
||||
|
|
@ -35,6 +37,7 @@ export function createImHostPlugin(internals = {}) {
|
|||
await startQq(ctx, channelConfig(config, 'qq'));
|
||||
await startTelegram(ctx, channelConfig(config, 'telegram'));
|
||||
await startDiscord(ctx, channelConfig(config, 'discord'));
|
||||
await startWhatsapp(ctx, channelConfig(config, 'whatsapp'));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ const required = [
|
|||
'plugin-src/host/channels/wecom/index.mjs',
|
||||
'plugin-src/host/channels/telegram/index.mjs',
|
||||
'plugin-src/host/channels/discord/index.mjs',
|
||||
'plugin-src/host/channels/whatsapp/index.mjs',
|
||||
'src/channels/feishu/feishu-runtime.mjs',
|
||||
'src/channels/weixin/weixin-runtime.mjs',
|
||||
'src/channels/dingtalk/dingtalk-runtime.mjs',
|
||||
|
|
@ -25,6 +26,8 @@ const required = [
|
|||
'src/channels/wecom/wecom-runtime.mjs',
|
||||
'src/channels/telegram/telegram-runtime.mjs',
|
||||
'src/channels/discord/discord-runtime.mjs',
|
||||
'src/channels/whatsapp/whatsapp-runtime.mjs',
|
||||
'src/channels/whatsapp/whatsapp-web-session.mjs',
|
||||
];
|
||||
await Promise.all(required.map((path) => access(resolve(root, path))));
|
||||
|
||||
|
|
@ -57,7 +60,7 @@ if (!client.includes('container-type: inline-size')
|
|||
|| !client.includes('@container (max-width: 680px)')) {
|
||||
throw new Error('client bundle does not contain the narrow-panel DingTalk QR layout');
|
||||
}
|
||||
for (const marker of ['/feishu', '/weixin', '/dingtalk', '/wecom', '/qq', '/telegram', '/discord']) {
|
||||
for (const marker of ['/feishu', '/weixin', '/dingtalk', '/wecom', '/qq', '/telegram', '/discord', '/whatsapp']) {
|
||||
if (!host.includes(marker)) {
|
||||
throw new Error(`host bundle does not contain the internal ${marker} RPC provider`);
|
||||
}
|
||||
|
|
@ -83,6 +86,7 @@ const directDependencies = {
|
|||
'@tencent-connect/qqbot-connector': '1.2.0',
|
||||
'@tencent-connect/qqbot-nodejs': '1.0.4',
|
||||
'@wecom/aibot-node-sdk': '1.0.7',
|
||||
'@whiskeysockets/baileys': '7.0.0-rc14',
|
||||
qrcode: '1.5.4',
|
||||
};
|
||||
for (const [name, version] of Object.entries(directDependencies)) {
|
||||
|
|
@ -101,8 +105,8 @@ for (const [name, version] of Object.entries(bundledBuildDependencies)) {
|
|||
throw new Error(`${name} must be a pinned build dependency at ${version}`);
|
||||
}
|
||||
}
|
||||
if (lock.packages?.['node_modules/protobufjs']?.dev !== true) {
|
||||
throw new Error('protobufjs must remain build-only in the package lock');
|
||||
if (lock.packages?.['node_modules/protobufjs']?.dev === true) {
|
||||
throw new Error('protobufjs must remain available to the Baileys runtime');
|
||||
}
|
||||
if (manifest.bin?.['dsh-im'] !== 'bin/dsh-im.mjs') {
|
||||
throw new Error('package manifest must publish the dsh-im executable');
|
||||
|
|
|
|||
165
src/channels/whatsapp/config-store.mjs
Normal file
165
src/channels/whatsapp/config-store.mjs
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
import { createHash } from 'node:crypto';
|
||||
import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
|
||||
import { dirname } from 'node:path';
|
||||
|
||||
const EMPTY_DOCUMENT = Object.freeze({ version: 2, bots: Object.freeze([]) });
|
||||
const BOT_ID_PATTERN = /^whatsapp_[a-f0-9]{24}$/;
|
||||
const AUTH_DIRECTORY_PATTERN = /^[a-f0-9-]{36}$/;
|
||||
|
||||
function cleanString(value) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
export function normalizeWhatsappAccountJid(value) {
|
||||
const jid = cleanString(value)?.toLowerCase();
|
||||
return /^\d{5,32}@(s\.whatsapp\.net|lid)$/.test(jid ?? '') ? jid : null;
|
||||
}
|
||||
|
||||
export function deriveWhatsappBotId(accountJid) {
|
||||
const normalized = normalizeWhatsappAccountJid(accountJid);
|
||||
if (!normalized) throw new TypeError('A valid WhatsApp account JID is required');
|
||||
return `whatsapp_${createHash('sha256').update(normalized).digest('hex').slice(0, 24)}`;
|
||||
}
|
||||
|
||||
export function maskWhatsappAccount(accountJid) {
|
||||
const digits = normalizeWhatsappAccountJid(accountJid)?.split('@')[0] ?? '';
|
||||
if (!digits) return 'WhatsApp账号';
|
||||
if (digits.length <= 7) return `${digits.slice(0, 2)}•••${digits.slice(-2)}`;
|
||||
return `${digits.slice(0, 4)}••••${digits.slice(-4)}`;
|
||||
}
|
||||
|
||||
export class WhatsappConfigStore {
|
||||
#path;
|
||||
#value = EMPTY_DOCUMENT;
|
||||
#writeQueue = Promise.resolve();
|
||||
|
||||
constructor(path) {
|
||||
this.#path = path;
|
||||
}
|
||||
|
||||
async load() {
|
||||
try {
|
||||
const normalized = this.#normalizeDocument(JSON.parse(await readFile(this.#path, 'utf8')));
|
||||
if (!normalized) throw new Error('dsh-im WhatsApp config contains invalid account data');
|
||||
this.#value = normalized;
|
||||
} catch (error) {
|
||||
if (error?.code !== 'ENOENT') throw error;
|
||||
this.#value = EMPTY_DOCUMENT;
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
list() {
|
||||
return structuredClone(this.#value.bots);
|
||||
}
|
||||
|
||||
get(botId) {
|
||||
const bot = this.#value.bots.find((candidate) => candidate.botId === botId);
|
||||
return bot ? structuredClone(bot) : null;
|
||||
}
|
||||
|
||||
getByAccountJid(accountJid) {
|
||||
const normalized = normalizeWhatsappAccountJid(accountJid);
|
||||
const bot = this.#value.bots.find((candidate) => candidate.accountJid === normalized);
|
||||
return bot ? structuredClone(bot) : null;
|
||||
}
|
||||
|
||||
async save(value) {
|
||||
const normalized = this.#normalizeBot(value);
|
||||
if (!normalized) throw new Error('Refusing to persist incomplete WhatsApp account data');
|
||||
return this.#mutate((bots) => {
|
||||
const duplicate = bots.find((bot) => bot.accountJid === normalized.accountJid
|
||||
&& bot.botId !== normalized.botId);
|
||||
const authCollision = bots.find((bot) => bot.authDirectory === normalized.authDirectory
|
||||
&& bot.botId !== normalized.botId);
|
||||
if (duplicate || authCollision) throw new Error('Duplicate WhatsApp account identity');
|
||||
const index = bots.findIndex((bot) => bot.botId === normalized.botId);
|
||||
if (index === -1) bots.push(normalized);
|
||||
else bots[index] = normalized;
|
||||
return structuredClone(normalized);
|
||||
});
|
||||
}
|
||||
|
||||
async remove(botId) {
|
||||
if (!BOT_ID_PATTERN.test(botId)) throw new TypeError('Invalid WhatsApp bot id');
|
||||
return this.#mutate((bots) => {
|
||||
const index = bots.findIndex((bot) => bot.botId === botId);
|
||||
if (index === -1) return null;
|
||||
const [removed] = bots.splice(index, 1);
|
||||
return structuredClone(removed);
|
||||
});
|
||||
}
|
||||
|
||||
async clear() {
|
||||
const operation = this.#writeQueue.then(async () => {
|
||||
try {
|
||||
await unlink(this.#path);
|
||||
} catch (error) {
|
||||
if (error?.code !== 'ENOENT') throw error;
|
||||
}
|
||||
this.#value = EMPTY_DOCUMENT;
|
||||
});
|
||||
this.#writeQueue = operation.then(() => undefined, () => undefined);
|
||||
await operation;
|
||||
}
|
||||
|
||||
#normalizeBot(value) {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
const accountJid = normalizeWhatsappAccountJid(value.accountJid);
|
||||
const botId = cleanString(value.botId);
|
||||
const authDirectory = cleanString(value.authDirectory);
|
||||
const name = cleanString(value.name);
|
||||
if (!accountJid || !botId || !authDirectory || !name
|
||||
|| !BOT_ID_PATTERN.test(botId) || !AUTH_DIRECTORY_PATTERN.test(authDirectory)
|
||||
|| deriveWhatsappBotId(accountJid) !== botId) return null;
|
||||
return Object.freeze({
|
||||
botId,
|
||||
accountJid,
|
||||
authDirectory,
|
||||
name,
|
||||
createdAt: cleanString(value.createdAt) ?? new Date().toISOString(),
|
||||
connectedAt: cleanString(value.connectedAt),
|
||||
});
|
||||
}
|
||||
|
||||
#normalizeDocument(value) {
|
||||
if (!value || value.version !== 2 || !Array.isArray(value.bots)) return null;
|
||||
const bots = value.bots.map((bot) => this.#normalizeBot(bot));
|
||||
if (bots.some((bot) => bot === null)) return null;
|
||||
const botIds = new Set();
|
||||
const accountJids = new Set();
|
||||
const authDirectories = new Set();
|
||||
for (const bot of bots) {
|
||||
if (botIds.has(bot.botId) || accountJids.has(bot.accountJid)
|
||||
|| authDirectories.has(bot.authDirectory)) return null;
|
||||
botIds.add(bot.botId);
|
||||
accountJids.add(bot.accountJid);
|
||||
authDirectories.add(bot.authDirectory);
|
||||
}
|
||||
return Object.freeze({ version: 2, bots: Object.freeze(bots) });
|
||||
}
|
||||
|
||||
async #mutate(mutator) {
|
||||
let result;
|
||||
const operation = this.#writeQueue.then(async () => {
|
||||
const bots = [...this.#value.bots];
|
||||
result = mutator(bots);
|
||||
const document = Object.freeze({ version: 2, bots: Object.freeze(bots) });
|
||||
await this.#write(document);
|
||||
this.#value = document;
|
||||
});
|
||||
this.#writeQueue = operation.then(() => undefined, () => undefined);
|
||||
await operation;
|
||||
return result;
|
||||
}
|
||||
|
||||
async #write(document) {
|
||||
await mkdir(dirname(this.#path), { recursive: true, mode: 0o700 });
|
||||
const temporary = `${this.#path}.tmp`;
|
||||
await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, {
|
||||
encoding: 'utf8',
|
||||
mode: 0o600,
|
||||
});
|
||||
await rename(temporary, this.#path);
|
||||
}
|
||||
}
|
||||
3
src/channels/whatsapp/harness-client.mjs
Normal file
3
src/channels/whatsapp/harness-client.mjs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { HarnessClient } from '../weixin/harness-client.mjs';
|
||||
|
||||
export class WhatsappHarnessClient extends HarnessClient {}
|
||||
3
src/channels/whatsapp/state-store.mjs
Normal file
3
src/channels/whatsapp/state-store.mjs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { ConversationStateStore } from '../shared/conversation-state-store.mjs';
|
||||
|
||||
export class WhatsappStateStore extends ConversationStateStore {}
|
||||
15
src/channels/whatsapp/whatsapp-bridge.mjs
Normal file
15
src/channels/whatsapp/whatsapp-bridge.mjs
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { createTextBridgeStatus, TextHarnessBridge } from '../shared/text-harness-bridge.mjs';
|
||||
|
||||
export const WHATSAPP_DESCRIPTOR = Object.freeze({
|
||||
key: 'whatsapp',
|
||||
label: 'WhatsApp',
|
||||
connectionLabel: ' Web 关联设备',
|
||||
});
|
||||
|
||||
export class WhatsappHarnessBridge extends TextHarnessBridge {
|
||||
constructor(options) {
|
||||
super({ ...options, descriptor: WHATSAPP_DESCRIPTOR });
|
||||
}
|
||||
}
|
||||
|
||||
export { createTextBridgeStatus as createWhatsappBridgeStatus };
|
||||
388
src/channels/whatsapp/whatsapp-controller.mjs
Normal file
388
src/channels/whatsapp/whatsapp-controller.mjs
Normal file
|
|
@ -0,0 +1,388 @@
|
|||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { deriveWhatsappBotId, maskWhatsappAccount } from './config-store.mjs';
|
||||
|
||||
const ACTIVE_ATTEMPT_STATES = new Set(['starting', 'pending', 'connecting']);
|
||||
const TERMINAL_ATTEMPT_STATES = new Set(['connected', 'failed', 'cancelled']);
|
||||
const QR_TTL_MS = 60_000;
|
||||
|
||||
function safeError(code, message) {
|
||||
return Object.freeze({ code, message });
|
||||
}
|
||||
|
||||
function publicAttempt(record) {
|
||||
if (!record) return null;
|
||||
return {
|
||||
attemptId: record.id,
|
||||
status: record.state,
|
||||
qrRevision: record.qrRevision,
|
||||
pollIntervalMs: 1_000,
|
||||
...(record.qrValue ? { qrValue: record.qrValue } : {}),
|
||||
...(record.expiresAt ? { expiresAt: record.expiresAt } : {}),
|
||||
...(record.botId ? { botId: record.botId } : {}),
|
||||
...(record.error ? { error: structuredClone(record.error) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export class WhatsappController {
|
||||
#configStore;
|
||||
#authPath;
|
||||
#createSession;
|
||||
#createRuntime;
|
||||
#deleteAuth;
|
||||
#deleteState;
|
||||
#logger;
|
||||
#runtimes = new Map();
|
||||
#errors = new Map();
|
||||
#attempts = new Map();
|
||||
#transitions = new Map();
|
||||
#activeAttemptId = null;
|
||||
#revision = 0;
|
||||
#closed = false;
|
||||
|
||||
constructor({
|
||||
configStore,
|
||||
authPath,
|
||||
createSession,
|
||||
createRuntime,
|
||||
deleteAuth = async () => {},
|
||||
deleteState = async () => {},
|
||||
logger = console,
|
||||
}) {
|
||||
if (!configStore || typeof configStore.list !== 'function'
|
||||
|| typeof configStore.save !== 'function' || typeof configStore.remove !== 'function') {
|
||||
throw new TypeError('WhatsappController requires a config store');
|
||||
}
|
||||
if (typeof authPath !== 'function' || typeof createSession !== 'function'
|
||||
|| typeof createRuntime !== 'function') {
|
||||
throw new TypeError('WhatsappController dependencies are incomplete');
|
||||
}
|
||||
this.#configStore = configStore;
|
||||
this.#authPath = authPath;
|
||||
this.#createSession = createSession;
|
||||
this.#createRuntime = createRuntime;
|
||||
this.#deleteAuth = deleteAuth;
|
||||
this.#deleteState = deleteState;
|
||||
this.#logger = logger;
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
if (this.#closed) return this.status();
|
||||
for (const config of this.#configStore.list()) {
|
||||
await this.#withBotTransition(config.botId, async () => {
|
||||
if (this.#closed || this.#runtimes.get(config.botId)?.status?.ready) return;
|
||||
try {
|
||||
await this.#startRuntime(config);
|
||||
this.#errors.delete(config.botId);
|
||||
} catch (error) {
|
||||
this.#errors.set(config.botId, safeError(
|
||||
error?.code === 'relink-required' ? 'relink-required' : 'connection-failed',
|
||||
error?.code === 'relink-required'
|
||||
? 'WhatsApp 关联设备已失效,请移除后重新扫码。'
|
||||
: 'WhatsApp 连接未就绪,插件会自动重试。',
|
||||
));
|
||||
this.#logger.warn?.(`[dsh-im:whatsapp] bot ${config.botId} failed to initialize`);
|
||||
} finally {
|
||||
this.#touch();
|
||||
}
|
||||
});
|
||||
}
|
||||
return this.status();
|
||||
}
|
||||
|
||||
async startProvisioning() {
|
||||
if (this.#closed) throw new Error('WhatsApp controller is closed');
|
||||
if (this.#activeAttemptId) await this.cancelProvisioning(this.#activeAttemptId);
|
||||
let resolveFirstQr;
|
||||
let rejectFirstQr;
|
||||
let firstQrSettled = false;
|
||||
const firstQr = new Promise((resolve, reject) => {
|
||||
resolveFirstQr = () => {
|
||||
if (firstQrSettled) return;
|
||||
firstQrSettled = true;
|
||||
resolve();
|
||||
};
|
||||
rejectFirstQr = (error) => {
|
||||
if (firstQrSettled) return;
|
||||
firstQrSettled = true;
|
||||
reject(error);
|
||||
};
|
||||
});
|
||||
const id = randomUUID();
|
||||
const record = {
|
||||
id,
|
||||
state: 'starting',
|
||||
authDirectory: id,
|
||||
createdAt: Date.now(),
|
||||
expiresAt: null,
|
||||
qrRevision: 0,
|
||||
qrValue: null,
|
||||
controller: new AbortController(),
|
||||
session: null,
|
||||
task: null,
|
||||
error: null,
|
||||
botId: null,
|
||||
};
|
||||
this.#attempts.set(id, record);
|
||||
this.#activeAttemptId = id;
|
||||
this.#touch();
|
||||
|
||||
try {
|
||||
const session = await this.#createSession({
|
||||
authDir: this.#authPath(record.authDirectory),
|
||||
signal: record.controller.signal,
|
||||
logger: this.#logger,
|
||||
onQr: (value) => {
|
||||
if (record.controller.signal.aborted || TERMINAL_ATTEMPT_STATES.has(record.state)
|
||||
|| typeof value !== 'string' || !value) return;
|
||||
record.qrValue = value;
|
||||
record.qrRevision += 1;
|
||||
record.expiresAt = Date.now() + QR_TTL_MS;
|
||||
record.state = 'pending';
|
||||
this.#touch();
|
||||
resolveFirstQr();
|
||||
},
|
||||
});
|
||||
record.session = session;
|
||||
record.task = session.ready.then((identity) => this.#completeProvisioning(record, identity))
|
||||
.catch((error) => this.#failProvisioning(record, error, rejectFirstQr));
|
||||
await firstQr;
|
||||
return publicAttempt(record);
|
||||
} catch (error) {
|
||||
await this.#failProvisioning(record, error, rejectFirstQr);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
registrationStatus(attemptId) {
|
||||
return publicAttempt(this.#attempts.get(attemptId));
|
||||
}
|
||||
|
||||
async cancelProvisioning(attemptId) {
|
||||
const record = this.#attempts.get(attemptId);
|
||||
if (!record) return null;
|
||||
if (!TERMINAL_ATTEMPT_STATES.has(record.state)) {
|
||||
record.controller.abort();
|
||||
await record.session?.close().catch(() => undefined);
|
||||
await record.task?.catch(() => undefined);
|
||||
if (!TERMINAL_ATTEMPT_STATES.has(record.state)) {
|
||||
record.state = 'cancelled';
|
||||
record.error = safeError('cancelled', '扫码接入已取消。');
|
||||
}
|
||||
await this.#deleteAuth(record.authDirectory).catch(() => undefined);
|
||||
}
|
||||
if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
|
||||
this.#touch();
|
||||
return publicAttempt(record);
|
||||
}
|
||||
|
||||
async reconnectBot(botId) {
|
||||
const config = this.#configStore.get(botId);
|
||||
if (!config) throw new Error('Unknown WhatsApp bot');
|
||||
await this.#withBotTransition(botId, async () => {
|
||||
try {
|
||||
await this.#startRuntime(config);
|
||||
this.#errors.delete(botId);
|
||||
} catch (error) {
|
||||
this.#errors.set(botId, safeError(
|
||||
error?.code === 'relink-required' ? 'relink-required' : 'connection-failed',
|
||||
error?.code === 'relink-required'
|
||||
? 'WhatsApp 关联设备已失效,请移除后重新扫码。'
|
||||
: 'WhatsApp 连接仍未就绪,请稍后重试。',
|
||||
));
|
||||
throw error;
|
||||
} finally {
|
||||
this.#touch();
|
||||
}
|
||||
});
|
||||
return this.status();
|
||||
}
|
||||
|
||||
async deleteBot(botId) {
|
||||
const config = this.#configStore.get(botId);
|
||||
if (!config) throw new Error('Unknown WhatsApp bot');
|
||||
await this.#withBotTransition(botId, async () => {
|
||||
await this.#stopRuntime(botId);
|
||||
try {
|
||||
await this.#configStore.remove(botId);
|
||||
} catch (error) {
|
||||
await this.#startRuntime(config).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
await Promise.allSettled([
|
||||
this.#deleteAuth(config.authDirectory),
|
||||
this.#deleteState({ botId, config }),
|
||||
]);
|
||||
this.#errors.delete(botId);
|
||||
this.#touch();
|
||||
});
|
||||
return this.status();
|
||||
}
|
||||
|
||||
status() {
|
||||
const bots = this.#configStore.list().map((config) => {
|
||||
const runtimeStatus = this.#runtimes.get(config.botId)?.status ?? null;
|
||||
const connected = runtimeStatus?.ready === true
|
||||
&& runtimeStatus.connectionState === 'connected'
|
||||
&& runtimeStatus.harnessReachable === true;
|
||||
const state = connected ? 'connected'
|
||||
: runtimeStatus?.connectionState === 'connecting' ? 'connecting'
|
||||
: this.#errors.has(config.botId) || runtimeStatus?.connectionState === 'failed'
|
||||
? 'error' : 'offline';
|
||||
return {
|
||||
botId: config.botId,
|
||||
state,
|
||||
connected,
|
||||
configured: true,
|
||||
bot: { name: config.name, idMasked: maskWhatsappAccount(config.accountJid) },
|
||||
health: {
|
||||
status: connected ? 'healthy' : state === 'error' ? 'error' : 'offline',
|
||||
summary: connected ? 'WhatsApp Web 关联设备运行正常'
|
||||
: state === 'error' ? 'WhatsApp 连接未就绪' : 'WhatsApp 连接当前离线',
|
||||
lastCheckedAt: runtimeStatus?.lastCheckedAt ?? null,
|
||||
lastConnectedAt: runtimeStatus?.lastConnectedAt ?? null,
|
||||
},
|
||||
stats: {
|
||||
messagesReceived: runtimeStatus?.messagesReceived ?? 0,
|
||||
messagesReplied: runtimeStatus?.messagesReplied ?? 0,
|
||||
},
|
||||
error: structuredClone(this.#errors.get(config.botId) ?? null),
|
||||
};
|
||||
});
|
||||
const connectedCount = bots.filter((bot) => bot.connected).length;
|
||||
const active = this.#activeAttemptId ? this.#attempts.get(this.#activeAttemptId) : null;
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
revision: this.#revision,
|
||||
state: active && ACTIVE_ATTEMPT_STATES.has(active.state) ? 'provisioning'
|
||||
: bots.length === 0 ? 'disconnected'
|
||||
: connectedCount === bots.length ? 'connected'
|
||||
: connectedCount > 0 ? 'degraded' : 'offline',
|
||||
bots,
|
||||
totals: { configured: bots.length, connected: connectedCount },
|
||||
...(active && ACTIVE_ATTEMPT_STATES.has(active.state)
|
||||
? { provisioning: publicAttempt(active) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async close() {
|
||||
if (this.#closed) return;
|
||||
this.#closed = true;
|
||||
if (this.#activeAttemptId) await this.cancelProvisioning(this.#activeAttemptId);
|
||||
await Promise.allSettled([...this.#transitions.values()]);
|
||||
await Promise.allSettled([...this.#runtimes.keys()].map((botId) => this.#stopRuntime(botId)));
|
||||
}
|
||||
|
||||
async #completeProvisioning(record, identity) {
|
||||
if (record.controller.signal.aborted || this.#closed) return;
|
||||
record.state = 'connecting';
|
||||
record.qrValue = null;
|
||||
record.expiresAt = null;
|
||||
this.#touch();
|
||||
const botId = deriveWhatsappBotId(identity.accountJid);
|
||||
record.botId = botId;
|
||||
await record.session?.close();
|
||||
const previous = this.#configStore.get(botId);
|
||||
const config = {
|
||||
botId,
|
||||
accountJid: identity.accountJid,
|
||||
authDirectory: record.authDirectory,
|
||||
name: identity.name,
|
||||
createdAt: previous?.createdAt ?? new Date().toISOString(),
|
||||
connectedAt: new Date().toISOString(),
|
||||
};
|
||||
try {
|
||||
if (record.controller.signal.aborted || this.#closed) throw Object.assign(new Error(), { name: 'AbortError' });
|
||||
await this.#configStore.save(config);
|
||||
if (record.controller.signal.aborted || this.#closed) throw Object.assign(new Error(), { name: 'AbortError' });
|
||||
try {
|
||||
await this.#startRuntime(config);
|
||||
this.#errors.delete(botId);
|
||||
} catch (error) {
|
||||
this.#errors.set(botId, safeError('connection-failed', 'WhatsApp 已绑定,消息连接暂未就绪。'));
|
||||
this.#logger.warn?.(`[dsh-im:whatsapp] bot ${botId} did not reconnect after QR binding`);
|
||||
}
|
||||
if (previous?.authDirectory && previous.authDirectory !== config.authDirectory) {
|
||||
await this.#deleteAuth(previous.authDirectory).catch(() => undefined);
|
||||
}
|
||||
record.state = 'connected';
|
||||
record.error = null;
|
||||
} catch (error) {
|
||||
if (record.controller.signal.aborted || this.#closed || error?.name === 'AbortError') {
|
||||
await this.#stopRuntime(botId);
|
||||
if (previous) await this.#configStore.save(previous).catch(() => undefined);
|
||||
else await this.#configStore.remove(botId).catch(() => undefined);
|
||||
await this.#deleteAuth(record.authDirectory).catch(() => undefined);
|
||||
if (previous) await this.#startRuntime(previous).catch(() => undefined);
|
||||
record.state = 'cancelled';
|
||||
record.error = safeError('cancelled', '扫码接入已取消。');
|
||||
} else {
|
||||
await this.#deleteAuth(record.authDirectory).catch(() => undefined);
|
||||
record.state = 'failed';
|
||||
record.error = safeError('activation-failed', 'WhatsApp 已扫码,但无法保存关联设备。');
|
||||
this.#logger.error?.('[dsh-im:whatsapp] unable to persist linked-device session');
|
||||
}
|
||||
} finally {
|
||||
if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
|
||||
this.#touch();
|
||||
}
|
||||
}
|
||||
|
||||
async #failProvisioning(record, error, rejectFirstQr = () => {}) {
|
||||
if (TERMINAL_ATTEMPT_STATES.has(record.state)) return;
|
||||
if (record.controller.signal.aborted || error?.name === 'AbortError') {
|
||||
record.state = 'cancelled';
|
||||
record.error = safeError('cancelled', '扫码接入已取消。');
|
||||
} else {
|
||||
record.state = 'failed';
|
||||
record.error = safeError('qr-connect-failed', '无法连接 WhatsApp,请重新生成二维码。');
|
||||
}
|
||||
if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
|
||||
await record.session?.close().catch(() => undefined);
|
||||
await this.#deleteAuth(record.authDirectory).catch(() => undefined);
|
||||
this.#touch();
|
||||
rejectFirstQr(error);
|
||||
}
|
||||
|
||||
async #startRuntime(config) {
|
||||
if (this.#closed) throw new Error('WhatsApp controller is closed');
|
||||
await this.#stopRuntime(config.botId);
|
||||
if (this.#closed) throw new Error('WhatsApp controller is closed');
|
||||
const runtime = await this.#createRuntime({
|
||||
botId: config.botId,
|
||||
config,
|
||||
authDir: this.#authPath(config.authDirectory),
|
||||
});
|
||||
if (!runtime || typeof runtime.start !== 'function' || typeof runtime.stop !== 'function') {
|
||||
throw new TypeError('createRuntime returned an invalid WhatsApp runtime');
|
||||
}
|
||||
this.#runtimes.set(config.botId, runtime);
|
||||
try {
|
||||
await runtime.start();
|
||||
} catch (error) {
|
||||
await runtime.stop().catch(() => undefined);
|
||||
this.#runtimes.delete(config.botId);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async #stopRuntime(botId) {
|
||||
const runtime = this.#runtimes.get(botId);
|
||||
this.#runtimes.delete(botId);
|
||||
await runtime?.stop().catch(() => undefined);
|
||||
}
|
||||
|
||||
#withBotTransition(botId, operation) {
|
||||
const previous = this.#transitions.get(botId) ?? Promise.resolve();
|
||||
const current = previous.catch(() => undefined).then(operation);
|
||||
const settled = current.finally(() => {
|
||||
if (this.#transitions.get(botId) === settled) this.#transitions.delete(botId);
|
||||
});
|
||||
this.#transitions.set(botId, settled);
|
||||
return settled;
|
||||
}
|
||||
|
||||
#touch() {
|
||||
this.#revision += 1;
|
||||
}
|
||||
}
|
||||
299
src/channels/whatsapp/whatsapp-runtime.mjs
Normal file
299
src/channels/whatsapp/whatsapp-runtime.mjs
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
import {
|
||||
areJidsSameUser,
|
||||
normalizeMessageContent,
|
||||
} from '@whiskeysockets/baileys';
|
||||
|
||||
import { splitMessageText } from '../shared/editable-message-stream.mjs';
|
||||
import { createWhatsappBridgeStatus, WhatsappHarnessBridge } from './whatsapp-bridge.mjs';
|
||||
import { createWhatsappWebSession } from './whatsapp-web-session.mjs';
|
||||
|
||||
function messageContext(content) {
|
||||
return content?.extendedTextMessage?.contextInfo
|
||||
?? content?.imageMessage?.contextInfo
|
||||
?? content?.videoMessage?.contextInfo
|
||||
?? content?.documentMessage?.contextInfo
|
||||
?? null;
|
||||
}
|
||||
|
||||
function messageText(content) {
|
||||
return content?.conversation
|
||||
?? content?.extendedTextMessage?.text
|
||||
?? content?.imageMessage?.caption
|
||||
?? content?.videoMessage?.caption
|
||||
?? content?.documentMessage?.caption
|
||||
?? '';
|
||||
}
|
||||
|
||||
export function normalizeWhatsappMessage(message, accountJid) {
|
||||
const remoteJid = typeof message?.key?.remoteJid === 'string' ? message.key.remoteJid : '';
|
||||
const alternateRemoteJid = typeof message?.key?.remoteJidAlt === 'string'
|
||||
? message.key.remoteJidAlt : '';
|
||||
const messageId = typeof message?.key?.id === 'string' ? message.key.id : '';
|
||||
if (!remoteJid || !messageId || remoteJid === 'status@broadcast'
|
||||
|| remoteJid.endsWith('@newsletter')) return null;
|
||||
const group = remoteJid.endsWith('@g.us');
|
||||
const fromMe = message.key.fromMe === true;
|
||||
const selfChat = fromMe && !group
|
||||
&& [remoteJid, alternateRemoteJid].some((jid) => jid && areJidsSameUser(jid, accountJid));
|
||||
if (fromMe && !selfChat) return null;
|
||||
const senderJid = selfChat ? accountJid : group ? message.key.participant : remoteJid;
|
||||
if (typeof senderJid !== 'string' || !senderJid) return null;
|
||||
const content = normalizeMessageContent(message.message);
|
||||
const context = messageContext(content);
|
||||
const mentioned = Array.isArray(context?.mentionedJid)
|
||||
&& context.mentionedJid.some((jid) => areJidsSameUser(jid, accountJid));
|
||||
const replyToSelf = typeof context?.participant === 'string'
|
||||
&& areJidsSameUser(context.participant, accountJid);
|
||||
return {
|
||||
messageId: `${remoteJid}:${messageId}`,
|
||||
providerMessageId: messageId,
|
||||
senderId: senderJid,
|
||||
senderIsBot: false,
|
||||
kind: group ? 'group' : 'direct',
|
||||
conversationId: remoteJid,
|
||||
content: messageText(content),
|
||||
addressed: !group || mentioned || replyToSelf,
|
||||
selfChat,
|
||||
replyTarget: { jid: remoteJid, quoted: message, selfChat },
|
||||
};
|
||||
}
|
||||
|
||||
class RecentWhatsappOutboundIds {
|
||||
#ids = new Map();
|
||||
|
||||
has(id) {
|
||||
this.#purge();
|
||||
return this.#ids.has(id);
|
||||
}
|
||||
|
||||
remember(id) {
|
||||
if (typeof id !== 'string' || !id) return;
|
||||
this.#purge();
|
||||
this.#ids.set(id, Date.now() + 5 * 60_000);
|
||||
while (this.#ids.size > 256) this.#ids.delete(this.#ids.keys().next().value);
|
||||
}
|
||||
|
||||
#purge() {
|
||||
const now = Date.now();
|
||||
for (const [id, expiresAt] of this.#ids) {
|
||||
if (expiresAt > now) continue;
|
||||
this.#ids.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class WhatsappBotClient {
|
||||
#socket;
|
||||
#outboundIds;
|
||||
#typingTimers = new Map();
|
||||
|
||||
constructor(socket, outboundIds) {
|
||||
this.#socket = socket;
|
||||
this.#outboundIds = outboundIds;
|
||||
}
|
||||
|
||||
async sendText(target, text) {
|
||||
await this.#stopTyping(target.jid);
|
||||
let result = null;
|
||||
for (const [index, chunk] of splitMessageText(text, 4_000).entries()) {
|
||||
result = await this.#socket.sendMessage(
|
||||
target.jid,
|
||||
{ text: chunk },
|
||||
index === 0 && target.quoted ? { quoted: target.quoted } : undefined,
|
||||
);
|
||||
this.#outboundIds.remember(result?.key?.id);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async sendTyping(target) {
|
||||
if (!target.selfChat && target.quoted?.key) {
|
||||
await this.#socket.readMessages([target.quoted.key]).catch(() => undefined);
|
||||
}
|
||||
await this.#socket.sendPresenceUpdate('composing', target.jid);
|
||||
await this.#stopTyping(target.jid, false);
|
||||
const timer = setInterval(() => {
|
||||
void this.#socket.sendPresenceUpdate('composing', target.jid).catch(() => {
|
||||
void this.#stopTyping(target.jid);
|
||||
});
|
||||
}, 20_000);
|
||||
timer.unref?.();
|
||||
this.#typingTimers.set(target.jid, timer);
|
||||
}
|
||||
|
||||
async close() {
|
||||
const jids = [...this.#typingTimers.keys()];
|
||||
await Promise.allSettled(jids.map((jid) => this.#stopTyping(jid)));
|
||||
}
|
||||
|
||||
async #stopTyping(jid, sendPaused = true) {
|
||||
const timer = this.#typingTimers.get(jid);
|
||||
if (timer) clearInterval(timer);
|
||||
this.#typingTimers.delete(jid);
|
||||
if (sendPaused) await this.#socket.sendPresenceUpdate('paused', jid).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
export function createWhatsappRuntimeStatus() {
|
||||
return {
|
||||
startedAt: null,
|
||||
ready: false,
|
||||
connectionState: 'idle',
|
||||
harnessReachable: false,
|
||||
lastCheckedAt: null,
|
||||
lastConnectedAt: null,
|
||||
lastError: null,
|
||||
...createWhatsappBridgeStatus(),
|
||||
};
|
||||
}
|
||||
|
||||
export class WhatsappRuntime {
|
||||
#config;
|
||||
#authDir;
|
||||
#harness;
|
||||
#state;
|
||||
#logger;
|
||||
#replyTimeoutMs;
|
||||
#connectTimeoutMs;
|
||||
#createSession;
|
||||
#status = createWhatsappRuntimeStatus();
|
||||
#abortController = null;
|
||||
#session = null;
|
||||
#client = null;
|
||||
#bridge = null;
|
||||
#starting = null;
|
||||
|
||||
constructor({
|
||||
config,
|
||||
authDir,
|
||||
harness,
|
||||
state,
|
||||
logger = console,
|
||||
replyTimeoutMs = 600_000,
|
||||
connectTimeoutMs = 30_000,
|
||||
createSession = createWhatsappWebSession,
|
||||
}) {
|
||||
if (!config || !authDir || !harness || !state || typeof createSession !== 'function') {
|
||||
throw new TypeError('WhatsappRuntime requires config, auth directory, Harness, state, and session factory');
|
||||
}
|
||||
this.#config = config;
|
||||
this.#authDir = authDir;
|
||||
this.#harness = harness;
|
||||
this.#state = state;
|
||||
this.#logger = logger;
|
||||
this.#replyTimeoutMs = replyTimeoutMs;
|
||||
this.#connectTimeoutMs = connectTimeoutMs;
|
||||
this.#createSession = createSession;
|
||||
}
|
||||
|
||||
get status() {
|
||||
return structuredClone(this.#status);
|
||||
}
|
||||
|
||||
async start() {
|
||||
if (this.#status.ready && this.#session) return this.status;
|
||||
if (this.#starting) return this.#starting;
|
||||
this.#starting = this.#start().finally(() => { this.#starting = null; });
|
||||
return this.#starting;
|
||||
}
|
||||
|
||||
async #start() {
|
||||
await this.stop();
|
||||
this.#status.startedAt = new Date().toISOString();
|
||||
this.#status.connectionState = 'connecting';
|
||||
this.#status.lastError = null;
|
||||
await this.#harness.ensureRunning();
|
||||
this.#status.harnessReachable = true;
|
||||
const controller = new AbortController();
|
||||
this.#abortController = controller;
|
||||
const outboundIds = new RecentWhatsappOutboundIds();
|
||||
let rejectRelink;
|
||||
const relinkRequired = new Promise((_, reject) => { rejectRelink = reject; });
|
||||
void relinkRequired.catch(() => undefined);
|
||||
try {
|
||||
const session = await this.#createSession({
|
||||
authDir: this.#authDir,
|
||||
signal: controller.signal,
|
||||
logger: this.#logger,
|
||||
onQr: () => rejectRelink(Object.assign(
|
||||
new Error('WhatsApp linked-device session must be scanned again'),
|
||||
{ code: 'relink-required' },
|
||||
)),
|
||||
onMessage: async (raw) => {
|
||||
const message = normalizeWhatsappMessage(raw, this.#config.accountJid);
|
||||
if (!message || outboundIds.has(message.providerMessageId) || !this.#bridge) return;
|
||||
this.#status.lastCheckedAt = Date.now();
|
||||
await this.#bridge.accept(message);
|
||||
},
|
||||
onDisconnect: ({ error }) => {
|
||||
if (controller.signal.aborted) return;
|
||||
this.#status.ready = false;
|
||||
this.#status.connectionState = 'failed';
|
||||
this.#status.lastError = error?.message ?? 'WhatsApp Web connection closed';
|
||||
},
|
||||
});
|
||||
this.#session = session;
|
||||
let timer;
|
||||
const identity = await Promise.race([
|
||||
session.ready,
|
||||
relinkRequired,
|
||||
new Promise((_, reject) => {
|
||||
timer = setTimeout(
|
||||
() => reject(new Error('WhatsApp Web did not connect in time')),
|
||||
this.#connectTimeoutMs,
|
||||
);
|
||||
}),
|
||||
]).finally(() => clearTimeout(timer));
|
||||
if (!areJidsSameUser(identity.accountJid, this.#config.accountJid)) {
|
||||
throw new Error('WhatsApp linked account does not match the saved bot');
|
||||
}
|
||||
const client = new WhatsappBotClient(session.socket, outboundIds);
|
||||
this.#client = client;
|
||||
this.#bridge = new WhatsappHarnessBridge({
|
||||
bot: client,
|
||||
harness: this.#harness,
|
||||
state: this.#state,
|
||||
status: this.#status,
|
||||
logger: this.#logger,
|
||||
replyTimeoutMs: this.#replyTimeoutMs,
|
||||
});
|
||||
const now = Date.now();
|
||||
this.#status.ready = true;
|
||||
this.#status.connectionState = 'connected';
|
||||
this.#status.lastCheckedAt = now;
|
||||
this.#status.lastConnectedAt = now;
|
||||
return this.status;
|
||||
} catch (error) {
|
||||
this.#status.ready = false;
|
||||
this.#status.connectionState = 'failed';
|
||||
this.#status.lastError = error?.message ?? String(error);
|
||||
await this.stop();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async logout() {
|
||||
await this.#session?.logout().catch(() => undefined);
|
||||
return this.stop();
|
||||
}
|
||||
|
||||
async stop() {
|
||||
const session = this.#session;
|
||||
const client = this.#client;
|
||||
const bridge = this.#bridge;
|
||||
this.#abortController?.abort();
|
||||
this.#abortController = null;
|
||||
this.#session = null;
|
||||
this.#client = null;
|
||||
this.#bridge = null;
|
||||
await client?.close().catch(() => undefined);
|
||||
await session?.close().catch(() => undefined);
|
||||
await Promise.race([
|
||||
bridge?.waitForIdle() ?? Promise.resolve(),
|
||||
new Promise((resolve) => setTimeout(resolve, 2_000)),
|
||||
]);
|
||||
this.#status.ready = false;
|
||||
this.#status.connectionState = 'idle';
|
||||
return this.status;
|
||||
}
|
||||
}
|
||||
212
src/channels/whatsapp/whatsapp-web-session.mjs
Normal file
212
src/channels/whatsapp/whatsapp-web-session.mjs
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
import { chmod, mkdir, readdir } from 'node:fs/promises';
|
||||
|
||||
import makeWASocket, {
|
||||
Browsers,
|
||||
DisconnectReason,
|
||||
jidNormalizedUser,
|
||||
useMultiFileAuthState,
|
||||
} from '@whiskeysockets/baileys';
|
||||
|
||||
const SILENT_LOGGER = Object.freeze({
|
||||
level: 'silent',
|
||||
trace() {},
|
||||
debug() {},
|
||||
info() {},
|
||||
warn() {},
|
||||
error() {},
|
||||
fatal() {},
|
||||
child() { return this; },
|
||||
});
|
||||
const APPEND_RECENT_GRACE_MS = 60_000;
|
||||
|
||||
function abortError() {
|
||||
return Object.assign(new Error('WhatsApp connection was cancelled'), { name: 'AbortError' });
|
||||
}
|
||||
|
||||
function disconnectStatus(error) {
|
||||
return error?.output?.statusCode ?? error?.data?.statusCode ?? error?.statusCode ?? null;
|
||||
}
|
||||
|
||||
function messageTimestampMs(value) {
|
||||
let seconds = value;
|
||||
if (typeof seconds === 'string') {
|
||||
if (!/^\d+$/.test(seconds)) return null;
|
||||
seconds = Number(seconds);
|
||||
} else if (typeof seconds === 'bigint') {
|
||||
seconds = Number(seconds);
|
||||
} else if (seconds && typeof seconds === 'object') {
|
||||
seconds = Number(seconds.valueOf());
|
||||
}
|
||||
return Number.isFinite(seconds) && seconds >= 0 ? seconds * 1_000 : null;
|
||||
}
|
||||
|
||||
async function hardenAuthDirectory(path) {
|
||||
await mkdir(path, { recursive: true, mode: 0o700 });
|
||||
await chmod(path, 0o700);
|
||||
const entries = await readdir(path, { withFileTypes: true }).catch(() => []);
|
||||
await Promise.all(entries.filter((entry) => entry.isFile())
|
||||
.map((entry) => chmod(`${path}/${entry.name}`, 0o600).catch(() => undefined)));
|
||||
}
|
||||
|
||||
function normalizeIdentity(socket, authState) {
|
||||
const source = socket.user ?? authState.creds.me;
|
||||
const accountJid = jidNormalizedUser(source?.id);
|
||||
if (!/^\d{5,32}@(s\.whatsapp\.net|lid)$/.test(accountJid ?? '')) {
|
||||
throw new Error('WhatsApp did not return a valid linked account');
|
||||
}
|
||||
return {
|
||||
accountJid,
|
||||
name: typeof source?.name === 'string' && source.name.trim()
|
||||
? source.name.trim().slice(0, 100) : 'WhatsApp机器人',
|
||||
};
|
||||
}
|
||||
|
||||
export async function createWhatsappWebSession({
|
||||
authDir,
|
||||
onQr,
|
||||
onMessage,
|
||||
onDisconnect,
|
||||
signal,
|
||||
logger = console,
|
||||
makeSocket = makeWASocket,
|
||||
loadAuthState = useMultiFileAuthState,
|
||||
} = {}) {
|
||||
if (!authDir || typeof onQr !== 'function') {
|
||||
throw new TypeError('WhatsApp Web session requires an auth directory and QR callback');
|
||||
}
|
||||
await hardenAuthDirectory(authDir);
|
||||
const sessionStartedAt = Date.now();
|
||||
const { state, saveCreds } = await loadAuthState(authDir);
|
||||
const originalKeySet = state.keys.set.bind(state.keys);
|
||||
state.keys.set = async (data) => {
|
||||
await originalKeySet(data);
|
||||
await hardenAuthDirectory(authDir);
|
||||
};
|
||||
|
||||
let closed = false;
|
||||
let readySettled = false;
|
||||
let resolveReady;
|
||||
let rejectReady;
|
||||
let saveQueue = Promise.resolve();
|
||||
let socket = null;
|
||||
let socketGeneration = 0;
|
||||
let restartTask = null;
|
||||
const ready = new Promise((resolve, reject) => {
|
||||
resolveReady = resolve;
|
||||
rejectReady = reject;
|
||||
});
|
||||
|
||||
const settleFailure = (error) => {
|
||||
if (readySettled) return;
|
||||
readySettled = true;
|
||||
rejectReady(error);
|
||||
};
|
||||
const close = async () => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
socketGeneration += 1;
|
||||
settleFailure(abortError());
|
||||
await restartTask?.catch(() => undefined);
|
||||
await saveQueue.catch(() => undefined);
|
||||
await socket?.end(undefined).catch(() => undefined);
|
||||
};
|
||||
const logout = async () => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
socketGeneration += 1;
|
||||
settleFailure(abortError());
|
||||
await restartTask?.catch(() => undefined);
|
||||
await saveQueue.catch(() => undefined);
|
||||
await socket?.logout('Removed from DeepSeek Harness').catch(() => undefined);
|
||||
};
|
||||
|
||||
const startSocket = () => {
|
||||
const generation = ++socketGeneration;
|
||||
let connectionOpen = false;
|
||||
const nextSocket = makeSocket({
|
||||
auth: state,
|
||||
browser: Browsers.macOS('DeepSeek Harness'),
|
||||
logger: SILENT_LOGGER,
|
||||
markOnlineOnConnect: false,
|
||||
syncFullHistory: false,
|
||||
shouldSyncHistoryMessage: () => false,
|
||||
getMessage: async () => undefined,
|
||||
generateHighQualityLinkPreview: false,
|
||||
});
|
||||
socket = nextSocket;
|
||||
|
||||
const resolveWhenLinked = () => {
|
||||
if (!connectionOpen || !state.creds.me || readySettled) return;
|
||||
void saveQueue.then(() => {
|
||||
if (closed || readySettled || generation !== socketGeneration
|
||||
|| !connectionOpen || !state.creds.me) return;
|
||||
readySettled = true;
|
||||
resolveReady(normalizeIdentity(nextSocket, state));
|
||||
}).catch((error) => settleFailure(error));
|
||||
};
|
||||
|
||||
nextSocket.ev.on('creds.update', () => {
|
||||
if (closed || generation !== socketGeneration) return;
|
||||
saveQueue = saveQueue.then(async () => {
|
||||
await saveCreds();
|
||||
await hardenAuthDirectory(authDir);
|
||||
});
|
||||
saveQueue.catch(() => logger.error?.('[dsh-im:whatsapp] failed to persist linked-device state'));
|
||||
resolveWhenLinked();
|
||||
});
|
||||
nextSocket.ev.on('connection.update', (update) => {
|
||||
if (closed || generation !== socketGeneration) return;
|
||||
if (typeof update.qr === 'string' && update.qr) onQr(update.qr);
|
||||
if (update.connection === 'open') {
|
||||
connectionOpen = true;
|
||||
resolveWhenLinked();
|
||||
}
|
||||
if (update.connection === 'close') {
|
||||
const status = disconnectStatus(update.lastDisconnect?.error);
|
||||
if (status === DisconnectReason.restartRequired) {
|
||||
restartTask ??= saveQueue.then(async () => {
|
||||
if (closed || generation !== socketGeneration) return;
|
||||
await nextSocket.end(undefined).catch(() => undefined);
|
||||
if (closed || generation !== socketGeneration) return;
|
||||
startSocket();
|
||||
}).catch((error) => settleFailure(error)).finally(() => {
|
||||
restartTask = null;
|
||||
});
|
||||
return;
|
||||
}
|
||||
const loggedOut = status === DisconnectReason.loggedOut;
|
||||
const error = Object.assign(new Error(loggedOut
|
||||
? 'WhatsApp linked device was removed from the phone'
|
||||
: 'WhatsApp Web connection closed'), { code: loggedOut ? 'logged-out' : 'connection-closed' });
|
||||
if (!readySettled) settleFailure(error);
|
||||
else onDisconnect?.({ error, loggedOut });
|
||||
}
|
||||
});
|
||||
nextSocket.ev.on('messages.upsert', ({ messages, type }) => {
|
||||
if (closed || generation !== socketGeneration
|
||||
|| (type !== 'notify' && type !== 'append') || typeof onMessage !== 'function') return;
|
||||
for (const message of Array.isArray(messages) ? messages : []) {
|
||||
if (type === 'append') {
|
||||
const timestamp = messageTimestampMs(message?.messageTimestamp);
|
||||
if (timestamp === null || timestamp < sessionStartedAt - APPEND_RECENT_GRACE_MS) continue;
|
||||
}
|
||||
Promise.resolve(onMessage(message)).catch(() => {
|
||||
logger.error?.('[dsh-im:whatsapp] failed to process an inbound WhatsApp message');
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
startSocket();
|
||||
|
||||
if (signal) {
|
||||
if (signal.aborted) await close();
|
||||
else signal.addEventListener('abort', () => void close(), { once: true });
|
||||
}
|
||||
return Object.freeze({
|
||||
get socket() { return socket; },
|
||||
ready,
|
||||
close,
|
||||
logout,
|
||||
});
|
||||
}
|
||||
57
test/channels/whatsapp/client-ui.test.mjs
Normal file
57
test/channels/whatsapp/client-ui.test.mjs
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
|
||||
import {
|
||||
EmptyView,
|
||||
ProvisionView,
|
||||
QrPanel,
|
||||
WhatsappAccountCard,
|
||||
} from '../../../plugin-src/client/channels/whatsapp/index.js';
|
||||
|
||||
test('WhatsApp onboarding is QR-only with no Cloud API credential form', () => {
|
||||
const empty = renderToStaticMarkup(React.createElement(EmptyView, {}));
|
||||
const qr = renderToStaticMarkup(React.createElement(QrPanel, {
|
||||
provision: {
|
||||
qrCodeDataUrl: 'data:image/png;base64,QUJDRA==',
|
||||
expiresAt: Date.now() + 60_000,
|
||||
durationMs: 60_000,
|
||||
},
|
||||
now: Date.now(),
|
||||
}));
|
||||
assert.match(empty, /扫码绑定 WhatsApp 机器人/);
|
||||
assert.match(empty, /生成二维码/);
|
||||
assert.match(qr, /已关联设备/);
|
||||
assert.match(qr, /关联设备/);
|
||||
assert.doesNotMatch(`${empty}${qr}`, /Cloud API|Phone Number ID|Access Token|App Secret|Verify Token|Webhook/);
|
||||
});
|
||||
|
||||
test('WhatsApp QR startup renders a neutral loading state instead of an error card', () => {
|
||||
const markup = renderToStaticMarkup(React.createElement(ProvisionView, {
|
||||
provision: { status: 'starting' },
|
||||
busy: true,
|
||||
}));
|
||||
assert.match(markup, /正在生成 WhatsApp 二维码/);
|
||||
assert.match(markup, /aria-busy="true"/);
|
||||
assert.doesNotMatch(markup, /WhatsApp 没有接入完成|WHATSAPP_PROVISION_FAILED|ddt-inlineError/);
|
||||
});
|
||||
|
||||
test('WhatsApp account card uses the unified compact channel layout', () => {
|
||||
const markup = renderToStaticMarkup(React.createElement(WhatsappAccountCard, {
|
||||
account: {
|
||||
botId: 'whatsapp-card',
|
||||
state: 'connected',
|
||||
connected: true,
|
||||
bot: { name: 'Harness WhatsApp', idMasked: '1650••••0123' },
|
||||
health: { summary: 'WhatsApp Web 关联设备运行正常', lastCheckedAt: Date.now() },
|
||||
error: null,
|
||||
},
|
||||
}));
|
||||
assert.match(markup, /data-im-channel-logo="whatsapp"/);
|
||||
assert.match(markup, /WhatsApp Web/);
|
||||
assert.match(markup, /检查连接/);
|
||||
assert.match(markup, /移除接入/);
|
||||
assert.equal((markup.match(/class="ddt-metric dim-botMetric"/g) ?? []).length, 2);
|
||||
});
|
||||
355
test/channels/whatsapp/whatsapp.test.mjs
Normal file
355
test/channels/whatsapp/whatsapp.test.mjs
Normal file
|
|
@ -0,0 +1,355 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { mkdtemp, stat } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { DisconnectReason } from '@whiskeysockets/baileys';
|
||||
|
||||
import {
|
||||
WhatsappConfigStore,
|
||||
deriveWhatsappBotId,
|
||||
} from '../../../src/channels/whatsapp/config-store.mjs';
|
||||
import { WhatsappController } from '../../../src/channels/whatsapp/whatsapp-controller.mjs';
|
||||
import {
|
||||
WhatsappRuntime,
|
||||
normalizeWhatsappMessage,
|
||||
} from '../../../src/channels/whatsapp/whatsapp-runtime.mjs';
|
||||
import { createWhatsappWebSession } from '../../../src/channels/whatsapp/whatsapp-web-session.mjs';
|
||||
import {
|
||||
WHATSAPP_ENDPOINTS,
|
||||
createWhatsappRpcHandler,
|
||||
} from '../../../plugin-src/host/channels/whatsapp/rpc.mjs';
|
||||
|
||||
const ACCOUNT_JID = '16505550123@s.whatsapp.net';
|
||||
const AUTH_DIRECTORY = '7fe8c17e-4fb7-4c5b-a9dc-c36525575dd1';
|
||||
|
||||
function linkedConfig(overrides = {}) {
|
||||
return {
|
||||
botId: deriveWhatsappBotId(ACCOUNT_JID),
|
||||
accountJid: ACCOUNT_JID,
|
||||
authDirectory: AUTH_DIRECTORY,
|
||||
name: 'Harness WhatsApp',
|
||||
createdAt: new Date().toISOString(),
|
||||
connectedAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('WhatsApp config stores only linked-device metadata with restrictive permissions', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-im-whatsapp-config-'));
|
||||
const path = join(root, 'config.json');
|
||||
const store = await new WhatsappConfigStore(path).load();
|
||||
await store.save(linkedConfig());
|
||||
assert.equal(store.list()[0].accountJid, ACCOUNT_JID);
|
||||
assert.equal((await stat(path)).mode & 0o777, 0o600);
|
||||
await assert.rejects(() => store.save(linkedConfig({ botId: 'whatsapp_invalid' })));
|
||||
});
|
||||
|
||||
test('WhatsApp Web session reports QR and linked identity without printing either', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-im-whatsapp-session-'));
|
||||
const events = new EventEmitter();
|
||||
let ended = false;
|
||||
const socket = {
|
||||
ev: events,
|
||||
user: { id: '16505550123:4@s.whatsapp.net', name: 'Harness WhatsApp' },
|
||||
end: async () => { ended = true; },
|
||||
logout: async () => {},
|
||||
};
|
||||
const qrValues = [];
|
||||
const session = await createWhatsappWebSession({
|
||||
authDir: root,
|
||||
onQr: (value) => qrValues.push(value),
|
||||
makeSocket: () => socket,
|
||||
loadAuthState: async () => ({
|
||||
state: {
|
||||
creds: { me: socket.user },
|
||||
keys: { get: async () => ({}), set: async () => {} },
|
||||
},
|
||||
saveCreds: async () => {},
|
||||
}),
|
||||
});
|
||||
events.emit('connection.update', { qr: 'host-only-qr-value' });
|
||||
events.emit('connection.update', { connection: 'open' });
|
||||
assert.deepEqual(qrValues, ['host-only-qr-value']);
|
||||
assert.deepEqual(await session.ready, {
|
||||
accountJid: ACCOUNT_JID,
|
||||
name: 'Harness WhatsApp',
|
||||
});
|
||||
assert.equal((await stat(root)).mode & 0o777, 0o700);
|
||||
await session.close();
|
||||
assert.equal(ended, true);
|
||||
});
|
||||
|
||||
test('WhatsApp Web session restarts the socket after first-time QR pairing', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-im-whatsapp-restart-'));
|
||||
const sockets = Array.from({ length: 2 }, (_, index) => ({
|
||||
ev: new EventEmitter(),
|
||||
user: index === 1
|
||||
? { id: '16505550123:4@s.whatsapp.net', name: 'Harness WhatsApp' }
|
||||
: undefined,
|
||||
end: async () => {},
|
||||
logout: async () => {},
|
||||
}));
|
||||
let socketIndex = 0;
|
||||
let saveCount = 0;
|
||||
const authState = {
|
||||
creds: {},
|
||||
keys: { get: async () => ({}), set: async () => {} },
|
||||
};
|
||||
const session = await createWhatsappWebSession({
|
||||
authDir: root,
|
||||
onQr: () => {},
|
||||
makeSocket: () => sockets[socketIndex++],
|
||||
loadAuthState: async () => ({
|
||||
state: authState,
|
||||
saveCreds: async () => { saveCount += 1; },
|
||||
}),
|
||||
});
|
||||
authState.creds.me = sockets[1].user;
|
||||
sockets[0].ev.emit('creds.update', { me: sockets[1].user });
|
||||
sockets[0].ev.emit('connection.update', {
|
||||
connection: 'close',
|
||||
lastDisconnect: { error: { output: { statusCode: DisconnectReason.restartRequired } } },
|
||||
});
|
||||
for (let index = 0; index < 20 && socketIndex < 2; index += 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
assert.equal(socketIndex, 2);
|
||||
assert.equal(saveCount, 1);
|
||||
assert.equal(session.socket, sockets[1]);
|
||||
sockets[1].ev.emit('connection.update', { connection: 'open' });
|
||||
assert.deepEqual(await session.ready, {
|
||||
accountJid: ACCOUNT_JID,
|
||||
name: 'Harness WhatsApp',
|
||||
});
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test('WhatsApp Web session accepts recent append events without replaying stale history', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-im-whatsapp-append-'));
|
||||
const events = new EventEmitter();
|
||||
const received = [];
|
||||
const session = await createWhatsappWebSession({
|
||||
authDir: root,
|
||||
onQr: () => {},
|
||||
onMessage: async (message) => { received.push(message.key.id); },
|
||||
makeSocket: () => ({
|
||||
ev: events,
|
||||
end: async () => {},
|
||||
logout: async () => {},
|
||||
}),
|
||||
loadAuthState: async () => ({
|
||||
state: {
|
||||
creds: {},
|
||||
keys: { get: async () => ({}), set: async () => {} },
|
||||
},
|
||||
saveCreds: async () => {},
|
||||
}),
|
||||
});
|
||||
void session.ready.catch(() => undefined);
|
||||
events.emit('messages.upsert', {
|
||||
type: 'append',
|
||||
messages: [
|
||||
{ key: { id: 'recent' }, messageTimestamp: Math.floor(Date.now() / 1_000) },
|
||||
{ key: { id: 'stale' }, messageTimestamp: Math.floor(Date.now() / 1_000) - 300 },
|
||||
],
|
||||
});
|
||||
events.emit('messages.upsert', {
|
||||
type: 'notify',
|
||||
messages: [{ key: { id: 'notify' } }],
|
||||
});
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.deepEqual(received, ['recent', 'notify']);
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test('WhatsApp normalizes direct and explicitly mentioned group messages', () => {
|
||||
const direct = normalizeWhatsappMessage({
|
||||
key: { remoteJid: '16505550999@s.whatsapp.net', id: 'direct-1', fromMe: false },
|
||||
message: { conversation: 'hello' },
|
||||
}, ACCOUNT_JID);
|
||||
assert.equal(direct.kind, 'direct');
|
||||
assert.equal(direct.addressed, true);
|
||||
assert.equal(direct.content, 'hello');
|
||||
|
||||
const group = normalizeWhatsappMessage({
|
||||
key: {
|
||||
remoteJid: '120363000000000000@g.us',
|
||||
participant: '16505550999@s.whatsapp.net',
|
||||
id: 'group-1',
|
||||
fromMe: false,
|
||||
},
|
||||
message: {
|
||||
extendedTextMessage: {
|
||||
text: 'question',
|
||||
contextInfo: { mentionedJid: [ACCOUNT_JID] },
|
||||
},
|
||||
},
|
||||
}, ACCOUNT_JID);
|
||||
assert.equal(group.kind, 'group');
|
||||
assert.equal(group.addressed, true);
|
||||
assert.equal(normalizeWhatsappMessage({
|
||||
key: { remoteJid: 'status@broadcast', id: 'ignored', fromMe: false },
|
||||
message: { conversation: 'ignored' },
|
||||
}, ACCOUNT_JID), null);
|
||||
|
||||
const selfChat = normalizeWhatsappMessage({
|
||||
key: { remoteJid: ACCOUNT_JID, id: 'self-1', fromMe: true },
|
||||
message: { conversation: 'message yourself' },
|
||||
}, ACCOUNT_JID);
|
||||
assert.equal(selfChat.selfChat, true);
|
||||
assert.equal(selfChat.addressed, true);
|
||||
assert.equal(normalizeWhatsappMessage({
|
||||
key: { remoteJid: '16505550999@s.whatsapp.net', id: 'outbound-1', fromMe: true },
|
||||
message: { conversation: 'ordinary outbound message' },
|
||||
}, ACCOUNT_JID), null);
|
||||
});
|
||||
|
||||
test('WhatsApp runtime connects a linked device and replies through Harness', async () => {
|
||||
let callbacks;
|
||||
const calls = [];
|
||||
const socket = {
|
||||
sendPresenceUpdate: async (...args) => calls.push(['presence', ...args]),
|
||||
readMessages: async () => {},
|
||||
sendMessage: async (jid, content) => {
|
||||
calls.push(['message', jid, content]);
|
||||
return { key: { id: 'reply-1' } };
|
||||
},
|
||||
};
|
||||
const state = {
|
||||
hasSeen: () => false,
|
||||
markSeen: async () => {},
|
||||
sessionFor: () => 'session-1',
|
||||
sessionExists: async () => true,
|
||||
};
|
||||
const harness = {
|
||||
ensureRunning: async () => {},
|
||||
sessionExists: async () => true,
|
||||
ask: async () => 'Harness answer',
|
||||
};
|
||||
const runtime = new WhatsappRuntime({
|
||||
config: linkedConfig(),
|
||||
authDir: '/tmp/test-whatsapp-auth',
|
||||
harness,
|
||||
state,
|
||||
createSession: async (options) => {
|
||||
callbacks = options;
|
||||
return {
|
||||
socket,
|
||||
ready: Promise.resolve({ accountJid: ACCOUNT_JID, name: 'Harness WhatsApp' }),
|
||||
close: async () => {},
|
||||
logout: async () => {},
|
||||
};
|
||||
},
|
||||
});
|
||||
await runtime.start();
|
||||
await callbacks.onMessage({
|
||||
key: { remoteJid: '16505550999@s.whatsapp.net', id: 'direct-2', fromMe: false },
|
||||
message: { conversation: 'hello' },
|
||||
});
|
||||
assert.equal(runtime.status.ready, true);
|
||||
assert.ok(calls.some((call) => call[0] === 'presence' && call[1] === 'composing'));
|
||||
assert.ok(calls.some((call) => call[0] === 'message' && call[2].text === 'Harness answer'));
|
||||
await runtime.stop();
|
||||
});
|
||||
|
||||
test('WhatsApp runtime answers self-chat without processing its own reply echo', async () => {
|
||||
let callbacks;
|
||||
let askCount = 0;
|
||||
const sent = [];
|
||||
const socket = {
|
||||
sendPresenceUpdate: async () => {},
|
||||
readMessages: async () => { throw new Error('self-chat must not send a read receipt'); },
|
||||
sendMessage: async (jid, content) => {
|
||||
sent.push([jid, content]);
|
||||
return { key: { id: 'bot-reply-1' } };
|
||||
},
|
||||
};
|
||||
const runtime = new WhatsappRuntime({
|
||||
config: linkedConfig(),
|
||||
authDir: '/tmp/test-whatsapp-self-chat',
|
||||
harness: {
|
||||
ensureRunning: async () => {},
|
||||
sessionExists: async () => true,
|
||||
ask: async () => { askCount += 1; return 'Harness self-chat answer'; },
|
||||
},
|
||||
state: {
|
||||
hasSeen: () => false,
|
||||
markSeen: async () => {},
|
||||
sessionFor: () => 'session-self',
|
||||
sessionExists: async () => true,
|
||||
},
|
||||
createSession: async (options) => {
|
||||
callbacks = options;
|
||||
return {
|
||||
socket,
|
||||
ready: Promise.resolve({ accountJid: ACCOUNT_JID, name: 'Harness WhatsApp' }),
|
||||
close: async () => {},
|
||||
logout: async () => {},
|
||||
};
|
||||
},
|
||||
});
|
||||
await runtime.start();
|
||||
await callbacks.onMessage({
|
||||
key: { remoteJid: ACCOUNT_JID, id: 'owner-message-1', fromMe: true },
|
||||
message: { conversation: 'hello from message yourself' },
|
||||
});
|
||||
await callbacks.onMessage({
|
||||
key: { remoteJid: ACCOUNT_JID, id: 'bot-reply-1', fromMe: true },
|
||||
message: { conversation: 'Harness self-chat answer' },
|
||||
});
|
||||
assert.equal(askCount, 1);
|
||||
assert.deepEqual(sent, [[ACCOUNT_JID, { text: 'Harness self-chat answer' }]]);
|
||||
await runtime.stop();
|
||||
});
|
||||
|
||||
test('WhatsApp QR controller and RPC keep the raw QR and linked identity host-only', async (t) => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-im-whatsapp-controller-'));
|
||||
const configStore = await new WhatsappConfigStore(join(root, 'config.json')).load();
|
||||
let sessionOptions;
|
||||
let resolveReady;
|
||||
const ready = new Promise((resolve) => { resolveReady = resolve; });
|
||||
const deletedAuth = [];
|
||||
const controller = new WhatsappController({
|
||||
configStore,
|
||||
authPath: (name) => join(root, 'auth', name),
|
||||
createSession: async (options) => {
|
||||
sessionOptions = options;
|
||||
queueMicrotask(() => options.onQr('raw-linked-device-qr'));
|
||||
return { ready, close: async () => {} };
|
||||
},
|
||||
createRuntime: async () => ({
|
||||
status: {
|
||||
ready: true,
|
||||
connectionState: 'connected',
|
||||
harnessReachable: true,
|
||||
lastCheckedAt: Date.now(),
|
||||
},
|
||||
start: async () => {},
|
||||
stop: async () => {},
|
||||
}),
|
||||
deleteAuth: async (name) => deletedAuth.push(name),
|
||||
});
|
||||
t.after(() => controller.close());
|
||||
const handler = createWhatsappRpcHandler(controller, {
|
||||
encodeQr: async () => 'data:image/png;base64,QUJDRA==',
|
||||
});
|
||||
const started = await handler(WHATSAPP_ENDPOINTS.beginProvisioning, {});
|
||||
assert.equal(started.ok, true);
|
||||
assert.match(started.value.qrCodeDataUrl, /^data:image\/png/);
|
||||
assert.doesNotMatch(JSON.stringify(started.value), /raw-linked-device-qr|accountJid|authDirectory/);
|
||||
resolveReady({ accountJid: ACCOUNT_JID, name: 'Harness WhatsApp' });
|
||||
let status;
|
||||
for (let index = 0; index < 20; index += 1) {
|
||||
status = await handler(WHATSAPP_ENDPOINTS.status, {});
|
||||
if (status.value?.bots?.length) break;
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
assert.equal(status.ok, true);
|
||||
assert.equal(status.value.bots[0].connected, true);
|
||||
assert.doesNotMatch(JSON.stringify(status.value), /16505550123@s\.whatsapp\.net|authDirectory/);
|
||||
assert.equal(sessionOptions.signal.aborted, false);
|
||||
assert.deepEqual(deletedAuth, []);
|
||||
});
|
||||
|
|
@ -65,7 +65,7 @@ const QQ_SOURCE_URL = new URL(
|
|||
import.meta.url,
|
||||
);
|
||||
|
||||
test('IM settings renders seven compact logo channel tabs without enable switches', async () => {
|
||||
test('IM settings renders eight compact logo channel tabs without enable switches', async () => {
|
||||
const styles = await readFile(STYLES_URL, 'utf8');
|
||||
const markup = renderToStaticMarkup(React.createElement(IMSettingsTab, {
|
||||
feishuRpcCall: async () => ({ ok: true, value: {} }),
|
||||
|
|
@ -75,6 +75,7 @@ test('IM settings renders seven compact logo channel tabs without enable switche
|
|||
qqRpcCall: async () => ({ ok: true, value: {} }),
|
||||
telegramRpcCall: async () => ({ ok: true, value: {} }),
|
||||
discordRpcCall: async () => ({ ok: true, value: {} }),
|
||||
whatsappRpcCall: async () => ({ ok: true, value: {} }),
|
||||
}));
|
||||
|
||||
assert.match(markup, /IM机器人/);
|
||||
|
|
@ -87,6 +88,7 @@ test('IM settings renders seven compact logo channel tabs without enable switche
|
|||
assert.match(markup, />QQ</);
|
||||
assert.match(markup, />Telegram</);
|
||||
assert.match(markup, />Discord</);
|
||||
assert.match(markup, />WhatsApp</);
|
||||
assert.match(markup, /dim-logoWeixin/);
|
||||
assert.match(markup, /dim-logoFeishu/);
|
||||
assert.match(markup, /dim-logoDingtalk/);
|
||||
|
|
@ -94,8 +96,9 @@ test('IM settings renders seven compact logo channel tabs without enable switche
|
|||
assert.match(markup, /dim-logoQq/);
|
||||
assert.match(markup, /dim-logoTelegram/);
|
||||
assert.match(markup, /dim-logoDiscord/);
|
||||
assert.match(markup, /dim-logoWhatsapp/);
|
||||
assert.match(styles, /\.dim-logoFeishu svg \{ width: 28px; height: 28px; \}/);
|
||||
assert.equal((markup.match(/role="tab"/g) ?? []).length, 7);
|
||||
assert.equal((markup.match(/role="tab"/g) ?? []).length, 8);
|
||||
assert.equal((markup.match(/aria-selected="true"/g) ?? []).length, 1);
|
||||
assert.doesNotMatch(markup, /role="switch"|type="checkbox"/);
|
||||
assert.doesNotMatch(markup, /dim-chevron|扫码绑定<\/small>|扫码接入<\/small>/);
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import test from 'node:test';
|
|||
|
||||
import { createImHostPlugin, inject, name } from '../plugin-src/host/index.mjs';
|
||||
|
||||
test('Host composes all seven IM channels inside one plugin context', async () => {
|
||||
test('Host composes all eight IM channels inside one plugin context', async () => {
|
||||
const calls = [];
|
||||
const plugin = createImHostPlugin({
|
||||
applyFeishu: async (ctx, config) => calls.push(['feishu', ctx, config]),
|
||||
|
|
@ -13,6 +13,7 @@ test('Host composes all seven IM channels inside one plugin context', async () =
|
|||
applyQq: async (ctx, config) => calls.push(['qq', ctx, config]),
|
||||
applyTelegram: async (ctx, config) => calls.push(['telegram', ctx, config]),
|
||||
applyDiscord: async (ctx, config) => calls.push(['discord', ctx, config]),
|
||||
applyWhatsapp: async (ctx, config) => calls.push(['whatsapp', ctx, config]),
|
||||
});
|
||||
const ctx = { marker: 'shared-context' };
|
||||
const config = {
|
||||
|
|
@ -24,6 +25,7 @@ test('Host composes all seven IM channels inside one plugin context', async () =
|
|||
qq: { replyTimeoutMs: 60_000 },
|
||||
telegram: { replyTimeoutMs: 60_000 },
|
||||
discord: { replyTimeoutMs: 60_000 },
|
||||
whatsapp: { replyTimeoutMs: 60_000 },
|
||||
};
|
||||
|
||||
await plugin.apply(ctx, config);
|
||||
|
|
@ -38,6 +40,7 @@ test('Host composes all seven IM channels inside one plugin context', async () =
|
|||
['qq', ctx, { ...config.qq, rpcAuthority: 'trusted-host' }],
|
||||
['telegram', ctx, { ...config.telegram, rpcAuthority: 'trusted-host' }],
|
||||
['discord', ctx, { ...config.discord, rpcAuthority: 'trusted-host' }],
|
||||
['whatsapp', ctx, { ...config.whatsapp, rpcAuthority: 'trusted-host' }],
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue