feat(whatsapp): add access modes

This commit is contained in:
xmanrui 2026-08-23 21:28:50 +08:00
parent b0e2c266c5
commit 9b65dd3d6b
15 changed files with 860 additions and 162 deletions

View file

@ -6,6 +6,11 @@ This file records the notable changes in each dsh-im release. Its format follows
## [Unreleased]
### Added / 新增
- WhatsApp 新增仅自己、指定联系人和开放响应三种访问模式;旧机器人和新接入机器人均默认仅响应账号自聊。
Added Only me, Selected contacts, and Open responses access modes for WhatsApp; existing and newly linked bots now default to self-chat only.
## [1.1.0] - 2026-08-23
### Added / 新增

View file

@ -53,7 +53,7 @@ Connect IM bots to DeepSeek Harness by scanning a QR code, using an App Manifest
| Slack | Create an app from the bundled App Manifest, then enter a Bot Token (`xoxb-`) and App Token (`xapp-`) | Socket Mode connection; direct DM replies, mention-only channel replies, and preferred native streaming API |
| Telegram | Enter a Bot Token generated by @BotFather | Bot API long polling; DMs work by default and groups respond to mentions or replies, while each bot can optionally enable a private-DM allowlist; streaming uses message edits |
| Discord | Enter a Bot Token generated in the Developer Portal | Gateway v10 connection; direct DM replies, mention-only server replies, and streaming through message edits |
| WhatsApp | Scan a QR code with mobile WhatsApp to link a device | WhatsApp Web connection; read receipt and typing indicator followed by the final answer |
| WhatsApp | Scan a QR code with mobile WhatsApp to link a device | WhatsApp Web connection; self-chat only by default, with optional selected-contact and open-response modes; read receipt and typing indicator followed by the final answer |
Other IM platforms can be added through the same channel-adapter structure.
@ -116,6 +116,8 @@ If this machine must use a forward proxy to reach Feishu, set `HTTPS_PROXY` to a
Each Telegram bot has its own access-mode control on its bot card. Existing and newly connected bots both default to **Compatible mode**: DMs receive replies, while group messages require a mention of or reply to the bot. Restrictions apply only after explicitly switching that bot to **Safe mode (private-chat allowlist)**. Safe mode ignores every group message and admits only numeric User IDs in that bot's allowlist. Enter one ID per line. Switching back to Compatible mode retains the allowlist without enforcing it, so it is available when Safe mode is enabled again. An empty allowlist in Safe mode rejects all inbound messages for that bot.
Each WhatsApp bot also has its own access mode. Existing bots migrate to **Only me**, which is also the default for newly linked bots and accepts only self-chat messages from the linked account. **Selected contacts** additionally accepts direct messages from allowlisted phone numbers and ignores groups. Enter one number with its country or region code per line; a leading `+` is optional. **Open responses** preserves the previous behavior: all direct messages are accepted, together with group mentions or replies. Switching modes retains the allowlist. An empty Selected contacts allowlist behaves like Only me, and rejected messages are ignored silently.
## Bot commands
| Command | Description |
@ -166,7 +168,7 @@ If the Slack desktop app has no native Slash Command registered with the same na
- `/session` accepts exactly one Session ID obtained from `/sessionlist`. It neither creates a session nor immediately prompts the model; later messages in the current chat continue the bound session. Regular archived sessions can be bound without being unarchived, while subagent sessions cannot be bound.
- `/session` locates the session's unique workspace automatically. Binding inside the current workspace replaces only this chat's mapping. A cross-workspace binding switches the bot workspace, clears the old session mappings for all of that bot's chats, and then binds this chat, so it affects the bot's other chats. A reply already being generated may still finish.
- Workspace switches and session bindings only clear or replace dsh-im chat mappings. They never delete, empty, or archive old Session contents; an old Session can still be listed and bound again.
- Any user within the platform bot's visibility scope who can normally message it can run these commands; there is no separate administrator role. Telegram Compatible mode follows the original DM and group mention/reply rules. Safe mode admits only private users in that bot's allowlist and always ignores group commands.
- Any user admitted by the current channel access policy can run these commands; there is no separate administrator role. Telegram Compatible mode follows the original DM and group mention/reply rules, while Safe mode admits only allowlisted private users. WhatsApp Only me accepts self-chat only, Selected contacts accepts self-chat plus allowlisted direct messages, and Open responses accepts every direct message plus group mentions or replies.
- Agent Preset names and IDs come from the same Harness Host, and any command-authorized user can change the Preset used by all future new Sessions across this bot's chats. Expose `/presetlist` and `/preset` only to trusted users.
- The list comes from the Harness Host's global registry and can include local absolute paths for other bots, other channels, or non-IM projects. Restrict the bot's visibility to trusted users.
- Session results also come from the global Harness Host. Session IDs and titles can belong to other bots, other channels, or non-IM projects, and may contain sensitive metadata. Enable these commands only when every user in the bot's visibility scope is trusted.
@ -191,7 +193,7 @@ If the Slack desktop app has no native Slash Command registered with the same na
- Follows the DeepSeek Harness language preference and switches the settings UI live between Chinese and English.
- Uses logos for WeChat, Feishu, DingTalk, WeCom, QQ, Slack, Telegram, Discord, WhatsApp, and AI Office navigation without enable/disable switches.
- Keeps RPC endpoints, credentials, connection supervision, and session mappings isolated by IM channel; the Office Connector separately owns Device credentials, Job leases, approval waits, and concurrency limits.
- Returns only QR codes, the public Slack Manifest, redacted status data, and the access mode and allowlist User IDs that the user explicitly saved for the current Telegram bot. Manually entered secrets and Tokens travel one way to the local Host; no RPC response returns App Secrets, `bot_token`, DingTalk `client_secret`, WeCom Secrets, QQ `app_secret`, Slack Bot/App Tokens, Telegram/Discord Bot Tokens, WhatsApp linked-device keys, AI Office Device Tokens, or other raw user identifiers observed from platform messages.
- Returns only QR codes, the public Slack Manifest, redacted status data, and access modes or allowlist identifiers explicitly saved for the current Telegram or WhatsApp bot. Manually entered secrets and Tokens travel one way to the local Host; no RPC response returns App Secrets, `bot_token`, DingTalk `client_secret`, WeCom Secrets, QQ `app_secret`, Slack Bot/App Tokens, Telegram/Discord Bot Tokens, WhatsApp linked-device keys, AI Office Device Tokens, or other raw user identifiers observed from platform messages.
## Local development

View file

@ -56,7 +56,7 @@ Connect IM bots to DeepSeek Harness by scanning a QR code, using an App Manifest
| Slack | 使用预置 App Manifest 创建应用,再填写 Bot Token(`xoxb-`)和 App Token(`xapp-`) | Socket Mode 长连接;私聊直接回复,频道被 @ 后响应,优先使用官方流式消息 API |
| Telegram | 使用 @BotFather 生成的 Bot Token | Bot API 长轮询;默认私聊直接响应、群聊被提及或回复时响应,也可为每个机器人独立启用私聊白名单安全模式;通过编辑消息流式显示回答 |
| Discord | 使用 Developer Portal 生成的 Bot Token | Gateway v10 长连接;私信直接回复,服务器频道被提及时响应,通过编辑消息流式显示回答 |
| WhatsApp | 使用手机 WhatsApp 扫码关联设备 | WhatsApp Web 长连接;显示已读和“正在输入”,再发送最终回答 |
| WhatsApp | 使用手机 WhatsApp 扫码关联设备 | WhatsApp Web 长连接;默认仅响应账号自聊,也可切换到指定联系人或开放响应模式;显示已读和“正在输入”,再发送最终回答 |
其他 IM 平台可继续按同一渠道适配器结构接入。
@ -119,6 +119,8 @@ GitHub 源安装会直接拉取并构建 Git 依赖;pnpm 10 及以上版本可
每个 Telegram 机器人都可以在自己的卡片中切换访问模式。旧机器人和新接入机器人均默认使用**兼容模式**:私聊直接响应,群聊仅在提及机器人或回复机器人消息时响应。只有主动切换到**安全模式(私聊白名单)**后,机器人才会忽略全部群聊,并只接受该机器人白名单中的数字 User ID。白名单每行一个 ID、按机器人独立保存;切回兼容模式时会保留但不使用,再切回安全模式即可继续使用。安全模式的空白名单会拒绝该机器人的所有入站消息。
每个 WhatsApp 机器人也有独立的访问模式。旧机器人升级后和新接入机器人都默认使用**仅自己模式**,只响应已绑定账号的自聊消息。**指定联系人模式**额外接受白名单电话号码的私聊并忽略群聊;号码需包含国家或地区代码,每行一个,可带开头的 `+`。**开放响应模式**保留原有行为:响应所有私聊,以及群聊中的提及或回复。切换模式会保留白名单;指定联系人模式的空白名单等同于仅自己模式。未授权消息会被静默忽略。
## 机器人命令
| 命令 | 作用 |
@ -169,7 +171,7 @@ Slack 桌面端若未注册同名的原生 Slash Command,会拦截直接以 `/
- `/session` 只接受一个由 `/sessionlist` 获得的 Session ID。它不会新建会话或立即向模型发送消息;绑定成功后,当前聊天的后续消息会继续该会话。普通归档会话可以绑定但不会自动取消归档,子代理会话不能绑定。
- `/session` 会自动定位会话唯一所属的工作区。同工作区绑定只替换当前聊天的映射;跨工作区绑定会切换该机器人的工作区、清除该机器人所有聊天的旧会话映射,再绑定当前聊天,因此会影响该机器人的其他聊天。已经开始生成的回复仍可完成。
- 工作区切换和会话绑定只会清除或替换 dsh-im 的聊天映射,不会删除、清空或归档任何旧 Session 内容;旧 Session 仍可再次列出和绑定。
- 任何已在对应平台可见范围内、能够正常向机器人发消息的用户都可以执行这些命令,不区分管理员和普通用户。Telegram 兼容模式遵循原有私聊及群聊提及/回复规则;安全模式只允许当前机器人白名单中的私聊用户执行,群聊命令始终忽略。
- 任何通过当前渠道访问策略的用户都可以执行这些命令,不另行区分管理员和普通用户。Telegram 兼容模式遵循原有私聊及群聊提及/回复规则;安全模式只允许当前机器人白名单中的私聊用户执行。WhatsApp 仅自己模式只接受自聊,指定联系人模式接受自聊和白名单私聊,开放响应模式接受所有私聊及群聊中的提及或回复。
- Agent Preset 名称和 ID 来自同一个 Harness Host,且任何有命令权限的用户都能修改该机器人所有聊天未来新 Session 的 Preset;请只向可信用户开放 `/presetlist` 和 `/preset`。
- 工作区列表来自 Harness Host 的全局登记信息,可能包含其他机器人、其他渠道或非 IM 项目的本机绝对路径。请将机器人可见范围限制给可信用户。
- 会话列表同样来自该全局 Harness Host;会话 ID 和标题可能属于其他机器人、其他渠道或非 IM 项目,并可能包含敏感元数据。开放命令前请确保所有可见用户都可信。
@ -194,7 +196,7 @@ Slack 桌面端若未注册同名的原生 Slash Command,会拦截直接以 `/
- 设置页跟随 DeepSeek Harness 的语言选择,在中文和 English 之间即时切换;
- 左侧使用 Logo 切换微信、飞书、钉钉、企业微信、QQ、Slack、Telegram、Discord、WhatsApp 和 AI Office,不使用启用/停用开关;
- 九个 IM 渠道保持独立的 RPC、凭据、连接监督和会话映射;Office Connector 另行维护设备凭据、Job 租约、审批等待与并发上限;
- 浏览器只获得二维码、Manifest、脱敏状态,以及用户为当前 Telegram 机器人主动保存的访问模式和白名单 User ID;手动输入的 Secret 或 Token 仅单向提交给本机 Host,任何 RPC 响应都不会返回 App Secret、`bot_token`、钉钉 `client_secret`、企业微信 Secret、QQ `app_secret`、Slack Bot/App Token、Telegram/Discord Bot Token、WhatsApp 关联设备密钥、AI Office Device Token,或从平台消息中观察到的其他原始用户标识。
- 浏览器只获得二维码、Manifest、脱敏状态,以及用户为当前 Telegram 或 WhatsApp 机器人主动保存的访问模式和白名单标识;手动输入的 Secret 或 Token 仅单向提交给本机 Host,任何 RPC 响应都不会返回 App Secret、`bot_token`、钉钉 `client_secret`、企业微信 Secret、QQ `app_secret`、Slack Bot/App Token、Telegram/Discord Bot Token、WhatsApp 关联设备密钥、AI Office Device Token,或从平台消息中观察到的其他原始用户标识。
## 本地开发

View file

@ -550,6 +550,24 @@ var EN = Object.freeze({
"\u6B63\u5728\u5EFA\u7ACB\u5B89\u5168\u7684\u5173\u8054\u8BBE\u5907\u4F1A\u8BDD\u3002": "Creating a secure linked-device session.",
"\u5173\u8054\u8BBE\u5907\u6B63\u5728\u63A5\u5165 DeepSeek Harness\u3002": "Linking the device to DeepSeek Harness.",
"WhatsApp Web \u5173\u8054\u8BBE\u5907\u8FD0\u884C\u6B63\u5E38": "WhatsApp linked device is healthy",
"\u67E5\u770B WhatsApp \u8BBF\u95EE\u6A21\u5F0F\u8BF4\u660E": "View WhatsApp access mode details",
"WhatsApp \u8BBF\u95EE\u6A21\u5F0F": "WhatsApp access mode",
"\u4EC5\u81EA\u5DF1\u6A21\u5F0F": "Only me",
"\u6307\u5B9A\u8054\u7CFB\u4EBA\u6A21\u5F0F": "Selected contacts",
"\u5F00\u653E\u54CD\u5E94\u6A21\u5F0F": "Open responses",
"\u4EC5\u81EA\u5DF1\u6A21\u5F0F\uFF08\u9ED8\u8BA4\uFF09": "Only me (default)",
"\u5DF2\u751F\u6548\uFF1A": "Active: ",
"\u53EA\u54CD\u5E94\u5DF2\u7ED1\u5B9A WhatsApp \u8D26\u53F7\u7684\u81EA\u804A\u6D88\u606F\u3002": "Only respond to self-chat messages from the linked WhatsApp account.",
"\u54CD\u5E94\u81EA\u804A\u548C\u767D\u540D\u5355\u8054\u7CFB\u4EBA\u7684\u79C1\u804A\uFF0C\u5FFD\u7565\u7FA4\u804A\u3002": "Respond to self-chat and allowlisted direct messages; ignore group messages.",
"\u54CD\u5E94\u6240\u6709\u79C1\u804A\uFF0C\u4EE5\u53CA\u7FA4\u804A\u4E2D\u7684\u63D0\u53CA\u6216\u56DE\u590D\u3002": "Respond to all direct messages and to group mentions or replies.",
"\u5141\u8BB8\u79C1\u804A\u7684 WhatsApp \u7535\u8BDD\u53F7\u7801": "WhatsApp phone numbers allowed to send direct messages",
"\u6BCF\u884C\u4E00\u4E2A\u542B\u56FD\u5BB6\u6216\u5730\u533A\u4EE3\u7801\u7684\u53F7\u7801": "One number with country or region code per line",
"\u53EF\u4EE5\u5305\u542B\u5F00\u5934\u7684 +\uFF0C\u4FDD\u5B58\u65F6\u4F1A\u81EA\u52A8\u79FB\u9664\u3002": "A leading + is allowed and removed when saved.",
"\u4EC5\u6307\u5B9A\u8054\u7CFB\u4EBA\u6A21\u5F0F\u4F7F\u7528\u767D\u540D\u5355\uFF0C\u5207\u6362\u6A21\u5F0F\u65F6\u4F1A\u4FDD\u7559\u3002": "Only Selected contacts uses the allowlist; it is retained when modes change.",
"\u767D\u540D\u5355\u4E3A\u7A7A\uFF1B\u4FDD\u5B58\u540E\u5C06\u53EA\u63A5\u53D7\u81EA\u804A\u6D88\u606F\u3002": "The allowlist is empty; only self-chat messages will be accepted after saving.",
"\u7535\u8BDD\u53F7\u7801\u5FC5\u987B\u5305\u542B\u56FD\u5BB6\u6216\u5730\u533A\u4EE3\u7801\uFF0C\u6BCF\u884C\u4E00\u4E2A\u3002": "Each phone number must include a country or region code on its own line.",
"WhatsApp \u8BBF\u95EE\u8BBE\u7F6E\u6682\u4E0D\u53EF\u7528\u3002": "WhatsApp access settings are currently unavailable.",
"WhatsApp \u8BBF\u95EE\u8BBE\u7F6E\u4FDD\u5B58\u5931\u8D25\u3002": "Could not save WhatsApp access settings.",
"Bot API \u957F\u8F6E\u8BE2": "Bot API long polling",
" Gateway \u957F\u8FDE\u63A5": " Gateway persistent connection",
"Gateway \u957F\u8FDE\u63A5": "Gateway persistent connection",
@ -9075,6 +9093,7 @@ var WHATSAPP_ENDPOINTS = Object.freeze({
cancelProvisioning: "provision.cancel",
reconnectBot: "bot.reconnect",
deleteBot: "bot.delete",
setAccessPolicy: "bot.access-policy.set",
setWorkspace: "bot.workspace.set",
setAgentPreset: SET_AGENT_PRESET_ENDPOINT
});
@ -9142,6 +9161,12 @@ function normalizeBot6(value) {
state: connected ? "connected" : state,
workspace: text4(value.workspace, "", 4096),
agentPreset: normalizeAgentPresetId(value.agentPreset),
accessPolicy: {
accessMode: ["self-only", "private-allowlist", "open"].includes(
value.accessPolicy?.accessMode
) ? value.accessPolicy.accessMode : "self-only",
allowedNumbers: Array.isArray(value.accessPolicy?.allowedNumbers) ? [...new Set(value.accessPolicy.allowedNumbers.filter((entry) => typeof entry === "string" && /^[1-9]\d{4,14}$/.test(entry)))] : []
},
bot: {
name: text4(value.bot?.name, "WhatsApp\u673A\u5668\u4EBA", 100),
idMasked: text4(value.bot?.idMasked, "WhatsApp\u8D26\u53F7", 140)
@ -9190,6 +9215,31 @@ var CSS10 = 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; }
.dwa-access { display: grid; gap: 10px; padding: 12px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 10px; background: var(--dsw-alias-bg-layer-2, #f7f8fa); }
.dwa-accessHeading { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.dwa-accessHeading > strong { font-size: 13px; }
.dwa-accessStatus { min-width: 0; display: inline-flex; align-items: center; justify-content: flex-end; gap: 6px; }
.dwa-accessBadge { flex: none; padding: 3px 8px; border-radius: 999px; color: #08785f; background: #eafbf0; font-size: 11px; font-weight: 700; }
.dwa-accessBadge[data-mode="private-allowlist"] { color: #0f6f8f; background: #eaf7fd; }
.dwa-accessBadge[data-mode="open"] { color: #a15c00; background: #fff3d6; }
.dwa-accessHelp { position: relative; display: inline-flex; flex: none; }
.dwa-accessHelpButton { width: 20px; height: 20px; display: grid; place-items: center; padding: 0; border: 1px solid color-mix(in srgb, #25d366 34%, var(--dsw-alias-border-l2, #dfe1e5)); border-radius: 50%; color: #128c7e; background: var(--dsw-alias-bg-layer-1, #fff); font: inherit; font-size: 12px; line-height: 1; font-weight: 750; cursor: help; }
.dwa-accessTooltip { position: absolute; top: calc(100% + 8px); right: 0; z-index: 30; width: 270px; max-width: min(290px, calc(100vw - 48px)); display: grid; gap: 8px; padding: 10px 11px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 9px; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-bg-layer-3, #fff); box-shadow: 0 10px 28px rgb(31 35 41 / 16%); opacity: 0; visibility: hidden; transform: translateY(-3px); pointer-events: none; transition: opacity .15s ease, transform .15s ease, visibility .15s ease; }
.dwa-accessTooltipItem { display: grid; gap: 2px; }
.dwa-accessTooltipItem + .dwa-accessTooltipItem { padding-top: 8px; border-top: 1px solid var(--dsw-alias-border-l2, #eef0f3); }
.dwa-accessTooltipItem strong { font-size: 12px; line-height: 17px; }
.dwa-accessTooltipItem > span { color: var(--dsw-alias-label-secondary, #646a73); font-size: 11px; line-height: 16px; }
.dwa-accessHelp:hover .dwa-accessTooltip, .dwa-accessHelp:focus-within .dwa-accessTooltip { opacity: 1; visibility: visible; transform: translateY(0); }
.dwa-accessField { display: grid; gap: 5px; color: var(--dsw-alias-label-primary, #1f2329); font-size: 12px; font-weight: 600; }
.dwa-accessField select, .dwa-accessField textarea { width: 100%; box-sizing: border-box; border: 1px solid var(--dsw-alias-border-l1, #c9cdd4); border-radius: 7px; color: inherit; background: var(--dsw-alias-bg-layer-1, #fff); font: inherit; font-weight: 400; }
.dwa-accessField select { height: 34px; padding: 0 9px; }
.dwa-accessField textarea { min-height: 68px; padding: 8px 9px; resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.dwa-accessField textarea:disabled { color: var(--dsw-alias-label-tertiary, #8f959e); background: var(--dsw-alias-bg-module-platform, #f2f3f5); cursor: not-allowed; resize: none; opacity: 1; }
.dwa-accessField small { color: var(--dsw-alias-label-secondary, #646a73); font-weight: 400; }
.dwa-accessWarning, .dwa-accessError { margin: 0; font-size: 12px; line-height: 1.5; }
.dwa-accessWarning { color: #a15c00; }
.dwa-accessError { color: var(--dsw-alias-state-error-primary, #d83931); }
.dwa-accessActions { display: flex; justify-content: flex-end; }
`;
function installWhatsappStyles() {
if (typeof document === "undefined") return () => {
@ -9207,6 +9257,156 @@ function installWhatsappStyles() {
// plugin-src/client/channels/whatsapp/index.js
var ACTIVE_STATES3 = /* @__PURE__ */ new Set(["pending", "connecting"]);
function accessPolicyFor(account) {
const accessMode = ["self-only", "private-allowlist", "open"].includes(
account?.accessPolicy?.accessMode
) ? account.accessPolicy.accessMode : "self-only";
return {
accessMode,
allowedNumbers: Array.isArray(account?.accessPolicy?.allowedNumbers) ? account.accessPolicy.allowedNumbers : []
};
}
function allowedNumbersFromText(value) {
const entries = value.split(/\r?\n/).map((entry) => entry.trim()).filter(Boolean);
const normalized = entries.map((entry) => entry.replace(/^\+/, ""));
if (normalized.some((entry) => !/^[1-9]\d{4,14}$/.test(entry))) {
throw new TypeError("\u7535\u8BDD\u53F7\u7801\u5FC5\u987B\u5305\u542B\u56FD\u5BB6\u6216\u5730\u533A\u4EE3\u7801\uFF0C\u6BCF\u884C\u4E00\u4E2A\u3002");
}
return [...new Set(normalized)];
}
function WhatsappAccessSettings({ account, busy = false, onSave }) {
const policy = accessPolicyFor(account);
const sourceNumbers = policy.allowedNumbers.join("\n");
const helpId = React19.useId();
const [accessMode, setAccessMode] = React19.useState(policy.accessMode);
const [allowedNumbers, setAllowedNumbers] = React19.useState(sourceNumbers);
const [error, setError] = React19.useState(null);
React19.useEffect(() => {
setAccessMode(policy.accessMode);
setAllowedNumbers(sourceNumbers);
setError(null);
}, [policy.accessMode, sourceNumbers]);
const save = async (event) => {
event.preventDefault();
setError(null);
try {
const normalized = allowedNumbersFromText(allowedNumbers);
if (typeof onSave !== "function") throw new Error("WhatsApp \u8BBF\u95EE\u8BBE\u7F6E\u6682\u4E0D\u53EF\u7528\u3002");
await onSave({ accessMode, allowedNumbers: normalized });
} catch (caught) {
setError(caught?.message ?? "WhatsApp \u8BBF\u95EE\u8BBE\u7F6E\u4FDD\u5B58\u5931\u8D25\u3002");
}
};
const allowlistEnabled = accessMode === "private-allowlist";
const labels = {
"self-only": "\u4EC5\u81EA\u5DF1\u6A21\u5F0F",
"private-allowlist": "\u6307\u5B9A\u8054\u7CFB\u4EBA\u6A21\u5F0F",
open: "\u5F00\u653E\u54CD\u5E94\u6A21\u5F0F"
};
return h2(
"form",
{ className: "dwa-access", onSubmit: save },
h2(
"div",
{ className: "dwa-accessHeading" },
h2("strong", null, "\u8BBF\u95EE\u8BBE\u7F6E"),
h2(
"span",
{ className: "dwa-accessStatus" },
h2(
"span",
{ className: "dwa-accessBadge", "data-mode": policy.accessMode },
["\u5DF2\u751F\u6548\uFF1A", labels[policy.accessMode]]
),
h2(
"span",
{ className: "dwa-accessHelp" },
h2("button", {
type: "button",
className: "dwa-accessHelpButton",
"aria-label": "\u67E5\u770B WhatsApp \u8BBF\u95EE\u6A21\u5F0F\u8BF4\u660E",
"aria-describedby": helpId
}, h2("span", { "aria-hidden": "true" }, "?")),
h2(
"span",
{ id: helpId, className: "dwa-accessTooltip", role: "tooltip" },
h2(
"span",
{ className: "dwa-accessTooltipItem" },
h2("strong", null, "\u4EC5\u81EA\u5DF1\u6A21\u5F0F"),
h2("span", null, "\u53EA\u54CD\u5E94\u5DF2\u7ED1\u5B9A WhatsApp \u8D26\u53F7\u7684\u81EA\u804A\u6D88\u606F\u3002")
),
h2(
"span",
{ className: "dwa-accessTooltipItem" },
h2("strong", null, "\u6307\u5B9A\u8054\u7CFB\u4EBA\u6A21\u5F0F"),
h2("span", null, "\u54CD\u5E94\u81EA\u804A\u548C\u767D\u540D\u5355\u8054\u7CFB\u4EBA\u7684\u79C1\u804A\uFF0C\u5FFD\u7565\u7FA4\u804A\u3002")
),
h2(
"span",
{ className: "dwa-accessTooltipItem" },
h2("strong", null, "\u5F00\u653E\u54CD\u5E94\u6A21\u5F0F"),
h2("span", null, "\u54CD\u5E94\u6240\u6709\u79C1\u804A\uFF0C\u4EE5\u53CA\u7FA4\u804A\u4E2D\u7684\u63D0\u53CA\u6216\u56DE\u590D\u3002")
)
)
)
)
),
h2(
"label",
{ className: "dwa-accessField" },
h2("span", null, "\u6A21\u5F0F"),
h2(
"select",
{
value: accessMode,
disabled: busy,
"aria-label": "WhatsApp \u8BBF\u95EE\u6A21\u5F0F",
onChange: (event) => {
setAccessMode(event.target.value);
setError(null);
}
},
h2("option", { value: "self-only" }, "\u4EC5\u81EA\u5DF1\u6A21\u5F0F\uFF08\u9ED8\u8BA4\uFF09"),
h2("option", { value: "private-allowlist" }, "\u6307\u5B9A\u8054\u7CFB\u4EBA\u6A21\u5F0F"),
h2("option", { value: "open" }, "\u5F00\u653E\u54CD\u5E94\u6A21\u5F0F")
)
),
allowlistEnabled ? h2(
"label",
{ className: "dwa-accessField" },
h2("span", null, "\u5141\u8BB8\u79C1\u804A\u7684 WhatsApp \u7535\u8BDD\u53F7\u7801"),
h2("textarea", {
value: allowedNumbers,
disabled: busy,
rows: 3,
placeholder: "\u6BCF\u884C\u4E00\u4E2A\u542B\u56FD\u5BB6\u6216\u5730\u533A\u4EE3\u7801\u7684\u53F7\u7801",
"aria-label": "\u5141\u8BB8\u79C1\u804A\u7684 WhatsApp \u7535\u8BDD\u53F7\u7801",
onChange: (event) => {
setAllowedNumbers(event.target.value);
setError(null);
}
}),
h2("small", null, "\u53EF\u4EE5\u5305\u542B\u5F00\u5934\u7684 +\uFF0C\u4FDD\u5B58\u65F6\u4F1A\u81EA\u52A8\u79FB\u9664\u3002")
) : null,
allowlistEnabled && allowedNumbers.trim() === "" ? h2(
"p",
{ className: "dwa-accessWarning", role: "status" },
"\u767D\u540D\u5355\u4E3A\u7A7A\uFF1B\u4FDD\u5B58\u540E\u5C06\u53EA\u63A5\u53D7\u81EA\u804A\u6D88\u606F\u3002"
) : null,
error ? h2("p", { className: "dwa-accessError", role: "alert" }, error) : null,
h2(
"div",
{ className: "dwa-accessActions" },
h2("button", {
type: "submit",
className: "ddt-button",
"data-kind": "secondary",
disabled: busy
}, busy ? "\u6B63\u5728\u4FDD\u5B58\u2026" : "\u4FDD\u5B58\u8BBF\u95EE\u8BBE\u7F6E")
)
);
}
var Button14 = React19.forwardRef(function Button15({ children, kind = "secondary", className = "", ...props }, ref) {
return h2("button", {
...props,
@ -9430,6 +9630,7 @@ function WhatsappAccountCard({
onReconnect,
onWorkspaceSave,
onAgentPresetSave,
onAccessPolicySave,
onRequestRemove,
onConfirmRemove,
onCancelRemove
@ -9480,6 +9681,11 @@ function WhatsappAccountCard({
disabled: Boolean(busy),
onSave: onAgentPresetSave
}),
h2(WhatsappAccessSettings, {
account,
busy: Boolean(busy),
onSave: onAccessPolicySave
}),
h2(
"div",
{ className: "ddt-accountFooter dim-cardFooter" },
@ -9747,6 +9953,12 @@ function WhatsappSettingsTab({ rpcCall }) {
WHATSAPP_ENDPOINTS.setAgentPreset,
{ botId: account.botId, agentPreset }
),
onAccessPolicySave: (accessPolicy) => botAction(
account,
"access",
WHATSAPP_ENDPOINTS.setAccessPolicy,
{ botId: account.botId, ...accessPolicy }
),
onRequestRemove: () => setRemoveTarget(account.botId),
onCancelRemove: () => setRemoveTarget(null),
onConfirmRemove: async () => {

File diff suppressed because one or more lines are too long

View file

@ -9,6 +9,7 @@ export const WHATSAPP_ENDPOINTS = Object.freeze({
cancelProvisioning: 'provision.cancel',
reconnectBot: 'bot.reconnect',
deleteBot: 'bot.delete',
setAccessPolicy: 'bot.access-policy.set',
setWorkspace: 'bot.workspace.set',
setAgentPreset: SET_AGENT_PRESET_ENDPOINT,
});
@ -86,6 +87,16 @@ function normalizeBot(value) {
state: connected ? 'connected' : state,
workspace: text(value.workspace, '', 4_096),
agentPreset: normalizeAgentPresetId(value.agentPreset),
accessPolicy: {
accessMode: ['self-only', 'private-allowlist', 'open'].includes(
value.accessPolicy?.accessMode,
) ? value.accessPolicy.accessMode : 'self-only',
allowedNumbers: Array.isArray(value.accessPolicy?.allowedNumbers)
? [...new Set(value.accessPolicy.allowedNumbers.filter((entry) => (
typeof entry === 'string' && /^[1-9]\d{4,14}$/.test(entry)
)))]
: [],
},
bot: {
name: text(value.bot?.name, 'WhatsApp机器人', 100),
idMasked: text(value.bot?.idMasked, 'WhatsApp账号', 140),

View file

@ -25,6 +25,119 @@ import { installWhatsappStyles } from './styles.js';
const ACTIVE_STATES = new Set(['pending', 'connecting']);
function accessPolicyFor(account) {
const accessMode = ['self-only', 'private-allowlist', 'open'].includes(
account?.accessPolicy?.accessMode,
) ? account.accessPolicy.accessMode : 'self-only';
return {
accessMode,
allowedNumbers: Array.isArray(account?.accessPolicy?.allowedNumbers)
? account.accessPolicy.allowedNumbers : [],
};
}
function allowedNumbersFromText(value) {
const entries = value.split(/\r?\n/).map((entry) => entry.trim()).filter(Boolean);
const normalized = entries.map((entry) => entry.replace(/^\+/, ''));
if (normalized.some((entry) => !/^[1-9]\d{4,14}$/.test(entry))) {
throw new TypeError('电话号码必须包含国家或地区代码,每行一个。');
}
return [...new Set(normalized)];
}
export function WhatsappAccessSettings({ account, busy = false, onSave }) {
const policy = accessPolicyFor(account);
const sourceNumbers = policy.allowedNumbers.join('\n');
const helpId = React.useId();
const [accessMode, setAccessMode] = React.useState(policy.accessMode);
const [allowedNumbers, setAllowedNumbers] = React.useState(sourceNumbers);
const [error, setError] = React.useState(null);
React.useEffect(() => {
setAccessMode(policy.accessMode);
setAllowedNumbers(sourceNumbers);
setError(null);
}, [policy.accessMode, sourceNumbers]);
const save = async (event) => {
event.preventDefault();
setError(null);
try {
const normalized = allowedNumbersFromText(allowedNumbers);
if (typeof onSave !== 'function') throw new Error('WhatsApp 访问设置暂不可用。');
await onSave({ accessMode, allowedNumbers: normalized });
} catch (caught) {
setError(caught?.message ?? 'WhatsApp 访问设置保存失败。');
}
};
const allowlistEnabled = accessMode === 'private-allowlist';
const labels = {
'self-only': '仅自己模式',
'private-allowlist': '指定联系人模式',
open: '开放响应模式',
};
return h('form', { className: 'dwa-access', onSubmit: save },
h('div', { className: 'dwa-accessHeading' },
h('strong', null, '访问设置'),
h('span', { className: 'dwa-accessStatus' },
h('span', { className: 'dwa-accessBadge', 'data-mode': policy.accessMode },
['已生效:', labels[policy.accessMode]]),
h('span', { className: 'dwa-accessHelp' },
h('button', {
type: 'button',
className: 'dwa-accessHelpButton',
'aria-label': '查看 WhatsApp 访问模式说明',
'aria-describedby': helpId,
}, h('span', { 'aria-hidden': 'true' }, '?')),
h('span', { id: helpId, className: 'dwa-accessTooltip', role: 'tooltip' },
h('span', { className: 'dwa-accessTooltipItem' },
h('strong', null, '仅自己模式'),
h('span', null, '只响应已绑定 WhatsApp 账号的自聊消息。')),
h('span', { className: 'dwa-accessTooltipItem' },
h('strong', null, '指定联系人模式'),
h('span', null, '响应自聊和白名单联系人的私聊,忽略群聊。')),
h('span', { className: 'dwa-accessTooltipItem' },
h('strong', null, '开放响应模式'),
h('span', null, '响应所有私聊,以及群聊中的提及或回复。')))))),
h('label', { className: 'dwa-accessField' },
h('span', null, '模式'),
h('select', {
value: accessMode,
disabled: busy,
'aria-label': 'WhatsApp 访问模式',
onChange: (event) => { setAccessMode(event.target.value); setError(null); },
},
h('option', { value: 'self-only' }, '仅自己模式(默认)'),
h('option', { value: 'private-allowlist' }, '指定联系人模式'),
h('option', { value: 'open' }, '开放响应模式'))),
allowlistEnabled
? h('label', { className: 'dwa-accessField' },
h('span', null, '允许私聊的 WhatsApp 电话号码'),
h('textarea', {
value: allowedNumbers,
disabled: busy,
rows: 3,
placeholder: '每行一个含国家或地区代码的号码',
'aria-label': '允许私聊的 WhatsApp 电话号码',
onChange: (event) => { setAllowedNumbers(event.target.value); setError(null); },
}),
h('small', null, '可以包含开头的 +,保存时会自动移除。'))
: null,
allowlistEnabled && allowedNumbers.trim() === ''
? h('p', { className: 'dwa-accessWarning', role: 'status' },
'白名单为空;保存后将只接受自聊消息。')
: null,
error ? h('p', { className: 'dwa-accessError', role: 'alert' }, error) : null,
h('div', { className: 'dwa-accessActions' },
h('button', {
type: 'submit',
className: 'ddt-button',
'data-kind': 'secondary',
disabled: busy,
}, busy ? '正在保存…' : '保存访问设置')));
}
const Button = React.forwardRef(function Button(
{ children, kind = 'secondary', className = '', ...props },
ref,
@ -180,6 +293,7 @@ export function WhatsappAccountCard({
onReconnect,
onWorkspaceSave,
onAgentPresetSave,
onAccessPolicySave,
onRequestRemove,
onConfirmRemove,
onCancelRemove,
@ -216,6 +330,11 @@ export function WhatsappAccountCard({
disabled: Boolean(busy),
onSave: onAgentPresetSave,
}),
h(WhatsappAccessSettings, {
account,
busy: Boolean(busy),
onSave: onAccessPolicySave,
}),
h('div', { className: 'ddt-accountFooter dim-cardFooter' },
h('div', { className: 'dim-cardFooterLayout' },
h('div', { className: 'ddt-actions dim-cardActions' },
@ -469,6 +588,12 @@ export function WhatsappSettingsTab({ rpcCall }) {
WHATSAPP_ENDPOINTS.setAgentPreset,
{ botId: account.botId, agentPreset },
),
onAccessPolicySave: (accessPolicy) => botAction(
account,
'access',
WHATSAPP_ENDPOINTS.setAccessPolicy,
{ botId: account.botId, ...accessPolicy },
),
onRequestRemove: () => setRemoveTarget(account.botId),
onCancelRemove: () => setRemoveTarget(null),
onConfirmRemove: async () => {

View file

@ -4,6 +4,31 @@ 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; }
.dwa-access { display: grid; gap: 10px; padding: 12px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 10px; background: var(--dsw-alias-bg-layer-2, #f7f8fa); }
.dwa-accessHeading { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.dwa-accessHeading > strong { font-size: 13px; }
.dwa-accessStatus { min-width: 0; display: inline-flex; align-items: center; justify-content: flex-end; gap: 6px; }
.dwa-accessBadge { flex: none; padding: 3px 8px; border-radius: 999px; color: #08785f; background: #eafbf0; font-size: 11px; font-weight: 700; }
.dwa-accessBadge[data-mode="private-allowlist"] { color: #0f6f8f; background: #eaf7fd; }
.dwa-accessBadge[data-mode="open"] { color: #a15c00; background: #fff3d6; }
.dwa-accessHelp { position: relative; display: inline-flex; flex: none; }
.dwa-accessHelpButton { width: 20px; height: 20px; display: grid; place-items: center; padding: 0; border: 1px solid color-mix(in srgb, #25d366 34%, var(--dsw-alias-border-l2, #dfe1e5)); border-radius: 50%; color: #128c7e; background: var(--dsw-alias-bg-layer-1, #fff); font: inherit; font-size: 12px; line-height: 1; font-weight: 750; cursor: help; }
.dwa-accessTooltip { position: absolute; top: calc(100% + 8px); right: 0; z-index: 30; width: 270px; max-width: min(290px, calc(100vw - 48px)); display: grid; gap: 8px; padding: 10px 11px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 9px; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-bg-layer-3, #fff); box-shadow: 0 10px 28px rgb(31 35 41 / 16%); opacity: 0; visibility: hidden; transform: translateY(-3px); pointer-events: none; transition: opacity .15s ease, transform .15s ease, visibility .15s ease; }
.dwa-accessTooltipItem { display: grid; gap: 2px; }
.dwa-accessTooltipItem + .dwa-accessTooltipItem { padding-top: 8px; border-top: 1px solid var(--dsw-alias-border-l2, #eef0f3); }
.dwa-accessTooltipItem strong { font-size: 12px; line-height: 17px; }
.dwa-accessTooltipItem > span { color: var(--dsw-alias-label-secondary, #646a73); font-size: 11px; line-height: 16px; }
.dwa-accessHelp:hover .dwa-accessTooltip, .dwa-accessHelp:focus-within .dwa-accessTooltip { opacity: 1; visibility: visible; transform: translateY(0); }
.dwa-accessField { display: grid; gap: 5px; color: var(--dsw-alias-label-primary, #1f2329); font-size: 12px; font-weight: 600; }
.dwa-accessField select, .dwa-accessField textarea { width: 100%; box-sizing: border-box; border: 1px solid var(--dsw-alias-border-l1, #c9cdd4); border-radius: 7px; color: inherit; background: var(--dsw-alias-bg-layer-1, #fff); font: inherit; font-weight: 400; }
.dwa-accessField select { height: 34px; padding: 0 9px; }
.dwa-accessField textarea { min-height: 68px; padding: 8px 9px; resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.dwa-accessField textarea:disabled { color: var(--dsw-alias-label-tertiary, #8f959e); background: var(--dsw-alias-bg-module-platform, #f2f3f5); cursor: not-allowed; resize: none; opacity: 1; }
.dwa-accessField small { color: var(--dsw-alias-label-secondary, #646a73); font-weight: 400; }
.dwa-accessWarning, .dwa-accessError { margin: 0; font-size: 12px; line-height: 1.5; }
.dwa-accessWarning { color: #a15c00; }
.dwa-accessError { color: var(--dsw-alias-state-error-primary, #d83931); }
.dwa-accessActions { display: flex; justify-content: flex-end; }
`;
export function installWhatsappStyles() {

View file

@ -360,6 +360,24 @@ const EN = Object.freeze({
'正在建立安全的关联设备会话。': 'Creating a secure linked-device session.',
'关联设备正在接入 DeepSeek Harness。': 'Linking the device to DeepSeek Harness.',
'WhatsApp Web 关联设备运行正常': 'WhatsApp linked device is healthy',
'查看 WhatsApp 访问模式说明': 'View WhatsApp access mode details',
'WhatsApp 访问模式': 'WhatsApp access mode',
'仅自己模式': 'Only me',
'指定联系人模式': 'Selected contacts',
'开放响应模式': 'Open responses',
'仅自己模式(默认)': 'Only me (default)',
'已生效:': 'Active: ',
'只响应已绑定 WhatsApp 账号的自聊消息。': 'Only respond to self-chat messages from the linked WhatsApp account.',
'响应自聊和白名单联系人的私聊,忽略群聊。': 'Respond to self-chat and allowlisted direct messages; ignore group messages.',
'响应所有私聊,以及群聊中的提及或回复。': 'Respond to all direct messages and to group mentions or replies.',
'允许私聊的 WhatsApp 电话号码': 'WhatsApp phone numbers allowed to send direct messages',
'每行一个含国家或地区代码的号码': 'One number with country or region code per line',
'可以包含开头的 +,保存时会自动移除。': 'A leading + is allowed and removed when saved.',
'仅指定联系人模式使用白名单,切换模式时会保留。': 'Only Selected contacts uses the allowlist; it is retained when modes change.',
'白名单为空;保存后将只接受自聊消息。': 'The allowlist is empty; only self-chat messages will be accepted after saving.',
'电话号码必须包含国家或地区代码,每行一个。': 'Each phone number must include a country or region code on its own line.',
'WhatsApp 访问设置暂不可用。': 'WhatsApp access settings are currently unavailable.',
'WhatsApp 访问设置保存失败。': 'Could not save WhatsApp access settings.',
'Bot API 长轮询': 'Bot API long polling',
' Gateway 长连接': ' Gateway persistent connection',
'Gateway 长连接': 'Gateway persistent connection',

View file

@ -1,6 +1,7 @@
import QRCode from 'qrcode';
import { publicConnectionTestResult } from '../../../../src/channels/shared/connection-test.mjs';
import { normalizeWhatsappAccessPolicy } from '../../../../src/channels/whatsapp/config-store.mjs';
import { resolveRpcAuthority } from '../../rpc-authority.mjs';
import { publicWorkspaceError, SET_WORKSPACE_ENDPOINT, validWorkspacePayload } from '../shared/workspace-rpc.mjs';
import { SET_AGENT_PRESET_ENDPOINT, validAgentPresetPayload } from '../shared/agent-preset-rpc.mjs';
@ -13,6 +14,7 @@ export const WHATSAPP_ENDPOINTS = Object.freeze({
cancelProvisioning: 'provision.cancel',
reconnectBot: 'bot.reconnect',
deleteBot: 'bot.delete',
setAccessPolicy: 'bot.access-policy.set',
setWorkspace: SET_WORKSPACE_ENDPOINT,
setAgentPreset: SET_AGENT_PRESET_ENDPOINT,
});
@ -50,6 +52,17 @@ function payloadFailure(endpoint, payload) {
return exactKeys(payload, ['botId', 'confirm']) && validId(payload.botId)
&& payload.confirm === true ? null : 'bot.delete requires a botId and confirm=true.';
}
if (endpoint === WHATSAPP_ENDPOINTS.setAccessPolicy) {
if (!exactKeys(payload, ['botId', 'accessMode', 'allowedNumbers'])
|| Object.keys(payload).length !== 3
|| !validId(payload.botId)) return '请输入有效的 WhatsApp 访问模式和电话号码。';
try {
normalizeWhatsappAccessPolicy(payload);
return null;
} catch {
return '请输入有效的 WhatsApp 访问模式和电话号码。';
}
}
if (endpoint === WHATSAPP_ENDPOINTS.setWorkspace) {
return validWorkspacePayload(payload)
? null : '请输入工作区绝对路径。';
@ -92,7 +105,7 @@ async function publicStatus(value, encodeQr) {
}
export function createWhatsappRpcHandler(controller, { encodeQr = qrDataUrl } = {}) {
for (const method of ['status', 'startProvisioning', 'registrationStatus', 'cancelProvisioning', 'reconnectBot', 'deleteBot']) {
for (const method of ['status', 'startProvisioning', 'registrationStatus', 'cancelProvisioning', 'reconnectBot', 'deleteBot', 'setAccessPolicy']) {
if (typeof controller?.[method] !== 'function') {
throw new TypeError(`A complete WhatsApp controller is required (${method})`);
}
@ -166,6 +179,11 @@ export function createWhatsappRpcHandler(controller, { encodeQr = qrDataUrl } =
await controller.updateAgentPreset(payload.botId, payload.agentPreset),
cachedEncode,
);
} else if (endpoint === WHATSAPP_ENDPOINTS.setAccessPolicy) {
value = await publicStatus(
await controller.setAccessPolicy(payload.botId, normalizeWhatsappAccessPolicy(payload)),
cachedEncode,
);
} else {
value = await publicStatus(await controller.deleteBot(payload.botId), cachedEncode);
}

View file

@ -5,6 +5,13 @@ 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}$/;
const WHATSAPP_PHONE_NUMBER = /^[1-9]\d{4,14}$/;
export const WHATSAPP_ACCESS_MODES = Object.freeze({
selfOnly: 'self-only',
privateAllowlist: 'private-allowlist',
open: 'open',
});
function cleanString(value) {
return typeof value === 'string' && value.trim() ? value.trim() : null;
@ -28,6 +35,35 @@ export function maskWhatsappAccount(accountJid) {
return `${digits.slice(0, 4)}••••${digits.slice(-4)}`;
}
export function normalizeWhatsappAllowedNumbers(value) {
if (value === undefined) return Object.freeze([]);
if (!Array.isArray(value)) {
throw new TypeError('allowedNumbers must be an array of WhatsApp phone numbers');
}
const normalized = value.map((entry) => {
const number = typeof entry === 'string' ? entry.trim().replace(/^\+/, '') : '';
if (!WHATSAPP_PHONE_NUMBER.test(number)) {
throw new TypeError('allowedNumbers contains an invalid WhatsApp phone number');
}
return number;
});
return Object.freeze([...new Set(normalized)]);
}
export function normalizeWhatsappAccessPolicy(value = {}) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new TypeError('WhatsApp access policy must be an object');
}
const accessMode = value.accessMode ?? WHATSAPP_ACCESS_MODES.selfOnly;
if (!Object.values(WHATSAPP_ACCESS_MODES).includes(accessMode)) {
throw new TypeError('WhatsApp accessMode is invalid');
}
return Object.freeze({
accessMode,
allowedNumbers: normalizeWhatsappAllowedNumbers(value.allowedNumbers),
});
}
export class WhatsappConfigStore {
#path;
#value = EMPTY_DOCUMENT;
@ -112,6 +148,12 @@ export class WhatsappConfigStore {
if (!accountJid || !botId || !authDirectory || !name
|| !BOT_ID_PATTERN.test(botId) || !AUTH_DIRECTORY_PATTERN.test(authDirectory)
|| deriveWhatsappBotId(accountJid) !== botId) return null;
let accessPolicy;
try {
accessPolicy = normalizeWhatsappAccessPolicy(value);
} catch {
return null;
}
return Object.freeze({
botId,
accountJid,
@ -119,6 +161,7 @@ export class WhatsappConfigStore {
name,
createdAt: cleanString(value.createdAt) ?? new Date().toISOString(),
connectedAt: cleanString(value.connectedAt),
...accessPolicy,
});
}

View file

@ -1,7 +1,11 @@
import { randomUUID } from 'node:crypto';
import { connectionTestMessage } from '../shared/connection-test.mjs';
import { deriveWhatsappBotId, maskWhatsappAccount } from './config-store.mjs';
import {
deriveWhatsappBotId,
maskWhatsappAccount,
normalizeWhatsappAccessPolicy,
} from './config-store.mjs';
const ACTIVE_ATTEMPT_STATES = new Set(['starting', 'pending', 'connecting']);
const TERMINAL_ATTEMPT_STATES = new Set(['connected', 'failed', 'cancelled']);
@ -218,6 +222,20 @@ export class WhatsappController {
});
}
async setAccessPolicy(botId, value) {
if (this.#closed) throw new Error('WhatsApp controller is closed');
const accessPolicy = normalizeWhatsappAccessPolicy(value);
await this.#withBotTransition(botId, async () => {
if (this.#closed) throw new Error('WhatsApp controller is closed');
const config = this.#configStore.get(botId);
if (!config) throw new Error('Unknown WhatsApp bot');
const saved = await this.#configStore.save({ ...config, ...accessPolicy });
this.#runtimes.get(botId)?.setAccessPolicy?.(saved);
this.#touch();
});
return this.status();
}
async deleteBot(botId) {
const config = this.#configStore.get(botId);
if (!config) throw new Error('Unknown WhatsApp bot');
@ -266,6 +284,7 @@ export class WhatsappController {
messagesReceived: runtimeStatus?.messagesReceived ?? 0,
messagesReplied: runtimeStatus?.messagesReplied ?? 0,
},
accessPolicy: normalizeWhatsappAccessPolicy(config),
error: structuredClone(this.#errors.get(config.botId) ?? null),
};
});
@ -310,6 +329,8 @@ export class WhatsappController {
name: identity.name,
createdAt: previous?.createdAt ?? new Date().toISOString(),
connectedAt: new Date().toISOString(),
accessMode: previous?.accessMode,
allowedNumbers: previous?.allowedNumbers,
};
try {
if (record.controller.signal.aborted || this.#closed) throw Object.assign(new Error(), { name: 'AbortError' });

View file

@ -10,6 +10,10 @@ import { splitMessageText } from '../shared/editable-message-stream.mjs';
import { ImagePromptError } from '../shared/image-prompt.mjs';
import { trackOutboundArtifactProviderPromise } from '../shared/semantic/artifact.mjs';
import { createWhatsappBridgeStatus, WhatsappHarnessBridge } from './whatsapp-bridge.mjs';
import {
WHATSAPP_ACCESS_MODES,
normalizeWhatsappAccessPolicy,
} from './config-store.mjs';
import { createWhatsappWebSession } from './whatsapp-web-session.mjs';
const IMAGE_MEDIA_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif']);
@ -181,6 +185,7 @@ export function normalizeWhatsappMessage(message, accountJid, {
&& [remoteJid, alternateRemoteJid].some((jid) => jid && areJidsSameUser(jid, accountJid));
if (fromMe && !selfChat) return null;
const senderJid = selfChat ? accountJid : group ? message.key.participant : remoteJid;
const senderAlternateJid = group ? message.key.participantAlt : alternateRemoteJid;
if (typeof senderJid !== 'string' || !senderJid) return null;
const viewOnce = hasViewOnceWrapper(message.message);
const content = normalizeMessageContent(message.message);
@ -194,6 +199,7 @@ export function normalizeWhatsappMessage(message, accountJid, {
messageId: `${remoteJid}:${messageId}`,
providerMessageId: messageId,
senderId: senderJid,
senderAlternateId: typeof senderAlternateJid === 'string' ? senderAlternateJid : '',
senderIsBot: false,
kind: group ? 'group' : 'direct',
conversationId: remoteJid,
@ -205,6 +211,22 @@ export function normalizeWhatsappMessage(message, accountJid, {
};
}
export function whatsappInboundAllowed(message, {
accessMode = WHATSAPP_ACCESS_MODES.selfOnly,
allowedNumbers = new Set(),
} = {}) {
if (accessMode === WHATSAPP_ACCESS_MODES.open) return true;
if (message?.kind !== 'direct') return false;
if (message.selfChat === true) return true;
if (accessMode !== WHATSAPP_ACCESS_MODES.privateAllowlist
|| !(allowedNumbers instanceof Set)) return false;
const senderJids = [message.senderId, message.senderAlternateId]
.filter((jid) => typeof jid === 'string' && jid.endsWith('@s.whatsapp.net'));
return [...allowedNumbers].some((number) => senderJids.some((jid) => (
areJidsSameUser(jid, `${number}@s.whatsapp.net`)
)));
}
class RecentWhatsappOutboundIds {
#ids = new Map();
@ -390,6 +412,8 @@ export class WhatsappRuntime {
#replyTimeoutMs;
#connectTimeoutMs;
#mediaUploadTimeoutMs;
#accessMode;
#allowedPrivateNumbers;
#createSession;
#status = createWhatsappRuntimeStatus();
#abortController = null;
@ -427,12 +451,21 @@ export class WhatsappRuntime {
WHATSAPP_MEDIA_UPLOAD_TIMEOUT_MS,
);
this.#createSession = createSession;
this.setAccessPolicy(config);
}
get status() {
return structuredClone(this.#status);
}
setAccessPolicy(value) {
const policy = normalizeWhatsappAccessPolicy(value);
this.#accessMode = policy.accessMode;
this.#allowedPrivateNumbers = new Set(policy.allowedNumbers);
this.#config = { ...this.#config, ...policy };
return policy;
}
async start() {
if (this.#status.ready && this.#session) return this.status;
if (this.#starting) return this.#starting;
@ -466,6 +499,14 @@ export class WhatsappRuntime {
const message = normalizeWhatsappMessage(raw, this.#config.accountJid);
if (!message || outboundIds.has(message.providerMessageId) || !this.#bridge) return;
this.#status.lastCheckedAt = Date.now();
if (!whatsappInboundAllowed(message, {
accessMode: this.#accessMode,
allowedNumbers: this.#allowedPrivateNumbers,
})) {
this.#status.messagesRejected += 1;
this.#status.lastRejectedAt = new Date().toISOString();
return;
}
await this.#bridge.accept(message);
},
onDisconnect: ({ error }) => {

View file

@ -10,6 +10,7 @@ import {
EmptyView,
ProvisionView,
QrPanel,
WhatsappAccessSettings,
WhatsappAccountCard,
WhatsappSettingsTab,
} from '../../../plugin-src/client/channels/whatsapp/index.js';
@ -71,15 +72,78 @@ test('WhatsApp account card uses the unified compact channel layout', () => {
assert.match(markup, /检查连接/);
assert.match(markup, /移除接入/);
assert.match(markup, /class="dim-presetSelect"/);
assert.match(markup, /仅自己模式(默认)/);
assert.match(markup, /指定联系人模式/);
assert.match(markup, /开放响应模式/);
assert.match(markup, /role="status"[^>]*>测试消息已发送/);
});
test('WhatsApp access settings save a normalized selected-contact allowlist', async () => {
const saved = [];
let renderer;
await act(async () => {
renderer = create(React.createElement(WhatsappAccessSettings, {
account: {
accessPolicy: { accessMode: 'self-only', allowedNumbers: [] },
},
onSave: async (value) => saved.push(value),
}));
});
const select = renderer.root.findByProps({ 'aria-label': 'WhatsApp 访问模式' });
await act(async () => {
select.props.onChange({ target: { value: 'private-allowlist' } });
});
const textarea = renderer.root.findByProps({
'aria-label': '允许私聊的 WhatsApp 电话号码',
});
await act(async () => {
textarea.props.onChange({ target: { value: '+16505550999\n16505550999' } });
});
await act(async () => {
renderer.root.findByType('form').props.onSubmit({ preventDefault() {} });
await flushMicrotasks();
});
assert.deepEqual(saved, [{
accessMode: 'private-allowlist',
allowedNumbers: ['16505550999'],
}]);
await act(async () => { renderer.unmount(); });
});
test('WhatsApp access settings only show the allowlist for selected contacts', async () => {
let renderer;
await act(async () => {
renderer = create(React.createElement(WhatsappAccessSettings, {
account: {
accessPolicy: { accessMode: 'self-only', allowedNumbers: ['16505550999'] },
},
onSave: async () => {},
}));
});
const select = renderer.root.findByProps({ 'aria-label': 'WhatsApp 访问模式' });
const allowlistFields = () => renderer.root.findAllByProps({
'aria-label': '允许私聊的 WhatsApp 电话号码',
});
assert.equal(allowlistFields().length, 0);
await act(async () => {
select.props.onChange({ target: { value: 'private-allowlist' } });
});
assert.equal(allowlistFields().length, 1);
await act(async () => {
select.props.onChange({ target: { value: 'open' } });
});
assert.equal(allowlistFields().length, 0);
await act(async () => { renderer.unmount(); });
});
test('WhatsApp connection check requests a test message from the existing reconnect endpoint', async () => {
const source = await readFile(new URL(
'../../../plugin-src/client/channels/whatsapp/index.js',
import.meta.url,
), 'utf8');
assert.match(source, /WHATSAPP_ENDPOINTS\.reconnectBot,[\s\S]*\{ botId: account\.botId, sendTest: true \}/);
assert.match(source, /WHATSAPP_ENDPOINTS\.setAccessPolicy/);
assert.match(source, /\[account\.botId\]: '连接检查失败,请稍后重试。'/);
assert.doesNotMatch(source, /连接检查失败:\$\{presentError\(error\)\.message\}/);
});

View file

@ -19,13 +19,16 @@ import {
createOutboundArtifactTool,
} from '../../../src/channels/shared/semantic/artifact.mjs';
import {
WHATSAPP_ACCESS_MODES,
WhatsappConfigStore,
deriveWhatsappBotId,
normalizeWhatsappAccessPolicy,
} from '../../../src/channels/whatsapp/config-store.mjs';
import { WhatsappController } from '../../../src/channels/whatsapp/whatsapp-controller.mjs';
import {
WhatsappRuntime,
normalizeWhatsappMessage,
whatsappInboundAllowed,
} from '../../../src/channels/whatsapp/whatsapp-runtime.mjs';
import { createWhatsappWebSession } from '../../../src/channels/whatsapp/whatsapp-web-session.mjs';
import {
@ -123,6 +126,8 @@ function linkedConfig(overrides = {}) {
accountJid: ACCOUNT_JID,
authDirectory: AUTH_DIRECTORY,
name: 'Harness WhatsApp',
accessMode: WHATSAPP_ACCESS_MODES.open,
allowedNumbers: [],
createdAt: new Date().toISOString(),
connectedAt: new Date().toISOString(),
...overrides,
@ -135,10 +140,37 @@ test('WhatsApp config stores only linked-device metadata with restrictive permis
const store = await new WhatsappConfigStore(path).load();
await store.save(linkedConfig());
assert.equal(store.list()[0].accountJid, ACCOUNT_JID);
assert.equal(store.list()[0].accessMode, WHATSAPP_ACCESS_MODES.open);
assert.equal((await stat(path)).mode & 0o777, 0o600);
await assert.rejects(() => store.save(linkedConfig({ botId: 'whatsapp_invalid' })));
});
test('WhatsApp migrates existing bots to self-only mode and validates access policies', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-im-whatsapp-access-migration-'));
const path = join(root, 'config.json');
const legacy = linkedConfig();
delete legacy.accessMode;
delete legacy.allowedNumbers;
await writeFile(path, `${JSON.stringify({ version: 2, bots: [legacy] })}\n`);
const store = await new WhatsappConfigStore(path).load();
assert.deepEqual(store.get(legacy.botId), {
...legacy,
accessMode: WHATSAPP_ACCESS_MODES.selfOnly,
allowedNumbers: [],
});
assert.deepEqual(normalizeWhatsappAccessPolicy({
accessMode: WHATSAPP_ACCESS_MODES.privateAllowlist,
allowedNumbers: ['+16505550999', '16505550999'],
}), {
accessMode: WHATSAPP_ACCESS_MODES.privateAllowlist,
allowedNumbers: ['16505550999'],
});
assert.throws(() => normalizeWhatsappAccessPolicy({
accessMode: 'compatible',
allowedNumbers: [],
}), /accessMode/);
});
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();
@ -299,6 +331,46 @@ test('WhatsApp normalizes direct and explicitly mentioned group messages', () =>
}, ACCOUNT_JID), null);
});
test('WhatsApp access modes allow self-chat, selected contacts, or the existing open behavior', () => {
const direct = normalizeWhatsappMessage({
key: {
remoteJid: '987654321098765@lid',
remoteJidAlt: '16505550999@s.whatsapp.net',
id: 'access-direct',
fromMe: false,
},
message: { conversation: 'hello' },
}, ACCOUNT_JID);
const group = normalizeWhatsappMessage({
key: {
remoteJid: '120363000000000000@g.us',
participant: '16505550999@s.whatsapp.net',
id: 'access-group',
fromMe: false,
},
message: { conversation: 'hello' },
}, ACCOUNT_JID);
const selfChat = normalizeWhatsappMessage({
key: { remoteJid: ACCOUNT_JID, id: 'access-self', fromMe: true },
message: { conversation: 'hello' },
}, ACCOUNT_JID);
assert.equal(whatsappInboundAllowed(selfChat), true);
assert.equal(whatsappInboundAllowed(direct), false);
assert.equal(whatsappInboundAllowed(group), false);
assert.equal(whatsappInboundAllowed(direct, {
accessMode: WHATSAPP_ACCESS_MODES.privateAllowlist,
allowedNumbers: new Set(['16505550999']),
}), true);
assert.equal(whatsappInboundAllowed(direct, {
accessMode: WHATSAPP_ACCESS_MODES.privateAllowlist,
allowedNumbers: new Set(['16505550000']),
}), false);
assert.equal(whatsappInboundAllowed(group, {
accessMode: WHATSAPP_ACCESS_MODES.open,
}), true);
});
test('WhatsApp exposes image media through a bounded Baileys download stream', async () => {
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const calls = [];
@ -420,7 +492,7 @@ test('WhatsApp exposes image media through a bounded Baileys download stream', a
});
});
test('WhatsApp runtime connects a linked device and replies through Harness', async () => {
test('WhatsApp runtime filters messages before the bridge and applies policy updates live', async () => {
let callbacks;
const calls = [];
const socket = {
@ -443,7 +515,7 @@ test('WhatsApp runtime connects a linked device and replies through Harness', as
ask: async () => 'Harness answer',
};
const runtime = new WhatsappRuntime({
config: linkedConfig(),
config: linkedConfig({ accessMode: WHATSAPP_ACCESS_MODES.selfOnly }),
authDir: '/tmp/test-whatsapp-auth',
harness,
state,
@ -463,6 +535,13 @@ test('WhatsApp runtime connects a linked device and replies through Harness', as
message: { conversation: 'hello' },
});
assert.equal(runtime.status.ready, true);
assert.equal(runtime.status.messagesRejected, 1);
assert.equal(calls.length, 0);
runtime.setAccessPolicy({ accessMode: WHATSAPP_ACCESS_MODES.open, allowedNumbers: [] });
await callbacks.onMessage({
key: { remoteJid: '16505550999@s.whatsapp.net', id: 'direct-3', fromMe: false },
message: { conversation: 'hello again' },
});
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();
@ -882,6 +961,7 @@ test('WhatsApp reconnect RPC sends tests only for the connected target and keeps
cancelProvisioning: async () => null,
reconnectBot: async () => snapshot(),
deleteBot: async () => snapshot(),
setAccessPolicy: async () => snapshot(),
sendConnectionTest: async () => {
sendCalls += 1;
if (sendFailure) throw new Error('private provider failure');
@ -947,6 +1027,7 @@ test('WhatsApp RPC never sends a connection test after reconnect is cancelled',
reconnectBot: async () => reconnect,
sendConnectionTest: async () => { sendCalls += 1; },
deleteBot: async () => ({ bots: [] }),
setAccessPolicy: async () => ({ bots: [] }),
};
const abort = new AbortController();
const result = createWhatsappRpcHandler(controller)(WHATSAPP_ENDPOINTS.reconnectBot, {
@ -971,6 +1052,7 @@ test('WhatsApp QR controller and RPC keep the raw QR and linked identity host-on
let resolveReady;
const ready = new Promise((resolve) => { resolveReady = resolve; });
const deletedAuth = [];
const appliedPolicies = [];
const controller = new WhatsappController({
configStore,
authPath: (name) => join(root, 'auth', name),
@ -988,6 +1070,10 @@ test('WhatsApp QR controller and RPC keep the raw QR and linked identity host-on
},
start: async () => {},
stop: async () => {},
setAccessPolicy: (value) => appliedPolicies.push({
accessMode: value.accessMode,
allowedNumbers: value.allowedNumbers,
}),
}),
deleteAuth: async (name) => deletedAuth.push(name),
});
@ -1008,7 +1094,32 @@ test('WhatsApp QR controller and RPC keep the raw QR and linked identity host-on
}
assert.equal(status.ok, true);
assert.equal(status.value.bots[0].connected, true);
assert.deepEqual(status.value.bots[0].accessPolicy, {
accessMode: WHATSAPP_ACCESS_MODES.selfOnly,
allowedNumbers: [],
});
assert.doesNotMatch(JSON.stringify(status.value), /16505550123@s\.whatsapp\.net|authDirectory/);
const updated = await handler(WHATSAPP_ENDPOINTS.setAccessPolicy, {
botId: status.value.bots[0].botId,
accessMode: WHATSAPP_ACCESS_MODES.privateAllowlist,
allowedNumbers: ['+16505550999'],
});
assert.equal(updated.ok, true);
assert.deepEqual(updated.value.bots[0].accessPolicy, {
accessMode: WHATSAPP_ACCESS_MODES.privateAllowlist,
allowedNumbers: ['16505550999'],
});
assert.deepEqual(appliedPolicies, [{
accessMode: WHATSAPP_ACCESS_MODES.privateAllowlist,
allowedNumbers: ['16505550999'],
}]);
const invalidPolicy = await handler(WHATSAPP_ENDPOINTS.setAccessPolicy, {
botId: status.value.bots[0].botId,
accessMode: 'compatible',
allowedNumbers: [],
});
assert.equal(invalidPolicy.ok, false);
assert.equal(invalidPolicy.error.code, 'bad-request');
assert.equal(sessionOptions.signal.aborted, false);
assert.deepEqual(deletedAuth, []);
});