feat: add unified IM access policies

This commit is contained in:
xmanrui 2026-09-01 10:45:12 +08:00
parent 075d2713c1
commit 840e5aa857
89 changed files with 5573 additions and 1676 deletions

View file

@ -0,0 +1,621 @@
# Issue #95:九渠道私聊、群聊白名单与命令权限方案
日期:2026-09-01。代码基线:v4.3.0 / `009f1ab`。状态:已实施并通过自动化、真实飞书群聊验收及用户手工私聊验收。
需求来源:[Issue #95](https://github.com/xmanrui/dsh-im/issues/95)。Issue 原始诉求包含群聊、私聊和命令白名单;本文按后续讨论收敛为九个 IM 渠道统一的发送者访问控制,以及“可以执行全部现有命令 / 不可以执行命令”两档权限。
## 1. 最终决定
九个 IM 渠道统一增加机器人级访问策略:
- 私聊和群聊的权限范围完全独立,但在同一个设置页中编辑。
- 两个场景都可以选择“允许所有用户”或“仅白名单用户”。
- 白名单按发送者限制,不保存、不选择、不限制群 ID;机器人能进入或接收哪些群,继续由渠道自身决定。
- 每个用户只有“可以执行命令”和“不可以执行命令”两档,不做命令名、命令组或角色分级。
- 同一用户可以在私聊中有命令权限、在群聊中没有,反之亦然。
- 原 owner、扫码接入者及现有明确授权身份始终保留原始访问权和全部命令权限,不受白名单模式、默认命令权限或用户行覆盖;访问设置只约束其他用户。
- 访问策略按 `botId` 保存,不跨机器人、渠道或 Host 共享。
实现复用现有三处机制:
1. 复用 `BotWorkspaceStore`,把访问策略和工作区、Agent Preset、上下文增强、投递目标一起按 `botId` 管理,不增加数据库或独立配置文件。
2. 复用现有“更多机器人设置”页面和页签骨架,在现有“投递设置”后增加第二个页签“访问设置”。
3. 复用现有六条入站处理路径:四个文字桥接渠道共用 `TextHarnessBridge`,另外五个渠道只接入薄的共享权限判断,不复制九套业务逻辑。
Telegram 和 WhatsApp 的旧访问设置自动迁移,迁移后旧入口不再显示。飞书当前没有用户可配置的白名单入口;“群聊响应方式”与白名单无关,继续保留。
AI Office Connector 不在本方案范围内,不修改其配置、协议、任务领取或执行链路。
## 2. 范围
### 2.1 必须实现
覆盖以下九个渠道:
1. 微信
2. 飞书
3. 钉钉
4. 企业微信
5. QQ
6. Slack
7. Telegram
8. Discord
9. WhatsApp
每个机器人必须具备:
- 一份私聊访问策略;
- 一份群聊成员访问策略;
- 两个场景各自独立的命令权限;
- 自动初始化或自动迁移;
- 设置保存后对后续入站事件立即生效,无需重连机器人;
- 删除机器人时随现有机器人级数据一起清理。
### 2.2 明确不做
- 不配置允许使用机器人的群、频道、Topic 或 Thread 列表。
- 不增加群 ID 白名单或群会话范围限制。
- 不按具体命令、命令组、管理员角色或组织部门授权。
- 不建立跨渠道统一用户账号,也不推断两个渠道 ID 是否属于同一个人。
- 不接入平台通讯录,不做用户搜索、昵称同步或自动补全。
- 不保存被动观察到的全量成员目录或访问审计数据库。
- 不限制主动投递、连接检查或机器人出站消息。
- 不改变 Session 的群聊、Topic、Thread 隔离方式。
- 不处理 AI Office。
## 3. 设置页体验
### 3.1 入口和页签
继续使用机器人卡片右上角的“更多机器人设置”入口。顶层只保留两个页签,顺序固定为:
| 页签 | 内容 |
| --- | --- |
| 投递设置 | 保持现状,仍作为默认页签,避免改变已有入口体验 |
| 访问设置 | 作为紧跟“投递设置”的第二个页签,统一编辑私聊、群聊的白名单和命令权限 |
“访问设置”页内按顺序竖向放置“私聊”和“群聊”两个区域,不再增加子页签、弹窗或第三个顶层页签。两个区域复用同一个场景编辑组件,页底只提供一个“保存访问设置”按钮,一次原子保存私聊和群聊策略。
微信当前没有群聊入站能力。其“访问设置”页仍显示群聊区域以保持九渠道页面结构一致,但该区域仅显示“当前渠道不支持群聊”说明并禁用控件;私聊区域仍可正常保存。其余渠道正常编辑群成员策略。
### 3.2 单个场景的控件
每个场景先提供访问模式:`允许所有用户` / `仅白名单用户`,再根据当前模式显示对应控件:
- `允许所有用户`:默认命令权限,以及独立的“命令权限例外”名单;
- `仅白名单用户`:独立的“白名单用户”名单,每行设置用户标识和命令权限;
- 两种名单都使用紧凑的新增按钮、删除按钮和逐行命令权限,但数据互不复用;
- 场景区域内不放单独保存按钮,统一使用页底的“保存访问设置”。
在“仅白名单用户”模式下,列表决定谁可以发送普通消息,每一行同时决定该用户能否执行命令。未添加白名单用户时,除渠道现有的特权身份外没有普通用户可以访问;保存前必须给出明确警告,但允许用户确认保存。
在“允许所有用户”模式下,所有发送者都可以发送普通消息;默认命令权限作用于未单独列出的用户,“命令权限例外”只覆盖指定用户的默认命令权限。
“白名单用户”和“命令权限例外”是两种业务场景的数据,必须分别保存。切换访问模式只切换当前生效规则,不转换、不清空另一份名单;切回原模式时恢复该模式上次保存的配置。
访问设置中的群聊区域只编辑群消息发送者,不提供群 ID 控件;不额外堆叠解释性旁白。
页面同时明确提示:原所有者/扫码接入者始终可以访问并执行命令,本页设置只约束其他用户。为保持实现简单,本期不把所有者做成特殊的只读用户行,也不允许页面修改其原始权限。
### 3.3 旧入口
- 删除 Telegram 机器人卡片中的旧“兼容模式 / 安全模式(私聊白名单)”访问设置。
- 删除 WhatsApp 机器人卡片中的旧“仅自己 / 指定联系人 / 开放响应模式”访问设置。
- 保留飞书机器人卡片中的“群聊响应方式”及其群消息权限授权流程。
- 其他机器人卡片、工作区、Agent Preset、上下文增强、连接检查和移除入口保持不变。
旧入口只从界面移除;旧数据在自动迁移成功前不能丢弃。
## 4. 最小数据模型
访问策略沿用现有 `direct` / `group` 场景划分,并在每个场景内分开保存 `open` 与 `allowlist` 配置:
```json
{
"direct": {
"mode": "allowlist",
"open": {
"defaultCanExecuteCommands": false,
"commandPermissionOverrides": [
{
"id": "open-mode-exception-id",
"canExecuteCommands": true
}
]
},
"allowlist": {
"users": [
{
"id": "direct-allowlisted-user-id",
"canExecuteCommands": true
}
]
}
},
"group": {
"mode": "open",
"open": {
"defaultCanExecuteCommands": false,
"commandPermissionOverrides": [
{
"id": "group-command-exception-id",
"canExecuteCommands": true
}
]
},
"allowlist": {
"users": [
{
"id": "group-allowlisted-member-id",
"canExecuteCommands": false
}
]
}
}
}
```
两套模式配置只复用相同的用户行形状,不复用同一个数组。仍不增加角色、规则表达式、命令数组或继承层级。
### 4.1 判断规则
| 模式 | 普通消息 | 命令 |
| --- | --- | --- |
| `open` | 所有可识别发送者均允许 | 命中 `open.commandPermissionOverrides` 时使用该行的 `canExecuteCommands`,否则使用 `open.defaultCanExecuteCommands` |
| `allowlist` | 只有命中 `allowlist.users` 的发送者允许 | 使用命中白名单行的 `canExecuteCommands`;未命中者连普通消息都不允许 |
两种模式只读取各自的数据。`allowlist` 模式不读取开放模式的默认命令权限或例外名单,`open` 模式也不读取白名单;切换模式不会让一类用户自动变成另一类用户。
在上表之前先判断渠道现有的特权身份。命中原 owner/扫码接入者时直接允许普通消息和命令,不读取场景模式或用户行。特权身份继续来自渠道既有、可验证的接入配置,不复制进访问策略,也不新增管理员角色:
- 微信:具体的 `ownerUserId`;
- 飞书:具体的 `ownerOpenIds`,通配符 `*` 不是人类身份,不作为特权 ID;
- 钉钉:现有 `approvedSenders[].staffId`;
- QQ:具体的 `ownerUserOpenid`,通配符 `*` 不作为特权 ID;
- WhatsApp:当前绑定账号 `accountJid`,复用现有 JID 等价比较;
- 企业微信、Slack、Telegram、Discord 当前没有可验证的人类 owner/扫码者字段,不臆造特权身份。Telegram 旧 `allowedUsers` 只是可编辑白名单,不升级为永久 owner。
### 4.2 校验和归一化
- 配置必须同时包含 `direct` 和 `group`,整份原子保存。
- `mode` 只能是 `open` 或 `allowlist`。
- `open.defaultCanExecuteCommands` 和两类用户行的 `canExecuteCommands` 必须是布尔值。
- 用户标识转成字符串、去除首尾空白、拒绝空值和控制字符,最大 256 个字符。
- 同一场景的同一份名单内不允许出现重复用户标识;同一 ID 可以分别存在于两种模式的名单中,权限互不继承。
- 除 WhatsApp 外均按渠道提供的稳定发送者标识精确匹配。
- WhatsApp 复用现有号码/JID 归一化和 `areJidsSameUser()`,兼容号码 JID、LID 和备用发送者 JID,不另写身份算法。
- 入站事件无法得到可靠发送者标识时拒绝处理,不根据昵称猜测身份。
### 4.3 每个渠道使用的发送者标识
| 渠道 | 私聊 | 群聊成员 |
| --- | --- | --- |
| 微信 | `from_user_id` | 当前不支持群聊入站 |
| 飞书 | `sender.sender_id.open_id` | `sender.sender_id.open_id` |
| 钉钉 | `senderStaffId`,缺失时沿用现有 `senderId` | 同左 |
| 企业微信 | `from.userid` | `from.userid` |
| QQ | `user_openid` 对应的现有 `senderId` | `member_openid` 对应的现有 `senderId` |
| Slack | `event.user` | `event.user` |
| Telegram | `message.from.id` 的字符串形式 | 同左 |
| Discord | `message.author.id` | 同左 |
| WhatsApp | 当前发送者号码/JID | 群参与者号码/JID及其备用标识 |
标识是渠道内、机器人内、场景内的不透明字符串。尤其 QQ 的私聊 `user_openid` 和群成员 `member_openid` 不能互相推导,所以同一页中的两个区域必须分别配置。
## 5. 入站处理顺序
访问策略只增加一道共享门禁,不接管渠道原生路由:
```text
平台事件基本校验与机器人回声过滤
↓
渠道现有触发规则(@、回复、Thread、群消息权限等)
↓
原 owner/扫码接入者命中则直接保留访问与全部命令权限
↓(其他用户)
按 direct/group + senderId 检查访问策略
↓
若现有命令解析器识别为命令,再检查命令权限
↓
现有去重、批量输入、问题/审批、Session、Harness 和回复流程
```
实现时访问判断必须早于以下副作用:
- 下载图片或文件;
- 创建、切换或清除 Session;
- 执行本地命令或 Harness 命令;
- 回答问题或审批;
- 触发模型请求;
- 执行飞书卡片中的命令等价操作。
被白名单拒绝的消息沿用现有安全行为:不调用 Harness、不创建 Session、不回复。可以复用现有日志记录渠道、`botId`、场景和拒绝原因,但不新增审计库、计数器,也不输出完整发送者标识。
发送普通消息有权限、但执行命令无权限时,由当前渠道现有文字发送方法回复“你可以发送普通消息,但没有执行命令的权限。”;该消息在本地消费,不能作为普通 Prompt 转发给模型。
权限设置对保存后的新入站事件生效,不取消已经开始的 Harness Turn,也不追溯改变已经进入队列的事件。
## 6. 命令权限边界
“命令”指当前代码已经识别并在本地执行的 dsh-im 命令,包括:
- `/help`、`/status`、`/version`、`/new`、`/stop`、`/steer` 等控制命令;
- Workspace、Session、模型、推理等级、Agent Preset、历史和压缩命令;
- `/batch`、`/send`、`/cancel` 等批量输入控制命令;
- 飞书 `/repair`、`/watch` 等现有渠道命令;
- 飞书卡片中与新建 Session、切换 Workspace/Session/模型/Preset 等价的操作。
实现不能简单地把所有 `/` 开头文本都判成命令。应复用现有命令解析器,并为目前“解析和执行混在一起”的 Workspace、Compact 等分支补充无副作用的识别函数。未被现有解析器识别的 `/foo` 继续按现有普通消息行为处理。
以下不是命令,只检查普通访问权限:
- 普通文字、图片和文件消息;
- 对 Harness 补充问题的回答;
- 对 Harness 审批的批准或拒绝;
- 渠道原生引用、提及和线程回复本身;
- 主动投递和连接测试。
飞书 `/repair` 等现有平台授权流程仍保留平台侧和所有者身份校验。新的命令布尔值不能绕过这些已有安全条件,也不新增第二种管理员角色。
## 7. 九渠道行为基线与初始化
新策略取得唯一处理权前,必须把当前可验证的入站行为转换为等价初始值。所有当前能够执行命令的用户在初始化后均为 `canExecuteCommands: true`;开放范围的 `open.defaultCanExecuteCommands` 也为 `true`,避免升级后功能突然减少。
初始化名单用于保持升级前其他用户的行为;owner/扫码接入者不复制到可编辑、可公开的访问策略行,而是始终由上一节的 Host 特权判断保留原始权限。
| 渠道 | 当前行为基线 | 私聊初始值 | 群聊初始值 | 继续保留的渠道行为 |
| --- | --- | --- | --- | --- |
| 微信 | 仅绑定账号所有者可以发送消息 | `allowlist` 空名单;owner 由 Host 特权放行 | `allowlist` 空名单、默认不允许命令;当前仅作完整策略占位,Runtime 不读取 | 微信扫码身份、消息协议和回复机制 |
| 飞书 | 没有用户可见白名单设置;底层 `ownerOpenIds` 当前承担有效发送者边界 | 有 `*` 则 `open`,否则为 `allowlist` 空名单,owner 由 Host 特权放行 | 同私聊 | “仅 @ 响应 / 响应所有群消息”、群消息权限和 Topic Session |
| 钉钉 | 私聊直接处理,群聊被 @ 时处理 | `open` | `open` | Stream、`isInAtList`、AI Card、现有授权信息 |
| 企业微信 | 处理平台投递的私聊和群聊消息 | `open` | `open` | 企业微信自身可见范围、WebSocket 和流式回复 |
| QQ | 扫码机器人私聊仅绑定者;手动绑定的 `*` 为开放;群聊接受任意被 @ 的成员 | `*` 为 `open`,否则为 `allowlist` 空名单,owner 由 Host 特权放行 | `open` | `GROUP_AT_MESSAGE_CREATE`、Markdown 回复和群成员作用域 ID |
| Slack | 私聊直接响应,频道仅处理 `app_mention` | `open` | `open` | Socket Mode、提及、线程和流式消息 |
| Telegram | `compatible` 为私聊开放、群聊提及/回复;`private-allowlist` 为白名单私聊且拒绝群聊 | 按旧模式迁移 | 按旧模式迁移 | 提及、回复、Topic、Rich Message 和命令菜单 |
| Discord | 私信直接响应;服务器频道首次 @ 后进入机器人管理的 Thread | `open` | `open` | Gateway、@、Thread 创建与后续 Thread 路由 |
| WhatsApp | `self-only`、`private-allowlist`、`open` 三种模式 | 按旧模式迁移 | 按旧模式迁移 | 自聊、提及、回复、已读和输入状态 |
飞书在产品上没有旧白名单入口,因此不称为“迁移飞书白名单”。但当前代码确实用 `ownerOpenIds` 过滤普通消息和卡片操作者;如果直接把新版策略设为开放,会扩大现有访问范围。这里仅用它生成一次等价初始策略。初始化后:
- 新访问策略负责普通用户的消息和命令访问,原 owner 先走共享特权放行;
- `ownerOpenIds` 继续保留给接入所有者、修复校验和连接测试等原有用途;
- “群聊响应方式”继续独立生效;
- `ownerOpenIds` 不再作为第二套普通用户白名单叠加,只用于共享特权放行及既有修复校验,避免新增用户仍被隐藏门禁拒绝。
## 8. Telegram 和 WhatsApp 自动迁移
本节只处理 Telegram、WhatsApp 已存在的渠道访问配置。开发期曾使用但从未发布的统一结构 `{ mode, defaultCanExecuteCommands, users }` 不兼容、不迁移,也不在 Store 加载时回写;配置必须直接使用第 4 节定义的最终拆分结构。
### 8.1 Telegram
| 旧配置 | 新私聊设置 | 新群聊设置 |
| --- | --- | --- |
| `compatible` | `open`,默认可执行命令;旧 `allowedUsers` 保存在独立的 `allowlist.users`,当前不生效 | `open`,默认可执行命令 |
| `private-allowlist` | `allowlist`,迁移全部 `allowedUsers` 到 `allowlist.users`,每人可执行命令 | `allowlist`,空名单,即继续拒绝所有群成员 |
旧名单只属于私聊,不能自动复制为群成员名单。
### 8.2 WhatsApp
| 旧配置 | 新私聊设置 | 新群聊设置 |
| --- | --- | --- |
| `self-only` | `allowlist`,空名单;当前绑定账号由 Host 特权放行 | `allowlist`,空名单 |
| `private-allowlist` | `allowlist`,仅迁移全部 `allowedNumbers` 到 `allowlist.users`,允许命令;当前绑定账号由 Host 特权放行 | `allowlist`,空名单 |
| `open` | `open`,默认允许命令;旧号码保存在独立的 `allowlist.users`,当前不生效 | `open`,默认允许命令 |
旧 `allowedNumbers` 只用于私聊,不能复制为群成员名单。绑定账号身份仅保留在 Host,使用现有账号 JID 和身份匹配函数做特权判断,不写入可公开的访问策略。
### 8.3 迁移时机和原子性
1. Host 加载渠道 `config.json` 和 `workspaces.json`。
2. 若 `accessPolicies[botId]` 已存在,直接使用,绝不再次根据旧字段覆盖。
3. 若不存在,按第 7、8 节生成完整策略。
4. 先使用 `BotWorkspaceStore` 现有临时文件加 `rename` 方式原子写入包含新策略段的 `workspaces.json`。
5. 写入成功后再启动该机器人 Runtime,新策略成为唯一普通消息访问来源。
迁移必须幂等。空白名单也是有效配置,不能因为数组为空而被误判为“尚未迁移”。
为避免跨两个文件做脆弱事务,本期不强制删除 Telegram/WhatsApp `config.json` 中的旧字段;它们作为不可见、只读的回退数据保留,但 Runtime、状态接口和页面都不再使用。后续如需清理可单独实施,不能在本期双写一个无法表达私聊/群聊拆分及命令权限的旧模型。
迁移写入失败时,不得默认开放。受影响渠道不完成启动并显示可诊断错误,其他渠道继续运行;旧 `config.json` 未被修改,可在修复文件权限或磁盘问题后重试迁移。
## 9. 持久化与共享实现
### 9.1 复用 `BotWorkspaceStore`
继续使用 `workspaces.json` 版本 2,增加一个可选的顶层 `accessPolicies` 段:
```json
{
"version": 2,
"workspaces": {},
"agentPresets": {},
"contextEnhancement": {},
"deliveryTargets": {},
"accessPolicies": {
"bot_id": {
"direct": {},
"group": {}
}
}
}
```
这是向后兼容的增量字段,处理方式与现有可选的 `contextEnhancement` 类似,不为一个独立的可选段或内部 schema 调整引入新文档版本。首次为仍是 v1 的文档写入访问策略时,与现有投递目标逻辑一样写成 v2;已是 v2 的文档不变版本。这样回退到当前代码时,旧读取器仍能加载工作区等原有数据,只会忽略不认识的策略段。
需要在现有 Store 中增加的能力只有:
- `accessPolicyFor(botId)`;
- `setAccessPolicy(botId, policy, { incarnation })`;
- `ensure(botId, { initialAccessPolicy })` 初始化;
- `decorateStatus()` 返回当前策略;
- `reconcile()` 和机器人删除事务同步清理策略。
保存仍使用现有每机器人队列、临时文件、`0600` 权限和原子重命名。一次保存失败不能发布半份策略,私聊和群聊不能分两次落盘。
`accessPolicies` 的校验错误必须与工作区、Preset、上下文增强和投递目标隔离:某个机器人策略损坏时,只标记该机器人的访问策略不可用并拒绝其入站消息,不能让整份 `workspaces.json` 失效或影响其他已有功能。
### 9.2 共享权限模块
新增一个浏览器和 Host 均可导入的 `src/channels/shared/access-policy.mjs`,只负责:
- 默认值和完整配置校验;
- 用户标识归一化;
- `direct` / `group` 访问判断;
- 普通消息和命令两种判定结果;
- 自动迁移所需的简单构造函数。
渠道代码只负责提供当前事件的 `conversationType`、发送者候选标识及 WhatsApp 的既有匹配函数。共享模块不解析平台原始 Payload,不依赖 SDK。
### 9.3 动态读取,不重启机器人
生产装配层仿照现有上下文增强注入一个只读 Provider:
```js
{
botId,
getSettings: () => workspaces.accessPolicyFor(botId)
}
```
桥接器在事件到达时读取一次已提交快照。设置保存成功后,下一条事件自然读取新策略;不修改 Token、WebSocket、长轮询或 Runtime 生命周期,也不因改白名单重连机器人。
动态 Provider 同时提供一个只读的 `isPrivileged(senderIds, conversationType)` 判断,优先复用渠道现有 owner/扫码身份及 WhatsApp JID 比较。该判断不持久化到 `accessPolicies`,因此访问设置无法意外撤销接入者的原始权限。
### 9.4 RPC
复用现有各渠道 RPC 和 `createWorkspaceAwareController()`,统一增加:
```text
bot.access-policy.set
{ botId, policy }
```
该端点复用现有 RPC authority、Bot 存在性检查、incarnation 防止删除后同 ID 串写,以及完整状态快照返回方式。Telegram 和 WhatsApp 当前同名端点改为接收新版统一结构,不再保存旧渠道字段。
不新增公网 HTTP 权限接口,也不把访问策略混入主动投递 API。
### 9.5 设置页
在现有机器人设置页中增加共享 `AccessPolicyEditor`:
- 页面根据 `channel + botId` 调用对应渠道现有 RPC;
- “访问设置”固定为 `BOT_SETTINGS_TABS` 中紧跟“投递设置”的第二项;
- 同一页同时渲染 `direct` 和 `group`,复用同一场景编辑组件;
- 复用现有页签、按钮、表单、错误提示和中英文 i18n;
- 渠道定义只提供用户标识名称、占位示例及 `groupSupported`,不复制页面;
- 一次提交完整的 `direct + group` 策略,保存后用 RPC 返回的状态快照更新页面,不做乐观假成功。
## 10. 六条入站接入路径
九个渠道实际只需接入六条消息路径:
| 接入位置 | 覆盖渠道 | 改动 |
| --- | --- | --- |
| `src/channels/shared/text-harness-bridge.mjs` | Slack、Telegram、Discord、WhatsApp | 在共享 `accept()` 中加入一次访问判断和命令判断 |
| `src/channels/weixin/weixin-bridge.mjs` | 微信 | 用共享策略替换普通消息中的硬编码 owner-only 判断,所有者信息继续用于接入和连接测试 |
| `src/channels/feishu/bridge.mjs` | 飞书 | 普通消息和卡片操作改用共享策略;保留群聊响应方式及修复流程原有校验 |
| `src/channels/dingtalk/dingtalk-bridge.mjs` | 钉钉 | 在进入命令、审批和 Session 流程前增加共享判断 |
| `src/channels/wecom/wecom-bridge.mjs` | 企业微信 | 在现有消息快速路径前增加共享判断 |
| `src/channels/qq/qq-bridge.mjs` | QQ | 用共享策略替换私聊 owner 判断,保留群聊 @ 和群作用域成员 ID |
Telegram 的 `telegramInboundAllowed()`、WhatsApp 的 `whatsappInboundAllowed()` 以及微信、飞书、QQ 的旧普通消息硬编码门禁,在等价初始化完成后退出活动入站路径,避免出现“新版允许、旧版又拒绝”的双重权限来源。可暂时保留纯函数供迁移和回归测试使用,但 Runtime 不再调用。
飞书卡片必须区分:
- Harness 问题回答和审批:只要求普通访问权限;
- Session、Workspace、模型、Preset 等命令等价操作:还要求命令权限。
这样既不会让卡片绕过命令限制,也不会误伤正常的人机交互。
## 11. 必须保持不变的功能
新策略与以下现有机制是逻辑“且”关系,不能替代或删除:
| 渠道/能力 | 必须保持的行为 |
| --- | --- |
| 飞书 | `groupResponseMode`、群消息权限授权、Topic Session、卡片回调、`/repair` 平台校验 |
| 钉钉 | 群聊 `isInAtList`、Session Webhook 安全校验、AI Card 和 @发送者 |
| 企业微信 | 平台自身授权范围、群回调语义和流式回复 |
| QQ | `GROUP_AT_MESSAGE_CREATE`、私聊/群聊不同 Open ID、Markdown 回复 |
| Slack | 私聊和 `app_mention`、Thread、Socket Mode 和流式消息 |
| Telegram | 群聊提及/回复、Topic、长轮询、Rich Message 和原生命令菜单 |
| Discord | 私信、首次 @、机器人管理 Thread 和消息编辑流 |
| WhatsApp | 自聊识别、群聊提及/回复、回声过滤、已读和输入状态 |
| 九渠道共享 | 去重、批量输入、问题、审批、工作区、Session、模型、Preset、上下文增强、附件和产物回传 |
群聊最终可处理条件示例:
```text
渠道把该群消息交给机器人
AND 现有 @/回复/Thread/响应方式成立
AND 发送者满足群聊访问策略
AND(若为命令)发送者具有群聊命令权限
```
本方案不改变群内回复的可见性。机器人在群里回复后,群内其他成员是否可见仍由平台和群成员关系决定;白名单只控制谁能触发 dsh-im。
## 12. 错误、安全与并发
- 配置缺失由启动初始化补齐;初始化完成后,运行时缺失或损坏的策略按拒绝处理,不能回退为开放。
- 访问判断在附件下载和 Harness 调用前完成,拒绝事件不能产生模型成本或本地 Session。
- RPC 只返回当前机器人显式保存的策略,不返回 Token、Secret、平台原始事件或被动观察到的成员目录。
- 日志只记录渠道、`botId`、场景和拒绝原因,不记录完整用户 ID、消息正文或旧白名单内容。
- 保存使用完整策略和现有机器人 incarnation;机器人被删除或重新接入后,旧页面提交必须失败。
- 同一机器人的设置写入沿用现有队列串行化;最后一个成功提交的完整策略生效。
- 权限检查使用事件到达时的已提交快照。保存中的草稿和写盘失败内容绝不影响运行态。
- 被拒绝的命令不得转为普通 Prompt;被拒绝的卡片命令不得执行一半后再报错。
## 13. 最小改动清单
1. 新增共享访问策略模块和共享 RPC Payload 校验。
2. 在 `BotWorkspaceStore` v2 文档中增加可选访问策略段的读取、原子保存、状态装饰和删除清理。
3. 在九渠道生产装配中为现有机器人自动初始化/迁移策略,并向 Runtime 注入动态 Provider。
4. 在六条入站路径接入共享判断;飞书卡片额外区分普通交互和命令等价操作。
5. 为现有命令分支补齐无副作用识别,避免用 `/` 前缀粗略判断。
6. 在各渠道现有 RPC 中复用统一的 `bot.access-policy.set`。
7. 在“投递设置”后增加第二个顶层页签“访问设置”,并新增一个共享编辑组件。
8. 删除 Telegram、WhatsApp 机器人卡片上的旧访问设置组件;保留自动迁移和旧字段只读兼容。
9. 更新中英文 README、设置文案、帮助说明和 CHANGELOG。
10. 增加共享、Store、迁移、六条桥接路径、九渠道页面及回归测试;不增加第三方依赖。
## 14. 测试方案
### 14.1 共享策略
- 私聊和群聊互不影响。
- `open`、`allowlist`、空白名单和未知发送者结果正确。
- 开放模式默认命令权限及用户覆盖正确。
- 白名单模式逐用户命令权限正确。
- 开放模式命令例外与白名单分别保存;来回切换模式不会重解释、清空或覆盖另一份名单。
- deny-all 或删除全部可编辑用户行时,未写入访问策略的原 owner/扫码接入者仍可发送普通消息并执行命令;通配符 `*` 不能成为“所有人永久特权”。
- 重复 ID、空 ID、超长 ID、控制字符、缺字段和多余字段被拒绝。
- WhatsApp 多 JID/号码匹配复用现有算法。
- 策略损坏时拒绝而不是开放。
### 14.2 Store 和迁移
- v1 文档首次写入策略时无损升级到 v2;v2 文档保持版本不变。
- 工作区、Preset、上下文增强和投递目标在策略初始化、保存、损坏及删除场景下均不受影响。
- 已有 `accessPolicies[botId]` 时不重复迁移。
- 空名单不会触发第二次迁移。
- Telegram 两种模式和 WhatsApp 三种模式逐项符合第 8 节。
- 微信、飞书、QQ 的当前所有者边界初始化后等价。
- 写盘失败不发布新策略,临时文件不会被误读。
- 删除机器人同时清理访问策略;同 ID 重新接入不会继承旧策略。
### 14.3 入站行为
九渠道分别覆盖:
- 允许用户的普通私聊消息进入原处理流程;拒绝用户不调用 Harness。
- 允许群成员在满足渠道原有触发条件时进入;拒绝成员不能触发。
- 同一用户私聊允许、群聊拒绝,以及相反组合。
- 可执行命令用户执行现有命令;不可执行命令用户收到本地拒绝且不触发副作用。
- 未识别的 `/foo` 保持原有普通消息行为。
- 问题回答和审批不被误判为命令。
- 图片和文件在权限通过后才下载。
- 重复平台事件不重复回复;被拒绝事件不会因重连绕过策略。
渠道专项回归:
- 飞书 mention/all 两种群聊响应方式、群消息授权、Topic、卡片问题/审批和命令卡片。
- 钉钉群 @、AI Card、Session Webhook。
- 企业微信群聊流式回复。
- QQ 扫码私聊所有者、手动绑定 `*`、群成员 @ 和两类 Open ID。
- Slack `app_mention` 和 Thread。
- Telegram 提及、回复、Topic、长轮询和 Rich Message。
- Discord 首次 @ 建 Thread 和已管理 Thread 后续消息。
- WhatsApp 自聊、联系人私聊、群提及/回复、LID/备用 JID 和回声过滤。
- 微信所有者私聊及当前不支持群聊的页面提示。
### 14.4 设置页
- 九渠道机器人齿轮页均显示两个顶层页签:默认的“投递设置”和紧随其后的“访问设置”。
- 访问设置在同一页分别读取和编辑私聊、群聊策略,一次保存两者;每个场景的白名单与命令权限例外也各自保留,不丢草稿、互不串数据。
- 空白名单警告、命令开关、用户新增/删除、保存失败和重复 ID 提示正确。
- Telegram、WhatsApp 旧访问卡片不再出现。
- 飞书“群聊响应方式”仍在原位置并正常保存、授权。
- 微信的群聊区域显示不支持说明。
- 中英文文案完整,键盘页签和表单标签可访问。
### 14.5 全量与实机
自动化最终执行 `npm run check`,并确保现有工作区、上下文增强、主动投递、附件、产物、问题、审批、命令、连接检查、机器人删除与重连测试全部通过。
按本次实施要求,使用本机已登录的飞书客户端,选择一个现有机器人完成群聊和私聊实机验收:
1. 设置页显示迁移后的完整策略,且“访问设置”位于“投递设置”之后;
2. 群聊仍要求真实 `@`,不改变“仅在 @机器人时响应”规则;
3. 在群策略为空白名单、默认禁止命令时,原扫码 owner 仍能执行一个本地命令;
4. 同一 owner 的普通群消息仍能进入 Harness 并得到回复;
5. 在私聊策略为空白名单、默认禁止命令时,原扫码 owner 仍能执行本地命令;
6. 同一 owner 的普通私聊消息仍能进入 Harness 并得到回复;
7. 实测前后策略保持一致,没有为测试临时扩大白名单。
已登录客户端不能安全模拟另一个群成员,因此非 owner 的静默拒绝、命令拒绝和不下载附件由桥接器回归测试覆盖,不把 mock 结果描述成实机结论。
## 15. 验收标准
以下条件全部满足才可认为 Issue #95 完成:
1. 九个 IM 渠道都使用同一策略语义,AI Office 未被修改。
2. 每个机器人的第二个顶层页签均为“访问设置”;私聊、群聊互相独立,每个场景内的白名单和开放模式命令权限例外也分别保存。
3. 群聊只按发送成员控制,不存在群 ID 或群会话范围配置。
4. Telegram、WhatsApp 旧设置自动、幂等、无损迁移,旧入口消失。
5. 飞书群聊响应方式和群消息授权保持原功能;飞书没有被描述成已有用户白名单产品功能。
6. 微信、飞书、QQ 的隐藏发送者边界在切换到新策略时不扩大访问范围。
7. 所有既有渠道触发、Thread/Topic、Session、流式回复、附件、审批和卡片能力通过回归。
8. 普通访问被拒绝时不调用 Harness;命令被拒绝时不产生任何命令副作用。
9. 设置保存无需重连,机器人删除会清理策略,旧页面不能写入已删除机器人的配置。
10. 没有新增数据库、第三方依赖、群目录、角色系统或九份重复实现。
## 16. 实施顺序与回退
建议按以下顺序实施:
1. 共享策略及 Store v2 可选段测试;
2. 九渠道初始化/迁移测试;
3. 六条入站路径接入和命令识别测试;
4. RPC 与共享设置页;
5. 移除 Telegram、WhatsApp 旧入口;
6. 九渠道回归、全量构建和实机验收。
新策略只有在迁移完成、六条入站路径和设置页均通过回归后才取得唯一处理权。回退代码版本时旧 Telegram/WhatsApp 字段仍在,原 `config.json` 未被迁移过程改写;v2 `workspaces.json` 中新增的 `accessPolicies` 可由当前旧版本忽略,因此工作区等旧功能仍可读取。需要明确的限制是:回退后若旧代码再次写入 `workspaces.json`,会丢弃它不认识的新策略段;再次升级时可从保留的旧字段重新迁移,但不承诺恢复只存在于新版中的群聊和命令权限修改。
## 17. 实施与验收记录
### 17.1 自动化与构建
- `npm run check` 通过:1992 项测试全部通过,无失败、取消或跳过。
- 客户端和 Host 构建通过,`lib/client.js`、`lib/index.js` 已更新。
- 包产物验证通过,`git diff --check` 通过。
- 九渠道 Host 初始化、旧配置迁移、统一 RPC、动态策略读取和 owner 特权均有定向回归。
- 五个自定义桥与四个共享文字桥均覆盖普通拒绝、命令拒绝、owner 放行,以及拒绝前不下载附件、不创建 Discord Thread、不调用 Harness。
### 17.2 本地飞书群聊与私聊验收
验收时间:2026-09-01。使用本机已安装并登录的飞书客户端。
| 项目 | 实测值 |
| --- | --- |
| 机器人 | `今天是牢梁`(`bot_9577c8572d454122a4ef86180bf13566`) |
| 群聊 | `DeepSeek大会` |
| 群聊原生触发规则 | `仅在 @机器人时响应`,保持不变 |
| 群聊实测策略 | `group.mode = allowlist`、`group.allowlist.users = []`、`group.open.defaultCanExecuteCommands = false`、`group.open.commandPermissionOverrides = []` |
| 私聊实测策略 | `direct.mode = allowlist`、`direct.allowlist.users = []`、`direct.open.defaultCanExecuteCommands = false`、`direct.open.commandPermissionOverrides = []` |
| owner 数据 | owner 不出现在公开策略用户行,只由 Host 内部特权判断放行 |
群聊真实消息结果(本次实施验收执行):
1. 发送 `@今天是牢梁 /status`,机器人回复“连接正常”,并返回当前工作区和 Agent Preset;证明原扫码 owner 在空白名单、默认禁止命令时仍保留本地命令权限。
2. 发送普通标记消息 `@今天是牢梁 ISSUE95_OWNER_OK_20260901`,机器人回复“已收到。”;证明同一 owner 的普通群消息仍进入原 Harness 流程。
3. 实测前后 `workspaces.json` 中该机器人的群策略完全一致,没有因验收临时开放访问或加入 owner 用户行。
私聊真实消息结果(用户手工执行并确认):
1. 在同一机器人私聊中执行本地命令,命令正常响应;证明 owner 在私聊空白名单、默认禁止命令时仍保留本地命令权限。
2. 向同一机器人发送普通私聊消息,消息正常进入 Harness 并得到回复;证明 owner 的普通私聊访问也不受白名单影响。
3. 私聊验收未修改访问设置;记录时再次确认 `direct` 策略仍为空白名单且默认禁止命令。
结论:指定飞书机器人的群聊和私聊验收均通过。群聊结果由本次实施验收直接执行,私聊结果由用户手工执行并确认;二者共同验证 owner/扫码者“不受白名单和命令权限影响、保留原始权限”的最新要求成立。

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,351 @@
import * as React from 'react';
import {
DEFAULT_ACCESS_POLICY,
normalizeAccessPolicy,
validateAccessPolicy,
} from '../../src/channels/shared/access-policy.mjs';
import { h, localizeText } from './i18n.js';
export const ACCESS_POLICY_ENDPOINT = 'bot.access-policy.set';
export const ACCESS_CHANNEL_DEFINITIONS = Object.freeze({
weixin: Object.freeze({
directUserLabel: '微信用户 ID',
directPlaceholder: '填写微信用户 ID',
groupSupported: false,
}),
feishu: Object.freeze({
directUserLabel: '飞书 Open ID',
directPlaceholder: 'ou_xxx',
groupUserLabel: '群成员 Open ID',
groupPlaceholder: 'ou_xxx',
}),
dingtalk: Object.freeze({
directUserLabel: '钉钉用户 ID',
directPlaceholder: '填写 senderStaffId 或 senderId',
groupUserLabel: '群成员用户 ID',
groupPlaceholder: '填写 senderStaffId 或 senderId',
}),
wecom: Object.freeze({
directUserLabel: '企业微信用户 ID',
directPlaceholder: '填写 userid',
groupUserLabel: '群成员用户 ID',
groupPlaceholder: '填写 userid',
}),
qq: Object.freeze({
directUserLabel: 'QQ User Open ID',
directPlaceholder: '填写 user_openid',
groupUserLabel: '群成员 Open ID',
groupPlaceholder: '填写 member_openid',
}),
slack: Object.freeze({
directUserLabel: 'Slack User ID',
directPlaceholder: 'U0123456789',
groupUserLabel: '群成员 User ID',
groupPlaceholder: 'U0123456789',
}),
telegram: Object.freeze({
directUserLabel: 'Telegram User ID',
directPlaceholder: '填写数字 User ID',
groupUserLabel: '群成员 User ID',
groupPlaceholder: '填写数字 User ID',
}),
discord: Object.freeze({
directUserLabel: 'Discord User ID',
directPlaceholder: '填写数字 User ID',
groupUserLabel: '群成员 User ID',
groupPlaceholder: '填写数字 User ID',
}),
whatsapp: Object.freeze({
directUserLabel: 'WhatsApp 电话号码或 JID',
directPlaceholder: '8613800000000 或完整 JID',
groupUserLabel: '群成员电话号码或 JID',
groupPlaceholder: '8613800000000 或完整 JID',
}),
});
function clonePolicy(policy) {
const cloneScope = (scope) => ({
mode: scope.mode,
open: {
defaultCanExecuteCommands: scope.open.defaultCanExecuteCommands,
commandPermissionOverrides: scope.open.commandPermissionOverrides.map((user) => ({
...user,
})),
},
allowlist: {
users: scope.allowlist.users.map((user) => ({ ...user })),
},
});
return {
direct: cloneScope(policy.direct),
group: cloneScope(policy.group),
};
}
function unwrapRpcResult(result) {
if (result?.ok === true) return result.value;
if (result?.ok === false) {
const error = new Error(result.error?.message || '访问设置保存失败,请稍后重试。');
error.code = result.error?.code;
throw error;
}
return result;
}
function policyFromSnapshot(value, botId) {
const source = value?.snapshot ?? value;
const bot = Array.isArray(source?.bots)
? source.bots.find((entry) => entry?.botId === botId)
: null;
return normalizeAccessPolicy(bot?.accessPolicy ?? source?.accessPolicy ?? source?.policy);
}
function commandValue(value) {
return value === 'allow';
}
function ScenePolicyEditor({
scene,
title,
policy,
userLabel,
placeholder,
disabled = false,
unsupported = false,
onChange,
}) {
const ownerHelpId = React.useId();
const emptyAllowlistHelpId = React.useId();
const allowlist = policy.mode === 'allowlist';
const collectionKey = allowlist ? 'users' : 'commandPermissionOverrides';
const branchKey = allowlist ? 'allowlist' : 'open';
const users = policy[branchKey][collectionKey];
const emptyAllowlist = allowlist && users.length === 0;
const updateUsers = (nextUsers) => onChange({
...policy,
[branchKey]: {
...policy[branchKey],
[collectionKey]: nextUsers,
},
});
const updateUser = (index, patch) => updateUsers(users.map((user, userIndex) => (
userIndex === index ? { ...user, ...patch } : user
)));
return h('fieldset', {
className: 'dim-accessScene',
disabled,
'data-scene': scene,
'aria-label': localizeText(title),
},
h('legend', null,
h('span', { className: 'dim-accessLegendContent' },
h('span', null, title),
h('span', { className: 'dim-channelHelp dim-accessLegendHelp' },
h('button', {
type: 'button',
className: 'dim-channelHelpButton',
'aria-label': [localizeText(title), localizeText('查看访问权限说明')].join(' '),
'aria-describedby': ownerHelpId,
}, h('span', { 'aria-hidden': true }, '?')),
h('span', {
id: ownerHelpId,
className: 'dim-channelTooltip dim-accessHelpTooltip',
role: 'tooltip',
}, '原所有者或扫码接入者始终可以访问并执行命令;以下设置仅约束其他用户。')))),
unsupported
? h('div', { className: 'dim-accessUnsupported', role: 'note' },
h('strong', null, '当前渠道不支持群聊'),
h('p', null, '此区域无需配置,保存私聊设置时会保留现有群聊策略。'))
: h(React.Fragment, null,
h('div', { className: 'dim-accessControls', 'data-mode': policy.mode },
h('label', { className: 'dim-accessField' },
h('span', null, '访问模式'),
h('select', {
value: policy.mode,
'aria-label': [localizeText(title), localizeText('访问模式')].join(' '),
onChange: (event) => onChange({ ...policy, mode: event.target.value }),
},
h('option', { value: 'open' }, '允许所有用户'),
h('option', { value: 'allowlist' }, '仅白名单用户'))),
allowlist ? null : h('label', { className: 'dim-accessField' },
h('span', null, '默认命令权限'),
h('select', {
value: policy.open.defaultCanExecuteCommands ? 'allow' : 'deny',
'aria-label': [localizeText(title), localizeText('默认命令权限')].join(' '),
onChange: (event) => onChange({
...policy,
open: {
...policy.open,
defaultCanExecuteCommands: commandValue(event.target.value),
},
}),
},
h('option', { value: 'allow' }, '可以执行命令'),
h('option', { value: 'deny' }, '不可以执行命令')))),
h('div', { className: 'dim-accessUsers' },
h('div', { className: 'dim-accessUsersHeading' },
h('div', { className: 'dim-accessUsersTitle' },
h('strong', null, allowlist ? '白名单用户' : '命令权限例外'),
emptyAllowlist
? h('span', { className: 'dim-channelHelp dim-accessUsersHelp' },
h('button', {
type: 'button',
className: 'dim-channelHelpButton',
'aria-label': [localizeText(title), localizeText('查看白名单说明')].join(' '),
'aria-describedby': emptyAllowlistHelpId,
}, h('span', { 'aria-hidden': true }, '?')),
h('span', {
id: emptyAllowlistHelpId,
className: 'dim-channelTooltip dim-accessEmptyAllowlistTooltip',
role: 'tooltip',
}, '当前没有白名单用户,保存后普通用户将无法使用机器人。'))
: null),
h('button', {
type: 'button',
className: 'dim-deliveryButton dim-accessAddUser',
'aria-label': [localizeText(title), localizeText('新增用户')].join(' '),
title: localizeText('新增用户'),
onClick: () => updateUsers([...users, {
id: '',
canExecuteCommands: allowlist
? false
: !policy.open.defaultCanExecuteCommands,
}]),
}, h('span', { 'aria-hidden': true }, '+'))),
users.length === 0
? h('div', { className: 'dim-accessUsersEmpty' }, '尚未添加用户')
: h('ul', { className: 'dim-accessUserList' }, users.map((user, index) =>
h('li', { key: `${scene}-${policy.mode}-${index}`, className: 'dim-accessUserRow' },
h('label', { className: 'dim-accessField dim-accessUserId' },
h('span', null, userLabel),
h('input', {
value: user.id,
maxLength: 256,
required: true,
autoCapitalize: 'none',
autoCorrect: 'off',
spellCheck: false,
placeholder,
'aria-label': [localizeText(title), localizeText(userLabel), index + 1].join(' '),
onChange: (event) => updateUser(index, { id: event.target.value }),
})),
h('label', { className: 'dim-accessField dim-accessUserCommand' },
h('span', null, '命令权限'),
h('select', {
value: user.canExecuteCommands ? 'allow' : 'deny',
'aria-label': [
localizeText(title), localizeText('用户'), index + 1,
localizeText('命令权限'),
].join(' '),
onChange: (event) => updateUser(index, {
canExecuteCommands: commandValue(event.target.value),
}),
},
h('option', { value: 'allow' }, '可以执行命令'),
h('option', { value: 'deny' }, '不可以执行命令'))),
h('button', {
type: 'button',
className: 'dim-deliveryButton dim-accessDeleteUser',
'data-kind': 'danger',
'aria-label': [
localizeText(title), localizeText('删除'),
localizeText('用户'), index + 1,
].join(' '),
onClick: () => updateUsers(users.filter((_, userIndex) => userIndex !== index)),
}, '删除')))))));
}
export function AccessPolicySettingsPage({ channel, account, rpcCall, onSaved }) {
const definition = ACCESS_CHANNEL_DEFINITIONS[channel];
const initialPolicy = normalizeAccessPolicy(account?.accessPolicy);
const initialKey = JSON.stringify(initialPolicy);
const [draft, setDraft] = React.useState(() => clonePolicy(
initialPolicy ?? DEFAULT_ACCESS_POLICY,
));
const [saving, setSaving] = React.useState(false);
const [feedback, setFeedback] = React.useState(null);
React.useEffect(() => {
const next = normalizeAccessPolicy(account?.accessPolicy);
setDraft(clonePolicy(next ?? DEFAULT_ACCESS_POLICY));
}, [account?.botId, initialKey]);
React.useEffect(() => {
setFeedback(null);
}, [account?.botId]);
if (!definition) {
return h('div', { className: 'dim-accessState', role: 'alert' },
'当前渠道暂不支持访问设置。');
}
const save = async (event) => {
event.preventDefault();
setFeedback(null);
setSaving(true);
try {
const policy = validateAccessPolicy(draft);
if (typeof rpcCall !== 'function') throw new Error('访问设置暂不可用。');
const value = unwrapRpcResult(await rpcCall(ACCESS_POLICY_ENDPOINT, {
botId: account.botId,
policy,
}));
const saved = policyFromSnapshot(value, account.botId);
if (!saved) throw new Error('服务没有返回已保存的访问策略,请刷新后重试。');
setDraft(clonePolicy(saved));
onSaved?.(saved);
setFeedback({ tone: 'success', message: '访问设置已保存。' });
} catch (error) {
setFeedback({
tone: 'error',
message: error?.message || '访问设置保存失败,请稍后重试。',
});
} finally {
setSaving(false);
}
};
return h('form', {
className: 'dim-accessPage',
onSubmit: (event) => void save(event),
},
initialPolicy
? null
: h('div', { className: 'dim-accessState', role: 'alert' },
'访问策略尚未就绪,请返回机器人列表刷新后重试。'),
h(ScenePolicyEditor, {
scene: 'direct',
title: '私聊',
policy: draft.direct,
userLabel: definition.directUserLabel,
placeholder: definition.directPlaceholder,
disabled: saving,
onChange: (direct) => { setDraft((current) => ({ ...current, direct })); setFeedback(null); },
}),
h(ScenePolicyEditor, {
scene: 'group',
title: '群聊',
policy: draft.group,
userLabel: definition.groupUserLabel ?? definition.directUserLabel,
placeholder: definition.groupPlaceholder ?? definition.directPlaceholder,
disabled: saving || definition.groupSupported === false,
unsupported: definition.groupSupported === false,
onChange: (group) => { setDraft((current) => ({ ...current, group })); setFeedback(null); },
}),
feedback ? h('p', {
className: 'dim-accessFeedback',
'data-tone': feedback.tone,
role: feedback.tone === 'error' ? 'alert' : 'status',
'aria-live': 'polite',
}, feedback.message) : null,
h('div', { className: 'dim-accessActions' },
h('button', {
type: 'submit',
className: 'dim-deliveryButton',
'data-kind': 'primary',
disabled: saving || !initialPolicy,
}, saving ? '正在保存…' : '保存访问设置')));
}

View file

@ -22,7 +22,7 @@ function SettingsGlyph() {
h('path', { d: 'M19.4 15a1.7 1.7 0 0 0 .34 1.88l.06.06-2.83 2.83-.06-.06a1.7 1.7 0 0 0-1.88-.34 1.7 1.7 0 0 0-1.03 1.55V21h-4v-.08A1.7 1.7 0 0 0 8.97 19.4a1.7 1.7 0 0 0-1.88.34l-.06.06-2.83-2.83.06-.06A1.7 1.7 0 0 0 4.6 15a1.7 1.7 0 0 0-1.52-1.03H3v-4h.08A1.7 1.7 0 0 0 4.6 8.97a1.7 1.7 0 0 0-.34-1.88l-.06-.06L7.03 4.2l.06.06a1.7 1.7 0 0 0 1.88.34A1.7 1.7 0 0 0 10 3.08V3h4v.08a1.7 1.7 0 0 0 1.03 1.52 1.7 1.7 0 0 0 1.88-.34l.06-.06 2.83 2.83-.06.06a1.7 1.7 0 0 0-.34 1.88A1.7 1.7 0 0 0 20.92 10H21v4h-.08A1.7 1.7 0 0 0 19.4 15Z' }));
}
export function BotSettingsButton({ channel, botId, botName, connected }) {
export function BotSettingsButton({ channel, botId, botName, connected, accessPolicy }) {
const { openBotSettings } = React.useContext(BotSettingsContext);
const tooltipId = React.useId();
return h('span', { className: 'dim-botSettingsAction' },
@ -37,6 +37,7 @@ export function BotSettingsButton({ channel, botId, botName, connected }) {
botId,
botName,
connected: Boolean(connected),
accessPolicy,
}),
}, h(SettingsGlyph)),
h('span', {

View file

@ -1,5 +1,6 @@
import { normalizeAgentPresetCatalog, normalizeAgentPresetId, SET_AGENT_PRESET_ENDPOINT } from '../../agent-preset.js';
import { normalizeLastMessageError } from '../../last-message-error.js';
import { normalizeAccessPolicy } from '../../../../src/channels/shared/access-policy.mjs';
import { normalizeContextEnhancementConfig } from '../../../../src/channels/shared/context-enhancement.mjs';
export const DINGTALK_RPC_CHANNEL = '/dingtalk';
@ -15,6 +16,7 @@ export const DINGTALK_ENDPOINTS = Object.freeze({
setWorkspace: 'bot.workspace.set',
setAgentPreset: SET_AGENT_PRESET_ENDPOINT,
setContextEnhancement: 'bot.context-enhancement.set',
setAccessPolicy: 'bot.access-policy.set',
});
const ACCOUNT_STATES = new Set(['connected', 'connecting', 'offline', 'error']);
@ -165,6 +167,9 @@ function normalizeBot(value) {
workspace: optionalString(value.workspace, 4_096) ?? '',
agentPreset: normalizeAgentPresetId(value.agentPreset),
contextEnhancement: normalizeContextEnhancementConfig(value.contextEnhancement),
...(Object.hasOwn(value, 'accessPolicy')
? { accessPolicy: normalizeAccessPolicy(value.accessPolicy) }
: {}),
bot: {
name: optionalString(bot.name, 100) ?? '钉钉机器人',
clientIdMasked: optionalString(bot.clientIdMasked, 140) ?? '已安全保存',

View file

@ -253,6 +253,7 @@ export function AccountCard({
botId: account.botId,
botName: account.bot.name,
connected: account.connected,
accessPolicy: account.accessPolicy,
}))),
h(WorkspaceEditor, {
workspace: account.workspace,

View file

@ -8,6 +8,7 @@
import { normalizeAgentPresetCatalog, normalizeAgentPresetId } from "../../agent-preset.js";
import { normalizeLastMessageError } from "../../last-message-error.js";
import { normalizeAccessPolicy } from "../../../../src/channels/shared/access-policy.mjs";
import { normalizeContextEnhancementConfig } from "../../../../src/channels/shared/context-enhancement.mjs";
export const FEISHU_RPC_CHANNEL = "/feishu";
@ -26,6 +27,7 @@ export const FEISHU_ENDPOINTS = Object.freeze({
setWorkspace: "bot.workspace.set",
setAgentPreset: "bot.preset.set",
setContextEnhancement: "bot.context-enhancement.set",
setAccessPolicy: "bot.access-policy.set",
setGroupResponseMode: "bot.group-response-mode.set",
// Kept for rolling upgrades. The multi-bot UI never calls these endpoints.
testConnection: "connection.test",
@ -206,6 +208,9 @@ export function normalizeBotConnection(value, fallbackBotId) {
workspace: optionalString(value.workspace)?.slice(0, 4_096) ?? "",
agentPreset: normalizeAgentPresetId(value.agentPreset),
contextEnhancement: normalizeContextEnhancementConfig(value.contextEnhancement),
...(Object.hasOwn(value, "accessPolicy")
? { accessPolicy: normalizeAccessPolicy(value.accessPolicy) }
: {}),
groupResponseMode: normalizeGroupResponseMode(value.groupResponseMode),
groupMessagePermissionGranted: value.groupMessagePermissionGranted === true,
bot: normalizeBot(value.bot),

View file

@ -609,6 +609,7 @@ export function BotCard({
botId: connection.botId,
botName: bot.name,
connected,
accessPolicy: connection.accessPolicy,
})),
),
h(WorkspaceEditor, {

View file

@ -1,5 +1,6 @@
import { normalizeAgentPresetCatalog, normalizeAgentPresetId, SET_AGENT_PRESET_ENDPOINT } from '../../agent-preset.js';
import { normalizeLastMessageError } from '../../last-message-error.js';
import { normalizeAccessPolicy } from '../../../../src/channels/shared/access-policy.mjs';
import { normalizeContextEnhancementConfig } from '../../../../src/channels/shared/context-enhancement.mjs';
export const QQ_RPC_CHANNEL = '/qq';
@ -15,6 +16,7 @@ export const QQ_ENDPOINTS = Object.freeze({
setWorkspace: 'bot.workspace.set',
setAgentPreset: SET_AGENT_PRESET_ENDPOINT,
setContextEnhancement: 'bot.context-enhancement.set',
setAccessPolicy: 'bot.access-policy.set',
});
const PROVISION_STATES = new Set(['starting', 'pending', 'refreshing', 'connecting', 'connected', 'failed', 'cancelled']);
@ -90,6 +92,9 @@ function normalizeBot(value) {
workspace: text(value.workspace, '', 4_096),
agentPreset: normalizeAgentPresetId(value.agentPreset),
contextEnhancement: normalizeContextEnhancementConfig(value.contextEnhancement),
...(Object.hasOwn(value, 'accessPolicy')
? { accessPolicy: normalizeAccessPolicy(value.accessPolicy) }
: {}),
bot: {
name: text(value.bot?.name, 'QQ机器人', 100),
appIdMasked: text(value.bot?.appIdMasked, '应用标识已安全保存', 140),

View file

@ -197,6 +197,7 @@ export function AccountCard({
botId: account.botId,
botName: account.bot.name,
connected: account.connected,
accessPolicy: account.accessPolicy,
}))),
h(WorkspaceEditor, {
workspace: account.workspace,

View file

@ -1,5 +1,6 @@
import { normalizeAgentPresetCatalog, normalizeAgentPresetId, SET_AGENT_PRESET_ENDPOINT } from '../../agent-preset.js';
import { normalizeLastMessageError } from '../../last-message-error.js';
import { normalizeAccessPolicy } from '../../../../src/channels/shared/access-policy.mjs';
import { normalizeContextEnhancementConfig } from '../../../../src/channels/shared/context-enhancement.mjs';
const ACCOUNT_STATES = new Set(['connected', 'connecting', 'offline', 'error']);
@ -31,6 +32,7 @@ export const TOKEN_BOT_ENDPOINTS = Object.freeze({
setWorkspace: 'bot.workspace.set',
setAgentPreset: SET_AGENT_PRESET_ENDPOINT,
setContextEnhancement: 'bot.context-enhancement.set',
setAccessPolicy: 'bot.access-policy.set',
});
export function createTokenChannelApi(channel, connectionSummary, {
@ -60,6 +62,9 @@ export function createTokenChannelApi(channel, connectionSummary, {
workspace: text(value.workspace, '', 4_096),
agentPreset: normalizeAgentPresetId(value.agentPreset),
contextEnhancement: normalizeContextEnhancementConfig(value.contextEnhancement),
...(Object.hasOwn(value, 'accessPolicy')
? { accessPolicy: normalizeAccessPolicy(value.accessPolicy) }
: {}),
bot: {
name: text(value.bot?.name, `${channel}机器人`, 100),
username: text(value.bot?.username, '', 100),

View file

@ -103,6 +103,7 @@ export function createTokenChannelSettings(definition) {
botId: account.botId,
botName: account.bot.name,
connected: account.connected,
accessPolicy: account.accessPolicy,
}))),
h(WorkspaceEditor, {
workspace: account.workspace,

View file

@ -1,24 +1,9 @@
import { TOKEN_BOT_ENDPOINTS, createTokenChannelApi } from '../shared/token-api.js';
export const TELEGRAM_RPC_CHANNEL = '/telegram';
export const TELEGRAM_ENDPOINTS = Object.freeze({
...TOKEN_BOT_ENDPOINTS,
setAccessPolicy: 'bot.access-policy.set',
});
export const TELEGRAM_ENDPOINTS = TOKEN_BOT_ENDPOINTS;
const api = createTokenChannelApi('Telegram', ' Bot API 长轮询', {
normalizeBotExtension: (value) => {
const source = value?.accessPolicy;
const accessMode = source?.accessMode === 'private-allowlist'
? 'private-allowlist' : 'compatible';
const allowedUsers = Array.isArray(source?.allowedUsers)
? [...new Set(source.allowedUsers.filter((entry) => (
typeof entry === 'string' && /^[1-9]\d{0,15}$/.test(entry)
)))]
: [];
return { accessPolicy: { accessMode, allowedUsers } };
},
});
const api = createTokenChannelApi('Telegram', ' Bot API 长轮询');
export const unwrapRpcResult = api.unwrapRpcResult;
export const normalizeSnapshot = api.normalizeSnapshot;

View file

@ -1,121 +1,11 @@
import * as React from 'react';
import { TelegramLogoGlyph } from '../../channel-logos.js';
import { createTokenChannelSettings } from '../shared/token-channel.js';
import { h } from '../../i18n.js';
import {
TELEGRAM_ENDPOINTS,
telegramClientApi,
} from './api.js';
import { installTelegramStyles } from './styles.js';
function policyFor(account) {
return {
accessMode: account?.accessPolicy?.accessMode === 'private-allowlist'
? 'private-allowlist' : 'compatible',
allowedUsers: Array.isArray(account?.accessPolicy?.allowedUsers)
? account.accessPolicy.allowedUsers : [],
};
}
function allowedUsersFromText(value) {
const entries = value.split(/\r?\n/).map((entry) => entry.trim()).filter(Boolean);
if (entries.some((entry) => !/^[1-9]\d{0,15}$/.test(entry))) {
throw new TypeError('User ID 必须是 1–16 位正整数,每行一个。');
}
return [...new Set(entries)];
}
export function TelegramAccessSettings({ account, busy = false, onSave }) {
const policy = policyFor(account);
const sourceUsers = policy.allowedUsers.join('\n');
const accessHelpId = React.useId();
const [accessMode, setAccessMode] = React.useState(policy.accessMode);
const [allowedUsers, setAllowedUsers] = React.useState(sourceUsers);
const [error, setError] = React.useState(null);
React.useEffect(() => {
setAccessMode(policy.accessMode);
setAllowedUsers(sourceUsers);
setError(null);
}, [policy.accessMode, sourceUsers]);
const save = async (event) => {
event.preventDefault();
setError(null);
try {
const normalized = allowedUsersFromText(allowedUsers);
if (typeof onSave !== 'function') throw new Error('Telegram 访问设置暂不可用。');
await onSave({ accessMode, allowedUsers: normalized });
} catch (caught) {
setError(caught?.message ?? 'Telegram 访问设置保存失败。');
}
};
const privateAllowlist = accessMode === 'private-allowlist';
const savedPrivateAllowlist = policy.accessMode === 'private-allowlist';
const emptyAllowlist = privateAllowlist && allowedUsers.trim() === '';
return h('form', { className: 'dtg-access', onSubmit: save },
h('div', { className: 'dtg-accessHeading' },
h('strong', null, '访问设置'),
h('span', { className: 'dtg-accessStatus' },
h('span', { className: 'dtg-accessBadge', 'data-mode': policy.accessMode },
savedPrivateAllowlist ? '已生效:安全模式' : '已生效:兼容模式'),
h('span', { className: 'dtg-accessHelp' },
h('button', {
type: 'button',
className: 'dtg-accessHelpButton',
'aria-label': '查看 Telegram 访问模式说明',
'aria-describedby': accessHelpId,
}, h('span', { 'aria-hidden': 'true' }, '?')),
h('span', {
id: accessHelpId,
className: 'dtg-accessTooltip',
role: 'tooltip',
},
h('span', { className: 'dtg-accessTooltipItem' },
h('strong', null, '兼容模式'),
h('span', null, '保持原有行为:私聊直接响应,群聊在被提及或回复时响应。')),
h('span', { className: 'dtg-accessTooltipItem' },
h('strong', null, '安全模式'),
h('span', null, '群聊全部忽略,私聊仅允许白名单用户。')))))),
h('label', { className: 'dtg-accessField' },
h('span', null, '模式'),
h('select', {
value: accessMode,
disabled: busy,
'aria-label': 'Telegram 访问模式',
onChange: (event) => { setAccessMode(event.target.value); setError(null); },
},
h('option', { value: 'compatible' }, '兼容模式(默认)'),
h('option', { value: 'private-allowlist' }, '安全模式(私聊白名单)'))),
h('label', { className: 'dtg-accessField' },
h('span', null, '允许私聊的 Telegram User ID'),
h('textarea', {
value: allowedUsers,
disabled: busy || !privateAllowlist,
rows: 3,
placeholder: '每行一个数字 User ID',
'aria-label': '允许私聊的 Telegram User ID',
onChange: (event) => { setAllowedUsers(event.target.value); setError(null); },
}),
h('small', null, privateAllowlist
? '白名单仅属于当前机器人。'
: '兼容模式下暂不使用白名单,切换模式时会保留。')),
emptyAllowlist
? h('p', { className: 'dtg-accessWarning', role: 'status' },
'白名单为空;保存后该机器人会拒绝所有入站消息。')
: null,
error ? h('p', { className: 'dtg-accessError', role: 'alert' }, error) : null,
h('div', { className: 'dtg-accessActions' },
h('button', {
type: 'submit',
className: 'ddt-button',
'data-kind': 'secondary',
disabled: busy,
}, busy ? '正在保存…' : '保存访问设置')));
}
const channel = createTokenChannelSettings({
channel: 'Telegram',
endpoints: TELEGRAM_ENDPOINTS,
@ -129,8 +19,6 @@ const channel = createTokenChannelSettings({
emptyTitle: '接入 Telegram 机器人',
emptyDescription: '先通过 @BotFather 获取 Bot Token,再在这里完成接入。',
platformLabel: 'Telegram',
AccountSettings: TelegramAccessSettings,
accountSettingsEndpoint: TELEGRAM_ENDPOINTS.setAccessPolicy,
});
export const TelegramSettingsTab = channel.SettingsTab;

View file

@ -4,34 +4,6 @@ const CSS = String.raw`
.dtg-page { --ddt-accent: #229ed9; --ddt-accent-deep: #1687bd; --ddt-accent-wash: #eaf7fd; }
.dtg-avatar { color: #fff; background: #229ed9; }
.dtg-avatar svg { display: block; }
.dtg-access { min-width: 0; width: 100%; max-width: 100%; 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); }
.dtg-accessHeading { position: relative; min-width: 0; max-width: 100%; display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px 12px; }
.dtg-accessHeading > strong { min-width: 0; font-size: 13px; overflow-wrap: anywhere; }
.dtg-accessStatus { min-width: 0; max-width: 100%; flex: 0 1 auto; display: inline-flex; align-items: center; justify-content: flex-end; flex-wrap: wrap; gap: 6px; }
.dtg-accessBadge { min-width: 0; max-width: 100%; flex: 0 1 auto; padding: 3px 8px; border-radius: 999px; color: #1687bd; background: #eaf7fd; font-size: 11px; font-weight: 700; overflow-wrap: anywhere; text-align: center; }
.dtg-accessBadge[data-mode="private-allowlist"] { color: #a15c00; background: #fff3d6; }
.dtg-accessHelp { position: static; display: inline-flex; flex: none; }
.dtg-accessHelpButton { width: 20px; height: 20px; display: grid; place-items: center; padding: 0; border: 1px solid color-mix(in srgb, #229ed9 28%, var(--dsw-alias-border-l2, #dfe1e5)); border-radius: 50%; color: #1687bd; background: var(--dsw-alias-bg-layer-1, #fff); font: inherit; font-size: 12px; line-height: 1; font-weight: 750; cursor: help; transition: border-color .15s ease, color .15s ease, background .15s ease, box-shadow .15s ease; }
.dtg-accessHelpButton:hover { border-color: #229ed9; color: #1178a8; background: #eaf7fd; }
.dtg-accessHelpButton:focus-visible { outline: none; border-color: #229ed9; box-shadow: 0 0 0 3px color-mix(in srgb, #229ed9 18%, transparent); }
.dtg-accessTooltip { position: absolute; top: calc(100% + 8px); right: 0; z-index: 30; width: min(300px, 100%); max-width: 100%; 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; }
.dtg-accessTooltipItem { display: grid; gap: 2px; }
.dtg-accessTooltipItem + .dtg-accessTooltipItem { padding-top: 8px; border-top: 1px solid var(--dsw-alias-border-l2, #eef0f3); }
.dtg-accessTooltipItem strong { color: var(--dsw-alias-label-primary, #1f2329); font-size: 12px; line-height: 17px; font-weight: 700; }
.dtg-accessTooltipItem > span { color: var(--dsw-alias-label-secondary, #646a73); font-size: 11px; line-height: 16px; font-weight: 400; }
.dtg-accessHelp:hover .dtg-accessTooltip, .dtg-accessHelp:focus-within .dtg-accessTooltip { opacity: 1; visibility: visible; transform: translateY(0); }
.dtg-accessField { min-width: 0; max-width: 100%; display: grid; gap: 5px; color: var(--dsw-alias-label-primary, #1f2329); font-size: 12px; font-weight: 600; }
.dtg-accessField select, .dtg-accessField textarea { min-width: 0; width: 100%; max-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; }
.dtg-accessField select { height: 34px; padding: 0 9px; }
.dtg-accessField textarea { min-height: 68px; padding: 8px 9px; resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.dtg-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; }
.dtg-accessField > span, .dtg-accessField small { overflow-wrap: anywhere; }
.dtg-accessField small { color: var(--dsw-alias-label-secondary, #646a73); font-weight: 400; }
.dtg-accessWarning, .dtg-accessError { margin: 0; font-size: 12px; line-height: 1.5; }
.dtg-accessWarning { color: #a15c00; }
.dtg-accessError { color: var(--dsw-alias-state-error-primary, #d83931); }
.dtg-accessActions { min-width: 0; max-width: 100%; display: flex; justify-content: flex-end; flex-wrap: wrap; }
.dtg-accessActions .ddt-button { max-width: 100%; white-space: normal; }
`;
export function installTelegramStyles() {

View file

@ -1,5 +1,6 @@
import { normalizeAgentPresetCatalog, normalizeAgentPresetId, SET_AGENT_PRESET_ENDPOINT } from '../../agent-preset.js';
import { normalizeLastMessageError } from '../../last-message-error.js';
import { normalizeAccessPolicy } from '../../../../src/channels/shared/access-policy.mjs';
import { normalizeContextEnhancementConfig } from '../../../../src/channels/shared/context-enhancement.mjs';
export const WECOM_RPC_CHANNEL = '/wecom';
@ -15,6 +16,7 @@ export const WECOM_ENDPOINTS = Object.freeze({
setWorkspace: 'bot.workspace.set',
setAgentPreset: SET_AGENT_PRESET_ENDPOINT,
setContextEnhancement: 'bot.context-enhancement.set',
setAccessPolicy: 'bot.access-policy.set',
});
const PROVISION_STATES = new Set(['starting', 'pending', 'refreshing', 'connecting', 'connected', 'failed', 'cancelled']);
@ -99,6 +101,9 @@ function normalizeBot(value) {
workspace: text(value.workspace, '', 4_096),
agentPreset: normalizeAgentPresetId(value.agentPreset),
contextEnhancement: normalizeContextEnhancementConfig(value.contextEnhancement),
...(Object.hasOwn(value, 'accessPolicy')
? { accessPolicy: normalizeAccessPolicy(value.accessPolicy) }
: {}),
bot: {
name: text(value.bot?.name, '企业微信机器人', 100),
appIdMasked: text(value.bot?.appIdMasked, '应用标识已安全保存', 140),

View file

@ -196,6 +196,7 @@ export function AccountCard({
botId: account.botId,
botName: account.bot.name,
connected: account.connected,
accessPolicy: account.accessPolicy,
}))),
h(WorkspaceEditor, {
workspace: account.workspace,

View file

@ -1,5 +1,6 @@
import { normalizeAgentPresetCatalog, normalizeAgentPresetId, SET_AGENT_PRESET_ENDPOINT } from '../../agent-preset.js';
import { normalizeLastMessageError } from '../../last-message-error.js';
import { normalizeAccessPolicy } from '../../../../src/channels/shared/access-policy.mjs';
import { normalizeContextEnhancementConfig } from '../../../../src/channels/shared/context-enhancement.mjs';
export const WEIXIN_RPC_CHANNEL = '/weixin';
@ -14,6 +15,7 @@ export const WEIXIN_ENDPOINTS = Object.freeze({
setWorkspace: 'bot.workspace.set',
setAgentPreset: SET_AGENT_PRESET_ENDPOINT,
setContextEnhancement: 'bot.context-enhancement.set',
setAccessPolicy: 'bot.access-policy.set',
});
const ACCOUNT_STATES = new Set(['connected', 'connecting', 'offline', 'error']);
@ -124,6 +126,9 @@ function normalizeBot(value) {
workspace: string(value.workspace).slice(0, 4_096),
agentPreset: normalizeAgentPresetId(value.agentPreset),
contextEnhancement: normalizeContextEnhancementConfig(value.contextEnhancement),
...(Object.hasOwn(value, 'accessPolicy')
? { accessPolicy: normalizeAccessPolicy(value.accessPolicy) }
: {}),
bot: {
name: string(value.bot.name, '微信机器人'),
accountIdMasked: string(value.bot.accountIdMasked, '已安全保存'),

View file

@ -237,6 +237,7 @@ export function AccountCard({
botId: account.botId,
botName: account.bot.name,
connected: account.connected,
accessPolicy: account.accessPolicy,
}))),
h(WorkspaceEditor, {
workspace: account.workspace,

View file

@ -1,5 +1,6 @@
import { normalizeAgentPresetCatalog, normalizeAgentPresetId, SET_AGENT_PRESET_ENDPOINT } from '../../agent-preset.js';
import { normalizeLastMessageError } from '../../last-message-error.js';
import { normalizeAccessPolicy } from '../../../../src/channels/shared/access-policy.mjs';
import { normalizeContextEnhancementConfig } from '../../../../src/channels/shared/context-enhancement.mjs';
export const WHATSAPP_RPC_CHANNEL = '/whatsapp';
@ -91,16 +92,9 @@ function normalizeBot(value) {
workspace: text(value.workspace, '', 4_096),
agentPreset: normalizeAgentPresetId(value.agentPreset),
contextEnhancement: normalizeContextEnhancementConfig(value.contextEnhancement),
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)
)))]
: [],
},
...(Object.hasOwn(value, 'accessPolicy')
? { accessPolicy: normalizeAccessPolicy(value.accessPolicy) }
: {}),
bot: {
name: text(value.bot?.name, 'WhatsApp机器人', 100),
idMasked: text(value.bot?.idMasked, 'WhatsApp账号', 140),

View file

@ -31,119 +31,6 @@ 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,
@ -300,7 +187,6 @@ export function WhatsappAccountCard({
onWorkspaceSave,
onAgentPresetSave,
onContextEnhancementSave,
onAccessPolicySave,
onRequestRemove,
onConfirmRemove,
onCancelRemove,
@ -333,6 +219,7 @@ export function WhatsappAccountCard({
botId: account.botId,
botName: account.bot.name,
connected: account.connected,
accessPolicy: account.accessPolicy,
}))),
h(WorkspaceEditor, {
workspace: account.workspace,
@ -349,11 +236,6 @@ export function WhatsappAccountCard({
disabled: Boolean(busy),
onSave: onContextEnhancementSave,
}),
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' },
@ -617,12 +499,6 @@ export function WhatsappSettingsTab({ rpcCall }) {
WHATSAPP_ENDPOINTS.setContextEnhancement,
{ botId: account.botId, config },
),
onAccessPolicySave: (accessPolicy) => botAction(
account,
'access',
WHATSAPP_ENDPOINTS.setAccessPolicy,
{ botId: account.botId, ...accessPolicy },
),
onRequestRemove: () => setRemoveTarget(account.botId),
onCancelRemove: () => setRemoveTarget(null),
onConfirmRemove: async () => {

View file

@ -4,31 +4,6 @@ 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

@ -1,5 +1,6 @@
import * as React from 'react';
import { AccessPolicySettingsPage } from './access-policy-settings.js';
import { h, isEnglish, localizeText } from './i18n.js';
export const DELIVERY_RPC_CHANNEL = '/dsh-im-delivery';
@ -20,6 +21,7 @@ export const DELIVERY_ENDPOINTS = Object.freeze({
export const BOT_SETTINGS_TABS = Object.freeze([
Object.freeze({ id: 'delivery', label: '投递设置' }),
Object.freeze({ id: 'access', label: '访问设置' }),
]);
const CHANNEL_DEFINITIONS = Object.freeze({
@ -536,8 +538,15 @@ function TargetRow({ definition, target, botId, connected, rpcCall, onChanged, o
: null);
}
export function DeliveryTargetSettingsPage({ channel, account, rpcCall, onBack }) {
export function DeliveryTargetSettingsPage({
channel,
account,
rpcCall,
accessRpcCall,
onBack,
}) {
const definition = CHANNEL_DEFINITIONS[channel];
const [activeTabId, setActiveTabId] = React.useState(BOT_SETTINGS_TABS[0].id);
const [phase, setPhase] = React.useState('loading');
const [targets, setTargets] = React.useState([]);
const [suggestionPhase, setSuggestionPhase] = React.useState('idle');
@ -547,8 +556,13 @@ export function DeliveryTargetSettingsPage({ channel, account, rpcCall, onBack }
const [editor, setEditor] = React.useState(null);
const [saving, setSaving] = React.useState(false);
const [botCopyState, setBotCopyState] = React.useState(null);
const [accessPolicy, setAccessPolicy] = React.useState(account.accessPolicy);
const mounted = React.useRef(true);
React.useEffect(() => {
setAccessPolicy(account.accessPolicy);
}, [account.botId, account.accessPolicy]);
const invoke = React.useCallback(async (endpoint, payload = {}, signal) => {
if (typeof rpcCall !== 'function') throw new Error('投递目标设置暂不可用。');
return unwrapRpcResult(await rpcCall(endpoint, payload, signal));
@ -658,9 +672,10 @@ export function DeliveryTargetSettingsPage({ channel, account, rpcCall, onBack }
}
};
const deliveryTab = BOT_SETTINGS_TABS[0];
const deliveryTabId = `dim-bot-settings-${deliveryTab.id}-tab`;
const deliveryPanelId = `dim-bot-settings-${deliveryTab.id}-panel`;
const activeTab = BOT_SETTINGS_TABS.find((tab) => tab.id === activeTabId)
?? BOT_SETTINGS_TABS[0];
const activeTabDomId = `dim-bot-settings-${activeTab.id}-tab`;
const activePanelId = `dim-bot-settings-${activeTab.id}-panel`;
return h('section', {
className: 'dim-deliveryPage',
@ -679,16 +694,25 @@ export function DeliveryTargetSettingsPage({ channel, account, rpcCall, onBack }
type: 'button',
role: 'tab',
className: 'dim-botSettingsTab',
'aria-selected': tab.id === deliveryTab.id,
'aria-selected': tab.id === activeTab.id,
'aria-controls': `dim-bot-settings-${tab.id}-panel`,
tabIndex: tab.id === deliveryTab.id ? 0 : -1,
tabIndex: tab.id === activeTab.id ? 0 : -1,
onClick: () => setActiveTabId(tab.id),
}, tab.label)))),
h('div', {
id: deliveryPanelId,
id: activePanelId,
className: 'dim-botSettingsTabPanel',
role: 'tabpanel',
'aria-labelledby': deliveryTabId,
'aria-labelledby': activeTabDomId,
},
activeTab.id === 'access'
? h(AccessPolicySettingsPage, {
channel,
account: { ...account, accessPolicy },
rpcCall: accessRpcCall,
onSaved: setAccessPolicy,
})
: h(React.Fragment, null,
h('section', { className: 'dim-deliveryIdentity', 'aria-labelledby': 'dim-delivery-bot-title' },
h('div', { className: 'dim-deliveryIdentityHeading' },
h('h2', { id: 'dim-delivery-bot-title', className: 'dim-deliveryBotName' },
@ -773,5 +797,5 @@ export function DeliveryTargetSettingsPage({ channel, account, rpcCall, onBack }
rpcCall: invoke,
onChanged: () => loadTargets({ silent: true }),
onEdit: () => setEditor({ mode: 'edit', target, source: 'edit' }),
}))))));
})))))));
}

View file

@ -10,6 +10,59 @@ const EN = Object.freeze({
'机器人设置': 'Bot settings',
'机器人设置页签': 'Bot settings tabs',
'投递设置': 'Delivery settings',
'访问设置': 'Access settings',
'查看访问权限说明': 'View access permission details',
'允许所有用户': 'Allow all users',
'仅白名单用户': 'Allowlisted users only',
'默认命令权限': 'Default command permission',
'命令权限': 'Command permission',
'可以执行命令': 'Can run commands',
'不可以执行命令': 'Cannot run commands',
'白名单用户': 'Allowlisted users',
'查看白名单说明': 'View allowlist details',
'命令权限例外': 'Command permission exceptions',
'当前没有白名单用户,保存后普通用户将无法使用机器人。': 'There are currently no allowlisted users. After saving, regular users will not be able to use the bot.',
'新增用户': 'Add user',
'尚未添加用户': 'No users added',
'当前渠道不支持群聊': 'Group chat is not supported by this channel',
'原所有者或扫码接入者始终可以访问并执行命令;以下设置仅约束其他用户。': 'The original owner or QR-code operator can always access the bot and run commands; the settings below apply only to other users.',
'此区域无需配置,保存私聊设置时会保留现有群聊策略。': 'No setup is needed here. Saving direct-message settings keeps the existing group policy.',
'访问设置已保存。': 'Access settings saved.',
'访问设置暂不可用。': 'Access settings are currently unavailable.',
'访问设置保存失败,请稍后重试。': 'Could not save access settings. Try again later.',
'服务没有返回已保存的访问策略,请刷新后重试。': 'The service did not return the saved access policy. Refresh and try again.',
'访问策略尚未就绪,请返回机器人列表刷新后重试。': 'The access policy is not ready. Return to the bot list, refresh, and try again.',
'当前渠道暂不支持访问设置。': 'Access settings are not supported by this channel yet.',
'用户标识无效。': 'The user ID is invalid.',
'用户标识必须是字符串。': 'The user ID must be a string.',
'用户标识不能为空、包含控制字符或超过 256 个字符。': 'The user ID cannot be empty, contain control characters, or exceed 256 characters.',
'用户条目必须包含用户标识和命令权限。': 'Each user entry must include a user ID and command permission.',
'命令权限必须是布尔值。': 'Command permission must be a boolean.',
'访问模式只能是 open 或 allowlist。': 'Access mode must be open or allowlist.',
'开放模式设置必须完整。': 'Open-mode settings must be complete.',
'开放模式默认命令权限必须是布尔值。': 'The open-mode default command permission must be a boolean.',
'开放模式命令权限覆盖用户必须是数组。': 'Open-mode command permission overrides must be an array.',
'开放模式命令权限覆盖用户不能包含重复的用户标识。': 'Open-mode command permission overrides cannot contain duplicate user IDs.',
'白名单模式设置必须完整。': 'Allowlist-mode settings must be complete.',
'白名单模式用户必须是数组。': 'Allowlist-mode users must be an array.',
'白名单模式用户不能包含重复的用户标识。': 'Allowlist-mode users cannot contain duplicate user IDs.',
'访问场景设置必须同时包含模式、开放模式设置和白名单模式设置。': 'Each access context must include its mode, open-mode settings, and allowlist-mode settings.',
'请同时提交完整的私聊和群聊访问设置。': 'Submit complete direct-message and group access settings together.',
'填写微信用户 ID': 'Enter a WeChat user ID',
'飞书 Open ID': 'Feishu Open ID',
'群成员 Open ID': 'Group member Open ID',
'钉钉用户 ID': 'DingTalk user ID',
'填写 senderStaffId 或 senderId': 'Enter senderStaffId or senderId',
'群成员用户 ID': 'Group member user ID',
'企业微信用户 ID': 'WeCom user ID',
'填写 userid': 'Enter userid',
'填写 member_openid': 'Enter member_openid',
'QQ User Open ID': 'QQ User Open ID',
'群成员 User ID': 'Group member User ID',
'填写数字 User ID': 'Enter a numeric user ID',
'WhatsApp 电话号码或 JID': 'WhatsApp phone number or JID',
'群成员电话号码或 JID': 'Group member phone number or JID',
'8613800000000 或完整 JID': '8613800000000 or a full JID',
'IM 渠道': 'IM channels',
'让 DeepSeek Harness 触手可及': 'Connecting DeepSeek Harness',
'当前版本': 'Current version',
@ -524,7 +577,6 @@ const EN = Object.freeze({
'先通过 @BotFather 获取 Bot Token,再在这里完成接入。': 'Get a Bot Token from @BotFather, then connect it here.',
'填写 @BotFather 生成的 Bot Token': 'Enter the Bot Token from @BotFather',
'访问模式': 'Access mode',
'访问设置': 'Access settings',
'Telegram 访问模式': 'Telegram access mode',
'查看 Telegram 访问模式说明': 'View Telegram access mode details',
'群聊全部忽略,私聊仅允许白名单用户。': 'All group messages are ignored; only allowlisted users may send DMs.',

View file

@ -294,6 +294,7 @@ export function IMSettingsTab({
channel: active.id,
account: deliverySettings,
rpcCall: rpcCalls.deliveryRpcCall,
accessRpcCall: rpcCalls[`${active.id}RpcCall`],
onBack: () => setDeliverySettings(null),
})
: active.id === 'weixin'

View file

@ -435,6 +435,38 @@ const CSS = String.raw`
.dim-targetField input[readonly] { color: var(--dsw-alias-label-secondary, #646a73); background: var(--dsw-alias-bg-module-platform, #f7f8fa); }
.dim-targetFormError { margin: 10px 0 0; font-size: 12px; line-height: 18px; }
.dim-targetFormActions { display: flex; justify-content: flex-end; gap: 7px; margin-top: 12px; }
.dim-accessPage { min-width: 0; display: grid; gap: 14px; }
.dim-accessScene { position: relative; min-width: 0; margin: 0; padding: 16px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 12px; background: var(--dsw-alias-bg-layer-3, #fff); }
.dim-accessScene > legend { padding: 0 6px; color: var(--dsw-alias-label-primary, #1f2329); font-size: 15px; line-height: 22px; font-weight: 650; }
.dim-accessLegendContent { display: inline-flex; align-items: center; gap: 6px; }
.dim-panel .dim-accessLegendHelp { position: static; }
.dim-accessLegendHelp .dim-channelTooltip { top: 20px; right: auto; left: 16px; width: min(320px, calc(100% - 32px)); max-width: none; }
.dim-accessControls { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
.dim-accessControls[data-mode="allowlist"] { grid-template-columns: minmax(0, 1fr); }
.dim-accessField { min-width: 0; display: grid; align-content: start; gap: 5px; color: var(--dsw-alias-label-secondary, #646a73); font-size: 11px; line-height: 16px; }
.dim-accessField input, .dim-accessField select { width: 100%; min-width: 0; height: 36px; padding: 0 9px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 7px; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-bg-layer-1, #fff); font: inherit; font-size: 12px; }
.dim-accessField input:focus, .dim-accessField select:focus { outline: 2px solid color-mix(in srgb, var(--dsw-alias-state-business-primary, #3370ff) 28%, transparent); border-color: var(--dsw-alias-state-business-primary, #3370ff); }
.dim-accessUsers { min-width: 0; margin-top: 14px; padding-top: 14px; border-top: 1px solid var(--dsw-alias-border-l1, #eef0f3); }
.dim-accessUsersHeading { position: relative; min-width: 0; display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
.dim-accessUsersHeading > div { min-width: 0; }
.dim-accessUsersTitle { display: inline-flex; align-items: center; gap: 6px; }
.dim-accessUsersHeading strong { color: var(--dsw-alias-label-primary, #1f2329); font-size: 13px; line-height: 20px; font-weight: 620; }
.dim-accessUsersHeading p { margin: 2px 0 0; color: var(--dsw-alias-label-secondary, #646a73); font-size: 11px; line-height: 17px; }
.dim-panel .dim-accessUsersHelp { position: static; }
.dim-accessUsersHelp .dim-channelTooltip { top: calc(100% + 7px); right: auto; left: 0; width: min(320px, 100%); max-width: none; }
.dim-accessAddUser { width: 32px; height: 32px; min-height: 32px; flex: 0 0 32px; padding: 0; font-size: 20px; line-height: 1; }
.dim-accessUsersEmpty { margin-top: 10px; padding: 15px 12px; border: 1px dashed var(--dsw-alias-border-l2, #dfe1e5); border-radius: 8px; color: var(--dsw-alias-label-tertiary, #8f959e); font-size: 12px; line-height: 18px; text-align: center; }
.dim-accessUserList { display: grid; gap: 9px; margin: 10px 0 0; padding: 0; list-style: none; }
.dim-accessUserRow { min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr) minmax(145px, 180px) max-content; align-items: end; gap: 10px; padding: 11px; border: 1px solid var(--dsw-alias-border-l1, #eef0f3); border-radius: 9px; background: var(--dsw-alias-bg-module-platform, #f7f8fa); }
.dim-accessDeleteUser { margin-bottom: 1px; }
.dim-accessUnsupported { padding: 18px 14px; border: 1px dashed var(--dsw-alias-border-l2, #dfe1e5); border-radius: 9px; color: var(--dsw-alias-label-secondary, #646a73); background: var(--dsw-alias-bg-module-platform, #f7f8fa); text-align: center; }
.dim-accessUnsupported strong { color: var(--dsw-alias-label-primary, #1f2329); font-size: 13px; line-height: 20px; }
.dim-accessUnsupported p { margin: 4px 0 0; font-size: 12px; line-height: 18px; }
.dim-accessState { padding: 11px 13px; border: 1px solid color-mix(in srgb, var(--dsw-alias-state-warn-primary, #d97706) 24%, var(--dsw-alias-border-l2, #dfe1e5)); border-radius: 9px; color: var(--dsw-alias-state-warn-primary, #d97706); background: color-mix(in srgb, var(--dsw-alias-state-warn-primary, #d97706) 7%, var(--dsw-alias-bg-layer-1, #fff)); font-size: 12px; line-height: 18px; }
.dim-accessFeedback { margin: 0; padding: 10px 12px; border-radius: 8px; color: var(--dsw-alias-label-secondary, #646a73); background: var(--dsw-alias-bg-module-platform, #f7f8fa); font-size: 12px; line-height: 18px; }
.dim-accessFeedback[data-tone="success"] { color: var(--dsw-alias-state-success-primary, #20a162); }
.dim-accessFeedback[data-tone="error"] { color: var(--dsw-alias-state-error-primary, #d54941); }
.dim-accessActions { display: flex; justify-content: flex-end; }
.dim-panel .dim-botCard .dim-cardFooter { margin-top: 0; }
.dim-panel .ddt-headingCopy { display: none; }
.dim-panel .ddt-qrFrame, .dim-panel .ddt-countdown { width: min(270px, 100%); }
@ -458,6 +490,9 @@ const CSS = String.raw`
.dim-targetActions { justify-content: flex-start; }
.dim-targetFormGrid { grid-template-columns: minmax(0, 1fr); }
.dim-targetSuggestionHeading { align-items: stretch; flex-direction: column; }
.dim-accessControls { grid-template-columns: minmax(0, 1fr); }
.dim-accessUserRow { grid-template-columns: minmax(0, 1fr); }
.dim-accessDeleteUser { justify-self: start; }
}
@media (max-width: 840px) {
.dim-title { align-items: flex-start; }
@ -486,6 +521,7 @@ const CSS = String.raw`
.dim-deliveryBotId { grid-template-columns: minmax(0, 1fr) max-content; }
.dim-deliveryBotId > span { grid-column: 1 / -1; }
.dim-targetActions .dim-deliveryButton { flex: 1 1 auto; }
.dim-accessActions .dim-deliveryButton { width: 100%; }
.dim-directoryPickerBackdrop { padding: 10px; }
.dim-directoryPicker { height: calc(100vh - 20px); min-height: 0; border-radius: 14px; }
.dim-directoryPickerHeader { padding: 18px 17px 14px; }

View file

@ -20,6 +20,10 @@ import { createConnectionSupervisor } from './connection-supervisor.mjs';
import { createHarnessCommandExecutor } from '../../harness-command-executor.mjs';
import { harnessConnection } from '../../harness-connection.mjs';
import { createHarnessSessionExecutors } from '../../harness-session-coordinator.mjs';
import {
accessPolicyProvider,
initialAccessPolicyFor,
} from '../shared/access-policy-production.mjs';
function pluginPaths(config) {
const dshHome = resolve(config.dshHome ?? process.env.DSH_HOME ?? join(homedir(), '.dsh'));
@ -60,6 +64,7 @@ export async function createProductionController(ctx, config = {}, internals = {
}
await Promise.all(configuredBots.map((bot) => workspaces.ensure(bot.botId, {
defaultAgentPreset: config.agentPreset,
initialAccessPolicy: initialAccessPolicyFor('dingtalk', bot),
})));
const observedConfigStore = typeof configStore.remove === 'function'
? observeBotWorkspaceRemovals(configStore, { workspaces })
@ -101,7 +106,10 @@ export async function createProductionController(ctx, config = {}, internals = {
logger,
createRuntime: async ({ botId, config: botConfig, clientSecret }) => {
const state = await stateFor(botId);
await workspaces.ensure(botId, { defaultAgentPreset: config.agentPreset });
await workspaces.ensure(botId, {
defaultAgentPreset: config.agentPreset,
initialAccessPolicy: initialAccessPolicyFor('dingtalk', botConfig),
});
const workspaceScope = createBotWorkspaceScope(harness, {
botId, workspaces, state, agentPresetCatalog,
});
@ -111,6 +119,9 @@ export async function createProductionController(ctx, config = {}, internals = {
harness: workspaceScope.harness,
state: workspaceScope.state,
contextEnhancement: { botId, getSettings: () => workspaces.contextEnhancementFor(botId) },
accessPolicy: accessPolicyProvider(workspaces, botId, {
channel: 'dingtalk', config: botConfig,
}),
replyTimeoutMs: config.replyTimeoutMs ?? 600_000,
maxMessageChars: config.maxMessageChars ?? 4_000,
connectTimeoutMs: config.connectTimeoutMs ?? 15_000,

View file

@ -1,5 +1,6 @@
import QRCode from 'qrcode';
import { SET_CONTEXT_ENHANCEMENT_ENDPOINT, validContextEnhancementPayload } from '../shared/context-enhancement-rpc.mjs';
import { SET_ACCESS_POLICY_ENDPOINT, validAccessPolicyPayload } from '../shared/access-policy-rpc.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';
@ -20,6 +21,7 @@ export const DINGTALK_ENDPOINTS = Object.freeze({
setWorkspace: SET_WORKSPACE_ENDPOINT,
setAgentPreset: SET_AGENT_PRESET_ENDPOINT,
setContextEnhancement: SET_CONTEXT_ENHANCEMENT_ENDPOINT,
setAccessPolicy: SET_ACCESS_POLICY_ENDPOINT,
approveSender: 'bot.sender.approve',
revokeSender: 'bot.sender.revoke',
});
@ -100,6 +102,10 @@ function payloadFailure(endpoint, payload) {
return validContextEnhancementPayload(payload)
? null : '请提交有效的上下文增强设置。';
}
if (endpoint === DINGTALK_ENDPOINTS.setAccessPolicy) {
return validAccessPolicyPayload(payload)
? null : '请提交有效的访问设置。';
}
if (endpoint === DINGTALK_ENDPOINTS.approveSender) {
return exactKeys(payload, ['botId', 'requestId', 'confirm'])
&& validId(payload.botId)
@ -262,6 +268,11 @@ export function createDingtalkRpcHandler(controller, { encodeQr = qrDataUrl } =
value = await controller.updateContextEnhancement(
payload.botId, payload.config, (status) => publicStatus(status, cachedEncode),
);
} else if (endpoint === DINGTALK_ENDPOINTS.setAccessPolicy) {
if (typeof controller.updateAccessPolicy !== 'function') throw new Error('Access policy update is unavailable');
value = await controller.updateAccessPolicy(
payload.botId, payload.policy, (status) => publicStatus(status, cachedEncode),
);
} else if (endpoint === DINGTALK_ENDPOINTS.setAgentPreset) {
if (typeof controller.updateAgentPreset !== 'function') throw new Error('Agent preset update is unavailable');
value = await publicStatus(

View file

@ -24,6 +24,10 @@ import {
} from '../../../../src/channels/shared/bot-workspace-store.mjs';
import { listAgentPresetCatalog } from '../../../../src/channels/shared/agent-preset.mjs';
import { createDeliveryAdapter } from '../../delivery-adapter.mjs';
import {
accessPolicyProvider,
initialAccessPolicyFor,
} from '../shared/access-policy-production.mjs';
// The WebSocket agent built here is only used for the Feishu long connection,
// whose endpoint is open.feishu.cn (Feishu) or open.larksuite.com (Lark).
@ -120,6 +124,7 @@ export async function createProductionController(ctx, config = {}, internals = {
}
await Promise.all(configuredBots.map((bot) => workspaces.ensure(bot.id, {
defaultAgentPreset: config.agentPreset,
initialAccessPolicy: initialAccessPolicyFor('feishu', bot),
})));
const observedConfigStore = typeof configStore.removeBot === 'function'
? observeBotWorkspaceRemovals(configStore, {
@ -181,7 +186,10 @@ export async function createProductionController(ctx, config = {}, internals = {
createRuntime: async ({ botId, config: botConfig, appSecret, repair }) => {
const state = await stateFor(botConfig);
const id = botId ?? botConfig.id ?? botConfig.appId;
await workspaces.ensure(id, { defaultAgentPreset: config.agentPreset });
await workspaces.ensure(id, {
defaultAgentPreset: config.agentPreset,
initialAccessPolicy: initialAccessPolicyFor('feishu', botConfig),
});
const workspaceScope = createBotWorkspaceScope(harness, {
botId: id, workspaces, state, agentPresetCatalog,
});
@ -198,6 +206,9 @@ export async function createProductionController(ctx, config = {}, internals = {
harness: workspaceScope.harness,
state: workspaceScope.state,
contextEnhancement: { botId: id, getSettings: () => workspaces.contextEnhancementFor(id) },
accessPolicy: accessPolicyProvider(workspaces, id, {
channel: 'feishu', config: botConfig,
}),
replyTimeoutMs: config.replyTimeoutMs ?? 600_000,
slashCommands: config.slashCommands !== false,
...(wsAgent ? { wsAgent } : {}),

View file

@ -5,21 +5,27 @@ import {
} from '../../../../src/channels/shared/agent-preset.mjs';
import { publicConnectionTestResult } from '../../../../src/channels/shared/connection-test.mjs';
import { publicMessageFailure } from '../../../../src/channels/shared/message-failure.mjs';
import { normalizeAccessPolicy } from '../../../../src/channels/shared/access-policy.mjs';
import { resolveRpcAuthority } from '../../rpc-authority.mjs';
import { publicWorkspaceError, validWorkspacePayload } from '../shared/workspace-rpc.mjs';
import { validAgentPresetPayload } from '../shared/agent-preset-rpc.mjs';
import { validContextEnhancementPayload } from '../shared/context-enhancement-rpc.mjs';
import { SET_ACCESS_POLICY_ENDPOINT, validAccessPolicyPayload } from '../shared/access-policy-rpc.mjs';
import { normalizeContextEnhancementConfig } from '../../../../src/channels/shared/context-enhancement.mjs';
import {
isFeishuGroupResponseMode,
normalizeFeishuGroupResponseMode,
} from '../../../../src/channels/feishu/group-response-mode.mjs';
import {
FEISHU_ENDPOINTS,
FEISHU_ENDPOINTS as FEISHU_CLIENT_ENDPOINTS,
FEISHU_RPC_CHANNEL,
} from '../../../client/channels/feishu/api.js';
export { FEISHU_ENDPOINTS, FEISHU_RPC_CHANNEL };
export const FEISHU_ENDPOINTS = Object.freeze({
...FEISHU_CLIENT_ENDPOINTS,
setAccessPolicy: SET_ACCESS_POLICY_ENDPOINT,
});
export { FEISHU_RPC_CHANNEL };
export const FEISHU_MULTI_ENDPOINTS = Object.freeze({
reconnectBot: 'bot.reconnect',
disconnectBot: 'bot.disconnect',
@ -279,6 +285,7 @@ function publicBotEntry(entry) {
configured: source.configured === true,
agentPreset: normalizeAgentPresetId(source.agentPreset),
contextEnhancement: normalizeContextEnhancementConfig(source.contextEnhancement),
accessPolicy: normalizeAccessPolicy(source.accessPolicy),
groupResponseMode: normalizeFeishuGroupResponseMode(source.groupResponseMode),
groupMessagePermissionGranted: source.groupMessagePermissionGranted === true,
bot: publicBot(source.bot),
@ -422,6 +429,10 @@ function validPayload(endpoint, payload) {
return validContextEnhancementPayload(payload)
? null : '请提交有效的上下文增强设置。';
}
if (endpoint === FEISHU_ENDPOINTS.setAccessPolicy) {
return validAccessPolicyPayload(payload)
? null : '请提交有效的访问设置。';
}
if (endpoint === FEISHU_ENDPOINTS.setGroupResponseMode) {
return hasOnlyKeys(payload, new Set(['botId', 'groupResponseMode']))
&& safeOpaqueId(payload.botId)
@ -679,6 +690,12 @@ export function createFeishuRpcHandler(controller, { encodeQr = qrCodeDataUrl }
payload.botId, payload.config,
(status) => toPublicFeishuStatus(status, { encodeQr: cachedEncodeQr }),
);
} else if (endpoint === FEISHU_ENDPOINTS.setAccessPolicy) {
if (typeof controller.updateAccessPolicy !== 'function') throw new Error('Access policy update is unavailable');
value = await controller.updateAccessPolicy(
payload.botId, payload.policy,
(status) => toPublicFeishuStatus(status, { encodeQr: cachedEncodeQr }),
);
} else if (endpoint === FEISHU_ENDPOINTS.setAgentPreset) {
if (typeof controller.updateAgentPreset !== 'function') throw new Error('Agent preset update is unavailable');
value = await toPublicFeishuStatus(

View file

@ -20,6 +20,10 @@ import { createConnectionSupervisor } from './connection-supervisor.mjs';
import { createHarnessCommandExecutor } from '../../harness-command-executor.mjs';
import { harnessConnection } from '../../harness-connection.mjs';
import { createHarnessSessionExecutors } from '../../harness-session-coordinator.mjs';
import {
accessPolicyProvider,
initialAccessPolicyFor,
} from '../shared/access-policy-production.mjs';
function pluginPaths(config) {
const dshHome = resolve(config.dshHome ?? process.env.DSH_HOME ?? join(homedir(), '.dsh'));
@ -54,6 +58,7 @@ export async function createProductionController(ctx, config = {}, internals = {
await workspaces.reconcile(configuredBots.map((bot) => bot.botId));
await Promise.all(configuredBots.map((bot) => workspaces.ensure(bot.botId, {
defaultAgentPreset: config.agentPreset,
initialAccessPolicy: initialAccessPolicyFor('qq', bot),
})));
const observedConfigStore = typeof configStore.remove === 'function'
? observeBotWorkspaceRemovals(configStore, { workspaces })
@ -92,7 +97,10 @@ export async function createProductionController(ctx, config = {}, internals = {
logger,
createRuntime: async ({ botId, config: botConfig, appSecret }) => {
const state = await stateFor(botId);
await workspaces.ensure(botId, { defaultAgentPreset: config.agentPreset });
await workspaces.ensure(botId, {
defaultAgentPreset: config.agentPreset,
initialAccessPolicy: initialAccessPolicyFor('qq', botConfig),
});
const workspaceScope = createBotWorkspaceScope(harness, {
botId, workspaces, state, agentPresetCatalog,
});
@ -102,6 +110,9 @@ export async function createProductionController(ctx, config = {}, internals = {
harness: workspaceScope.harness,
state: workspaceScope.state,
contextEnhancement: { botId, getSettings: () => workspaces.contextEnhancementFor(botId) },
accessPolicy: accessPolicyProvider(workspaces, botId, {
channel: 'qq', config: botConfig,
}),
replyTimeoutMs: config.replyTimeoutMs ?? 600_000,
connectTimeoutMs: config.connectTimeoutMs ?? 20_000,
logger: {

View file

@ -4,6 +4,7 @@ import {
publicConnectionTestResult,
} from '../../../../src/channels/shared/connection-test.mjs';
import { SET_CONTEXT_ENHANCEMENT_ENDPOINT, validContextEnhancementPayload } from '../shared/context-enhancement-rpc.mjs';
import { SET_ACCESS_POLICY_ENDPOINT, validAccessPolicyPayload } from '../shared/access-policy-rpc.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';
@ -20,6 +21,7 @@ export const QQ_ENDPOINTS = Object.freeze({
setWorkspace: SET_WORKSPACE_ENDPOINT,
setAgentPreset: SET_AGENT_PRESET_ENDPOINT,
setContextEnhancement: SET_CONTEXT_ENHANCEMENT_ENDPOINT,
setAccessPolicy: SET_ACCESS_POLICY_ENDPOINT,
});
export const QQ_RPC_ENDPOINTS = Object.freeze(Object.values(QQ_ENDPOINTS));
@ -82,6 +84,10 @@ function payloadFailure(endpoint, payload) {
return validContextEnhancementPayload(payload)
? null : '请提交有效的上下文增强设置。';
}
if (endpoint === QQ_ENDPOINTS.setAccessPolicy) {
return validAccessPolicyPayload(payload)
? null : '请提交有效的访问设置。';
}
return 'Unknown QQ endpoint.';
}
@ -182,6 +188,11 @@ export function createQqRpcHandler(controller, { encodeQr = qrDataUrl } = {}) {
value = await controller.updateContextEnhancement(
payload.botId, payload.config, (status) => publicStatus(status, cachedEncode),
);
} else if (endpoint === QQ_ENDPOINTS.setAccessPolicy) {
if (typeof controller.updateAccessPolicy !== 'function') throw new Error('Access policy update is unavailable');
value = await controller.updateAccessPolicy(
payload.botId, payload.policy, (status) => publicStatus(status, cachedEncode),
);
} else if (endpoint === QQ_ENDPOINTS.setAgentPreset) {
if (typeof controller.updateAgentPreset !== 'function') throw new Error('Agent preset update is unavailable');
value = await publicStatus(

View file

@ -0,0 +1,137 @@
import {
createAccessPolicy,
createAccessPolicyScope,
} from '../../../../src/channels/shared/access-policy.mjs';
function policyUsers(users) {
return users.map((id) => ({ id, canExecuteCommands: true }));
}
function openScope(allowlistUsers = []) {
return createAccessPolicyScope({
mode: 'open',
open: {
defaultCanExecuteCommands: true,
commandPermissionOverrides: [],
},
allowlist: { users: policyUsers(allowlistUsers) },
});
}
function allowlistScope(users = []) {
return createAccessPolicyScope({
mode: 'allowlist',
open: {
defaultCanExecuteCommands: false,
commandPermissionOverrides: [],
},
allowlist: { users: policyUsers(users) },
});
}
function cleanIds(values) {
return [...new Set((Array.isArray(values) ? values : [values])
.filter((value) => typeof value === 'string' || typeof value === 'number'
|| typeof value === 'bigint')
.map((value) => String(value).trim())
.filter(Boolean))];
}
function whatsappNumberJids(values) {
return cleanIds(values).map((value) => `${value.replace(/^\+/, '')}@s.whatsapp.net`);
}
/**
* Build the one-time, backwards-compatible seed for a bot whose workspace
* document does not yet contain an access policy.
*/
export function initialAccessPolicyFor(channel, config = {}) {
const key = String(channel ?? '').trim().toLowerCase();
if (key === 'weixin') {
return createAccessPolicy({
direct: allowlistScope(),
group: allowlistScope(),
});
}
if (key === 'feishu') {
const owners = cleanIds(config.ownerOpenIds ?? config.ownerOpenId);
const scope = owners.includes('*') ? openScope() : allowlistScope();
return createAccessPolicy({ direct: scope, group: scope });
}
if (key === 'qq') {
const owners = cleanIds(config.ownerUserOpenid);
return createAccessPolicy({
direct: owners.includes('*') ? openScope() : allowlistScope(),
group: openScope(),
});
}
if (key === 'telegram') {
const users = cleanIds(config.allowedUsers);
if ((config.accessMode ?? 'compatible') === 'private-allowlist') {
return createAccessPolicy({
direct: allowlistScope(users),
group: allowlistScope(),
});
}
return createAccessPolicy({
direct: openScope(users),
group: openScope(),
});
}
if (key === 'whatsapp') {
const mode = config.accessMode ?? 'self-only';
const allowed = whatsappNumberJids(config.allowedNumbers);
if (mode === 'open') {
return createAccessPolicy({ direct: openScope(allowed), group: openScope() });
}
return createAccessPolicy({
direct: allowlistScope(mode === 'private-allowlist'
? allowed
: []),
group: allowlistScope(),
});
}
if (['dingtalk', 'wecom', 'slack', 'discord'].includes(key)) {
return createAccessPolicy({ direct: openScope(), group: openScope() });
}
throw new TypeError(`Unsupported access-policy channel: ${channel}`);
}
export function privilegedSenderIdsFor(channel, config = {}) {
const key = String(channel ?? '').trim().toLowerCase();
if (key === 'weixin') return cleanIds(config.ownerUserId);
if (key === 'feishu') {
return cleanIds(config.ownerOpenIds ?? config.ownerOpenId).filter((id) => id !== '*');
}
if (key === 'dingtalk') {
const approved = Array.isArray(config.approvedSenders) ? config.approvedSenders : [];
return cleanIds(approved.map((entry) => entry?.staffId));
}
if (key === 'qq') return cleanIds(config.ownerUserOpenid).filter((id) => id !== '*');
if (key === 'whatsapp') return cleanIds(config.accountJid);
return [];
}
export function accessPolicyProvider(workspaces, botId, { channel, config, equals } = {}) {
if (!workspaces || typeof workspaces.accessPolicyFor !== 'function') {
throw new TypeError('A workspace store with access policies is required');
}
const privilegedSenderIds = new Set(privilegedSenderIdsFor(channel, config));
const sameSender = typeof equals === 'function' ? equals : (left, right) => left === right;
return Object.freeze({
botId,
getSettings: () => workspaces.accessPolicyFor(botId),
isPrivileged(senderIds, conversationType) {
if (!['direct', 'group'].includes(conversationType)) return false;
const candidates = Array.isArray(senderIds) ? senderIds : [senderIds];
try {
return candidates.some((senderId) => typeof senderId === 'string'
&& [...privilegedSenderIds].some((privilegedId) => (
sameSender(senderId.trim(), privilegedId) === true
)));
} catch {
return false;
}
},
});
}

View file

@ -0,0 +1,17 @@
import { validateAccessPolicy } from '../../../../src/channels/shared/access-policy.mjs';
export const SET_ACCESS_POLICY_ENDPOINT = 'bot.access-policy.set';
export function validAccessPolicyPayload(payload) {
try {
if (!payload || typeof payload !== 'object' || Array.isArray(payload)
|| Reflect.ownKeys(payload).length !== 2
|| !Object.hasOwn(payload, 'botId') || !Object.hasOwn(payload, 'policy')
|| typeof payload.botId !== 'string'
|| !/^[A-Za-z0-9_-]{1,128}$/.test(payload.botId)) return false;
validateAccessPolicy(payload.policy);
return true;
} catch {
return false;
}
}

View file

@ -17,6 +17,10 @@ import {
createDeliveryAdapter,
supportsDeliveryChannel,
} from '../../delivery-adapter.mjs';
import {
accessPolicyProvider,
initialAccessPolicyFor,
} from './access-policy-production.mjs';
export function pluginPaths(config, channel) {
const dshHome = resolve(config.dshHome ?? process.env.DSH_HOME ?? join(homedir(), '.dsh'));
@ -46,6 +50,11 @@ export async function createTokenProductionController(ctx, config, internals, de
throw new TypeError(`dsh-im ${channel} runtimeOptions must return an object`);
}
const createSupervisor = internals.createConnectionSupervisor ?? createTokenConnectionSupervisor;
const seedAccessPolicy = typeof definitions.initialAccessPolicyForBot === 'function'
? definitions.initialAccessPolicyForBot
// Telegram is the only token channel with a legacy access model. Other
// current token channels preserve their fully-open baseline.
: (bot) => initialAccessPolicyFor(channel === 'telegram' ? 'telegram' : 'discord', bot);
const logger = typeof ctx.logger === 'function'
? ctx.logger(`dsh-im:${channel}`) : (ctx.logger ?? console);
const agentPresetCatalog = () => listAgentPresetCatalog(ctx);
@ -59,6 +68,7 @@ export async function createTokenProductionController(ctx, config, internals, de
await workspaces.reconcile(configuredBots.map((bot) => bot.botId));
await Promise.all(configuredBots.map((bot) => workspaces.ensure(bot.botId, {
defaultAgentPreset: config.agentPreset,
initialAccessPolicy: seedAccessPolicy(bot),
})));
const observedConfigStore = typeof configStore.remove === 'function'
? observeBotWorkspaceRemovals(configStore, { workspaces })
@ -96,7 +106,10 @@ export async function createTokenProductionController(ctx, config, internals, de
...(internals.inspectToken ? { inspectToken: internals.inspectToken } : {}),
createRuntime: async ({ botId, config: botConfig, token }) => {
const state = await stateFor(botId);
await workspaces.ensure(botId, { defaultAgentPreset: config.agentPreset });
await workspaces.ensure(botId, {
defaultAgentPreset: config.agentPreset,
initialAccessPolicy: seedAccessPolicy(botConfig),
});
const workspaceScope = createBotWorkspaceScope(harness, {
botId, workspaces, state, agentPresetCatalog,
});
@ -107,6 +120,7 @@ export async function createTokenProductionController(ctx, config, internals, de
harness: workspaceScope.harness,
state: workspaceScope.state,
contextEnhancement: { botId, getSettings: () => workspaces.contextEnhancementFor(botId) },
accessPolicy: accessPolicyProvider(workspaces, botId, { channel, config: botConfig }),
replyTimeoutMs: config.replyTimeoutMs ?? 600_000,
connectTimeoutMs: config.connectTimeoutMs ?? 20_000,
logger: {

View file

@ -1,4 +1,5 @@
import { SET_CONTEXT_ENHANCEMENT_ENDPOINT, validContextEnhancementPayload } from './context-enhancement-rpc.mjs';
import { SET_ACCESS_POLICY_ENDPOINT, validAccessPolicyPayload } from './access-policy-rpc.mjs';
import { resolveRpcAuthority } from '../../rpc-authority.mjs';
import { publicConnectionTestResult } from '../../../../src/channels/shared/connection-test.mjs';
import {
@ -19,6 +20,7 @@ export const TOKEN_BOT_ENDPOINTS = Object.freeze({
setWorkspace: SET_WORKSPACE_ENDPOINT,
setAgentPreset: SET_AGENT_PRESET_ENDPOINT,
setContextEnhancement: SET_CONTEXT_ENHANCEMENT_ENDPOINT,
setAccessPolicy: SET_ACCESS_POLICY_ENDPOINT,
});
const ENDPOINTS = Object.freeze(Object.values(TOKEN_BOT_ENDPOINTS));
@ -77,6 +79,10 @@ function payloadFailure(endpoint, payload) {
return validContextEnhancementPayload(payload)
? null : '请提交有效的上下文增强设置。';
}
if (endpoint === TOKEN_BOT_ENDPOINTS.setAccessPolicy) {
return validAccessPolicyPayload(payload)
? null : '请提交有效的访问设置。';
}
return 'Unknown bot endpoint.';
}
@ -161,6 +167,9 @@ export function createTokenBotRpcHandler(controller, { channel }) {
} else if (endpoint === TOKEN_BOT_ENDPOINTS.setContextEnhancement) {
if (typeof controller.updateContextEnhancement !== 'function') throw new Error('Context enhancement update is unavailable');
value = await controller.updateContextEnhancement(payload.botId, payload.config);
} else if (endpoint === TOKEN_BOT_ENDPOINTS.setAccessPolicy) {
if (typeof controller.updateAccessPolicy !== 'function') throw new Error('Access policy update is unavailable');
value = await controller.updateAccessPolicy(payload.botId, payload.policy);
} else if (endpoint === TOKEN_BOT_ENDPOINTS.setAgentPreset) {
if (typeof controller.updateAgentPreset !== 'function') throw new Error('Agent preset update is unavailable');
value = await controller.updateAgentPreset(payload.botId, payload.agentPreset);

View file

@ -19,6 +19,10 @@ import { pluginPaths } from '../shared/production.mjs';
import { createHarnessCommandExecutor } from '../../harness-command-executor.mjs';
import { harnessConnection } from '../../harness-connection.mjs';
import { createHarnessSessionExecutors } from '../../harness-session-coordinator.mjs';
import {
accessPolicyProvider,
initialAccessPolicyFor,
} from '../shared/access-policy-production.mjs';
export async function createProductionController(ctx, config = {}, internals = {}) {
if (!ctx?.credentials) throw new TypeError('dsh-im slack requires ctx.credentials');
@ -43,6 +47,7 @@ export async function createProductionController(ctx, config = {}, internals = {
await workspaces.reconcile(configuredBots.map((bot) => bot.botId));
await Promise.all(configuredBots.map((bot) => workspaces.ensure(bot.botId, {
defaultAgentPreset: config.agentPreset,
initialAccessPolicy: initialAccessPolicyFor('slack', bot),
})));
const observedConfigStore = typeof configStore.remove === 'function'
? observeBotWorkspaceRemovals(configStore, { workspaces })
@ -80,7 +85,10 @@ export async function createProductionController(ctx, config = {}, internals = {
...(internals.inspectCredentials ? { inspectCredentials: internals.inspectCredentials } : {}),
createRuntime: async ({ botId, config: botConfig, botToken, appToken }) => {
const state = await stateFor(botId);
await workspaces.ensure(botId, { defaultAgentPreset: config.agentPreset });
await workspaces.ensure(botId, {
defaultAgentPreset: config.agentPreset,
initialAccessPolicy: initialAccessPolicyFor('slack', botConfig),
});
const workspaceScope = createBotWorkspaceScope(harness, {
botId, workspaces, state, agentPresetCatalog,
});
@ -91,6 +99,9 @@ export async function createProductionController(ctx, config = {}, internals = {
harness: workspaceScope.harness,
state: workspaceScope.state,
contextEnhancement: { botId, getSettings: () => workspaces.contextEnhancementFor(botId) },
accessPolicy: accessPolicyProvider(workspaces, botId, {
channel: 'slack', config: botConfig,
}),
replyTimeoutMs: config.replyTimeoutMs ?? 600_000,
connectTimeoutMs: config.connectTimeoutMs ?? 20_000,
logger: {

View file

@ -1,4 +1,5 @@
import { SET_CONTEXT_ENHANCEMENT_ENDPOINT, validContextEnhancementPayload } from '../shared/context-enhancement-rpc.mjs';
import { SET_ACCESS_POLICY_ENDPOINT, validAccessPolicyPayload } from '../shared/access-policy-rpc.mjs';
import { resolveRpcAuthority } from '../../rpc-authority.mjs';
import { publicConnectionTestResult } from '../../../../src/channels/shared/connection-test.mjs';
import {
@ -20,6 +21,7 @@ export const SLACK_ENDPOINTS = Object.freeze({
setWorkspace: SET_WORKSPACE_ENDPOINT,
setAgentPreset: SET_AGENT_PRESET_ENDPOINT,
setContextEnhancement: SET_CONTEXT_ENHANCEMENT_ENDPOINT,
setAccessPolicy: SET_ACCESS_POLICY_ENDPOINT,
});
export const SLACK_RPC_ENDPOINTS = Object.freeze(Object.values(SLACK_ENDPOINTS));
@ -81,6 +83,10 @@ function payloadFailure(endpoint, payload) {
return validContextEnhancementPayload(payload)
? null : '请提交有效的上下文增强设置。';
}
if (endpoint === SLACK_ENDPOINTS.setAccessPolicy) {
return validAccessPolicyPayload(payload)
? null : '请提交有效的访问设置。';
}
return 'Unknown Slack endpoint.';
}
@ -164,6 +170,10 @@ export function createSlackRpcHandler(controller) {
if (typeof controller.updateContextEnhancement !== 'function') throw new Error('Context enhancement update is unavailable');
value = await controller.updateContextEnhancement(payload.botId, payload.config);
}
else if (endpoint === SLACK_ENDPOINTS.setAccessPolicy) {
if (typeof controller.updateAccessPolicy !== 'function') throw new Error('Access policy update is unavailable');
value = await controller.updateAccessPolicy(payload.botId, payload.policy);
}
else if (endpoint === SLACK_ENDPOINTS.setAgentPreset) {
if (typeof controller.updateAgentPreset !== 'function') throw new Error('Agent preset update is unavailable');
value = await controller.updateAgentPreset(payload.botId, payload.agentPreset);

View file

@ -3,60 +3,13 @@ import {
createTokenBotRpcHandler,
} from '../shared/rpc.mjs';
import { resolveRpcAuthority } from '../../rpc-authority.mjs';
import { normalizeTelegramAccessPolicy } from '../../../../src/channels/telegram/config-store.mjs';
export const TELEGRAM_RPC_CHANNEL = '/telegram';
export const TELEGRAM_ENDPOINTS = Object.freeze({
...TOKEN_BOT_ENDPOINTS,
setAccessPolicy: 'bot.access-policy.set',
});
export const TELEGRAM_ENDPOINTS = TOKEN_BOT_ENDPOINTS;
export const TELEGRAM_RPC_ENDPOINTS = Object.freeze(Object.values(TELEGRAM_ENDPOINTS));
export function createTelegramRpcHandler(controller) {
if (typeof controller?.setAccessPolicy !== 'function') {
throw new TypeError('A complete Telegram controller is required (setAccessPolicy)');
}
const sharedHandler = createTokenBotRpcHandler(controller, { channel: 'Telegram' });
return async (endpoint, payload, signal) => {
if (endpoint !== TELEGRAM_ENDPOINTS.setAccessPolicy) {
return sharedHandler(endpoint, payload, signal);
}
if (signal?.aborted) {
return { ok: false, error: { code: 'cancelled', message: 'The request was cancelled.' } };
}
const keys = payload && typeof payload === 'object' && !Array.isArray(payload)
? Object.keys(payload) : [];
if (keys.length !== 3 || !keys.every((key) => (
['botId', 'accessMode', 'allowedUsers'].includes(key)
)) || typeof payload.botId !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(payload.botId)) {
return {
ok: false,
error: { code: 'bad-request', message: 'bot.access-policy.set requires a valid policy.' },
};
}
let accessPolicy;
try {
accessPolicy = normalizeTelegramAccessPolicy(payload);
} catch {
return {
ok: false,
error: { code: 'bad-request', message: '请输入有效的 Telegram 访问模式和数字 User ID。' },
};
}
try {
const value = await controller.setAccessPolicy(payload.botId, accessPolicy);
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: 'telegram-operation-failed', message: 'Telegram 操作失败,请稍后重试。' },
};
}
};
return createTokenBotRpcHandler(controller, { channel: 'Telegram' });
}
export function installTelegramRpc(ctx, controller, authority) {

View file

@ -20,6 +20,10 @@ import { createConnectionSupervisor } from './connection-supervisor.mjs';
import { createHarnessCommandExecutor } from '../../harness-command-executor.mjs';
import { harnessConnection } from '../../harness-connection.mjs';
import { createHarnessSessionExecutors } from '../../harness-session-coordinator.mjs';
import {
accessPolicyProvider,
initialAccessPolicyFor,
} from '../shared/access-policy-production.mjs';
function pluginPaths(config) {
const dshHome = resolve(config.dshHome ?? process.env.DSH_HOME ?? join(homedir(), '.dsh'));
@ -54,6 +58,7 @@ export async function createProductionController(ctx, config = {}, internals = {
await workspaces.reconcile(configuredBots.map((bot) => bot.botId));
await Promise.all(configuredBots.map((bot) => workspaces.ensure(bot.botId, {
defaultAgentPreset: config.agentPreset,
initialAccessPolicy: initialAccessPolicyFor('wecom', bot),
})));
const observedConfigStore = typeof configStore.remove === 'function'
? observeBotWorkspaceRemovals(configStore, { workspaces })
@ -95,7 +100,10 @@ export async function createProductionController(ctx, config = {}, internals = {
logger,
createRuntime: async ({ botId, config: botConfig, secret }) => {
const state = await stateFor(botId);
await workspaces.ensure(botId, { defaultAgentPreset: config.agentPreset });
await workspaces.ensure(botId, {
defaultAgentPreset: config.agentPreset,
initialAccessPolicy: initialAccessPolicyFor('wecom', botConfig),
});
const workspaceScope = createBotWorkspaceScope(harness, {
botId, workspaces, state, agentPresetCatalog,
});
@ -105,6 +113,9 @@ export async function createProductionController(ctx, config = {}, internals = {
harness: workspaceScope.harness,
state: workspaceScope.state,
contextEnhancement: { botId, getSettings: () => workspaces.contextEnhancementFor(botId) },
accessPolicy: accessPolicyProvider(workspaces, botId, {
channel: 'wecom', config: botConfig,
}),
replyTimeoutMs: config.replyTimeoutMs ?? 600_000,
connectTimeoutMs: config.connectTimeoutMs ?? 20_000,
maxReconnectAttempts: config.maxReconnectAttempts ?? 10,

View file

@ -1,5 +1,6 @@
import QRCode from 'qrcode';
import { SET_CONTEXT_ENHANCEMENT_ENDPOINT, validContextEnhancementPayload } from '../shared/context-enhancement-rpc.mjs';
import { SET_ACCESS_POLICY_ENDPOINT, validAccessPolicyPayload } from '../shared/access-policy-rpc.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';
@ -20,6 +21,7 @@ export const WECOM_ENDPOINTS = Object.freeze({
setWorkspace: SET_WORKSPACE_ENDPOINT,
setAgentPreset: SET_AGENT_PRESET_ENDPOINT,
setContextEnhancement: SET_CONTEXT_ENHANCEMENT_ENDPOINT,
setAccessPolicy: SET_ACCESS_POLICY_ENDPOINT,
});
export const WECOM_RPC_ENDPOINTS = Object.freeze(Object.values(WECOM_ENDPOINTS));
@ -82,6 +84,10 @@ function payloadFailure(endpoint, payload) {
return validContextEnhancementPayload(payload)
? null : '请提交有效的上下文增强设置。';
}
if (endpoint === WECOM_ENDPOINTS.setAccessPolicy) {
return validAccessPolicyPayload(payload)
? null : '请提交有效的访问设置。';
}
return 'Unknown Enterprise WeChat endpoint.';
}
@ -183,6 +189,11 @@ export function createWecomRpcHandler(controller, { encodeQr = qrDataUrl } = {})
value = await controller.updateContextEnhancement(
payload.botId, payload.config, (status) => publicStatus(status, cachedEncode),
);
} else if (endpoint === WECOM_ENDPOINTS.setAccessPolicy) {
if (typeof controller.updateAccessPolicy !== 'function') throw new Error('Access policy update is unavailable');
value = await controller.updateAccessPolicy(
payload.botId, payload.policy, (status) => publicStatus(status, cachedEncode),
);
} else if (endpoint === WECOM_ENDPOINTS.setAgentPreset) {
if (typeof controller.updateAgentPreset !== 'function') throw new Error('Agent preset update is unavailable');
value = await publicStatus(

View file

@ -23,6 +23,10 @@ import { createConnectionSupervisor } from './connection-supervisor.mjs';
import { createHarnessCommandExecutor } from '../../harness-command-executor.mjs';
import { harnessConnection } from '../../harness-connection.mjs';
import { createHarnessSessionExecutors } from '../../harness-session-coordinator.mjs';
import {
accessPolicyProvider,
initialAccessPolicyFor,
} from '../shared/access-policy-production.mjs';
function pluginPaths(config) {
const dshHome = resolve(config.dshHome ?? process.env.DSH_HOME ?? join(homedir(), '.dsh'));
@ -60,6 +64,7 @@ export async function createProductionController(ctx, config = {}, internals = {
await workspaces.reconcile(configuredBots.map((bot) => bot.botId));
await Promise.all(configuredBots.map((bot) => workspaces.ensure(bot.botId, {
defaultAgentPreset: config.agentPreset,
initialAccessPolicy: initialAccessPolicyFor('weixin', bot),
})));
const observedConfigStore = typeof configStore.remove === 'function'
? observeBotWorkspaceRemovals(configStore, { workspaces })
@ -98,7 +103,10 @@ export async function createProductionController(ctx, config = {}, internals = {
logger,
createRuntime: async ({ botId, config: accountConfig, token }) => {
const state = await stateFor(botId);
await workspaces.ensure(botId, { defaultAgentPreset: config.agentPreset });
await workspaces.ensure(botId, {
defaultAgentPreset: config.agentPreset,
initialAccessPolicy: initialAccessPolicyFor('weixin', accountConfig),
});
const workspaceScope = createBotWorkspaceScope(harness, {
botId, workspaces, state, agentPresetCatalog,
});
@ -109,6 +117,9 @@ export async function createProductionController(ctx, config = {}, internals = {
harness: workspaceScope.harness,
state: workspaceScope.state,
contextEnhancement: { botId, getSettings: () => workspaces.contextEnhancementFor(botId) },
accessPolicy: accessPolicyProvider(workspaces, botId, {
channel: 'weixin', config: accountConfig,
}),
replyTimeoutMs: config.replyTimeoutMs ?? 600_000,
maxMessageChars: config.maxMessageChars ?? DEFAULT_WEIXIN_MAX_MESSAGE_CHARS,
logger: {

View file

@ -1,5 +1,6 @@
import QRCode from 'qrcode';
import { SET_CONTEXT_ENHANCEMENT_ENDPOINT, validContextEnhancementPayload } from '../shared/context-enhancement-rpc.mjs';
import { SET_ACCESS_POLICY_ENDPOINT, validAccessPolicyPayload } from '../shared/access-policy-rpc.mjs';
import { resolveRpcAuthority } from '../../rpc-authority.mjs';
import {
publicWorkspaceError,
@ -27,6 +28,7 @@ export const WEIXIN_ENDPOINTS = Object.freeze({
setWorkspace: SET_WORKSPACE_ENDPOINT,
setAgentPreset: SET_AGENT_PRESET_ENDPOINT,
setContextEnhancement: SET_CONTEXT_ENHANCEMENT_ENDPOINT,
setAccessPolicy: SET_ACCESS_POLICY_ENDPOINT,
});
export const WEIXIN_RPC_ENDPOINTS = Object.freeze(Object.values(WEIXIN_ENDPOINTS));
@ -89,6 +91,10 @@ function payloadFailure(endpoint, payload) {
return validContextEnhancementPayload(payload)
? null : '请提交有效的上下文增强设置。';
}
if (endpoint === WEIXIN_ENDPOINTS.setAccessPolicy) {
return validAccessPolicyPayload(payload)
? null : '请提交有效的访问设置。';
}
return 'Unknown Weixin endpoint.';
}
@ -218,6 +224,11 @@ export function createWeixinRpcHandler(controller, { encodeQr = qrDataUrl } = {}
value = await controller.updateContextEnhancement(
payload.botId, payload.config, (status) => publicStatus(status, cachedEncode),
);
} else if (endpoint === WEIXIN_ENDPOINTS.setAccessPolicy) {
if (typeof controller.updateAccessPolicy !== 'function') throw new Error('Access policy update is unavailable');
value = await controller.updateAccessPolicy(
payload.botId, payload.policy, (status) => publicStatus(status, cachedEncode),
);
} else if (endpoint === WEIXIN_ENDPOINTS.setAgentPreset) {
if (typeof controller.updateAgentPreset !== 'function') throw new Error('Agent preset update is unavailable');
value = await publicStatus(

View file

@ -6,7 +6,10 @@ import { WhatsappConfigStore } from '../../../../src/channels/whatsapp/config-st
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 {
WhatsappRuntime,
whatsappAccessPolicyIdsEqual,
} from '../../../../src/channels/whatsapp/whatsapp-runtime.mjs';
import { createWhatsappWebSession } from '../../../../src/channels/whatsapp/whatsapp-web-session.mjs';
import {
BotWorkspaceStore,
@ -20,6 +23,10 @@ import { createTokenConnectionSupervisor } from '../shared/connection-supervisor
import { createHarnessCommandExecutor } from '../../harness-command-executor.mjs';
import { harnessConnection } from '../../harness-connection.mjs';
import { createHarnessSessionExecutors } from '../../harness-session-coordinator.mjs';
import {
accessPolicyProvider,
initialAccessPolicyFor,
} from '../shared/access-policy-production.mjs';
const AUTH_DIRECTORY_PATTERN = /^[a-f0-9-]{36}$/;
@ -61,6 +68,7 @@ export async function createProductionController(ctx, config = {}, internals = {
await workspaces.reconcile(configuredBots.map((bot) => bot.botId));
await Promise.all(configuredBots.map((bot) => workspaces.ensure(bot.botId, {
defaultAgentPreset: config.agentPreset,
initialAccessPolicy: initialAccessPolicyFor('whatsapp', bot),
})));
const observedConfigStore = typeof configStore.remove === 'function'
? observeBotWorkspaceRemovals(configStore, { workspaces })
@ -98,7 +106,10 @@ export async function createProductionController(ctx, config = {}, internals = {
logger,
createRuntime: async ({ botId, config: botConfig, authDir }) => {
const state = await stateFor(botId);
await workspaces.ensure(botId, { defaultAgentPreset: config.agentPreset });
await workspaces.ensure(botId, {
defaultAgentPreset: config.agentPreset,
initialAccessPolicy: initialAccessPolicyFor('whatsapp', botConfig),
});
const workspaceScope = createBotWorkspaceScope(harness, {
botId, workspaces, state, agentPresetCatalog,
});
@ -108,6 +119,9 @@ export async function createProductionController(ctx, config = {}, internals = {
harness: workspaceScope.harness,
state: workspaceScope.state,
contextEnhancement: { botId, getSettings: () => workspaces.contextEnhancementFor(botId) },
accessPolicy: accessPolicyProvider(workspaces, botId, {
channel: 'whatsapp', config: botConfig, equals: whatsappAccessPolicyIdsEqual,
}),
replyTimeoutMs: config.replyTimeoutMs ?? 600_000,
connectTimeoutMs: config.connectTimeoutMs ?? 30_000,
createSession,

View file

@ -1,7 +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 { SET_ACCESS_POLICY_ENDPOINT, validAccessPolicyPayload } from '../shared/access-policy-rpc.mjs';
import { SET_CONTEXT_ENHANCEMENT_ENDPOINT, validContextEnhancementPayload } from '../shared/context-enhancement-rpc.mjs';
import { resolveRpcAuthority } from '../../rpc-authority.mjs';
import { publicWorkspaceError, SET_WORKSPACE_ENDPOINT, validWorkspacePayload } from '../shared/workspace-rpc.mjs';
@ -15,7 +15,7 @@ export const WHATSAPP_ENDPOINTS = Object.freeze({
cancelProvisioning: 'provision.cancel',
reconnectBot: 'bot.reconnect',
deleteBot: 'bot.delete',
setAccessPolicy: 'bot.access-policy.set',
setAccessPolicy: SET_ACCESS_POLICY_ENDPOINT,
setWorkspace: SET_WORKSPACE_ENDPOINT,
setAgentPreset: SET_AGENT_PRESET_ENDPOINT,
setContextEnhancement: SET_CONTEXT_ENHANCEMENT_ENDPOINT,
@ -55,15 +55,8 @@ function payloadFailure(endpoint, payload) {
&& 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 访问模式和电话号码。';
}
return validAccessPolicyPayload(payload)
? null : '请提交有效的访问设置。';
}
if (endpoint === WHATSAPP_ENDPOINTS.setWorkspace) {
return validWorkspacePayload(payload)
@ -111,7 +104,7 @@ async function publicStatus(value, encodeQr) {
}
export function createWhatsappRpcHandler(controller, { encodeQr = qrDataUrl } = {}) {
for (const method of ['status', 'startProvisioning', 'registrationStatus', 'cancelProvisioning', 'reconnectBot', 'deleteBot', 'setAccessPolicy']) {
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})`);
}
@ -191,9 +184,11 @@ export function createWhatsappRpcHandler(controller, { encodeQr = qrDataUrl } =
cachedEncode,
);
} else if (endpoint === WHATSAPP_ENDPOINTS.setAccessPolicy) {
value = await publicStatus(
await controller.setAccessPolicy(payload.botId, normalizeWhatsappAccessPolicy(payload)),
cachedEncode,
if (typeof controller.updateAccessPolicy !== 'function') throw new Error('Access policy update is unavailable');
value = await controller.updateAccessPolicy(
payload.botId,
payload.policy,
(status) => publicStatus(status, cachedEncode),
);
} else {
value = await publicStatus(await controller.deleteBot(payload.botId), cachedEncode);

View file

@ -58,6 +58,10 @@ import {
messageFailureText,
setLastMessageFailure,
} from '../shared/message-failure.mjs';
import {
COMMAND_PERMISSION_DENIED_MESSAGE,
evaluateInboundAccess,
} from '../shared/inbound-access.mjs';
import { t } from '../shared/i18n.mjs';
const CARD_INITIAL_TEXT = '已连接 DeepSeek Harness,正在思考…';
@ -374,6 +378,7 @@ export class DingtalkHarnessBridge {
#harness;
#state;
#contextEnhancement;
#accessPolicy;
#status;
#logger;
#replyTimeoutMs;
@ -397,6 +402,7 @@ export class DingtalkHarnessBridge {
harness,
state,
contextEnhancement,
accessPolicy,
status = createDingtalkBridgeStatus(),
logger = console,
replyTimeoutMs = 600_000,
@ -415,6 +421,7 @@ export class DingtalkHarnessBridge {
this.#harness = harness;
this.#state = state;
this.#contextEnhancement = contextEnhancement;
this.#accessPolicy = accessPolicy;
this.#status = status;
this.#logger = logger;
this.#approvals = new HarnessApprovalQueue({ label: 'DingTalk', logger });
@ -439,17 +446,13 @@ export class DingtalkHarnessBridge {
const sender = senderStaffId(message);
if (!messageId || !sender || this.#state.hasSeen(messageId)
|| this.#acceptedMessageIds.has(messageId)) return Promise.resolve();
this.#acceptedMessageIds.set(messageId, contextSnapshot === undefined ? captureContextEnhancement(
this.#contextEnhancement,
message.conversationType === '1' || message.conversationType === 1 ? 'direct'
: message.conversationType === '2' || message.conversationType === 2 ? 'group' : null,
) : contextSnapshot);
const conversationType = String(message.conversationType) === '2' ? 'group'
: String(message.conversationType) === '1' ? 'direct' : null;
let key;
try {
key = conversationKey(message, sender);
} catch {
this.#acceptedMessageIds.delete(messageId);
increment(this.#status, 'messagesRejected');
this.#status.lastRejectedAt = new Date().toISOString();
return Promise.resolve();
@ -461,9 +464,6 @@ export class DingtalkHarnessBridge {
} catch {
// An unsafe reply route must never be able to submit an approval.
}
if (sessionWebhook && String(message.conversationType) !== '2') {
rememberConnectionTestTarget(this.#state, { sessionWebhook });
}
const pending = this.#pendingInteractions.get(key);
const promptMessage = dingtalkInboundMessage(message, {
api: this.#api,
@ -473,6 +473,26 @@ export class DingtalkHarnessBridge {
const commandText = nonEmptyString(promptMessage.content) ?? '';
const addressed = String(message.conversationType) !== '2' || message?.isInAtList === true;
const direct = String(message.conversationType) !== '2';
if (addressed) {
const access = evaluateInboundAccess(this.#accessPolicy, {
conversationType,
senderIds: sender,
text: commandText,
hasImages: hasInboundImages(promptMessage),
hasFiles: hasInboundFiles(promptMessage),
});
if (!access.allowed) {
this.#acceptedMessageIds.set(messageId, null);
return this.#finishAccessDecision(message, messageId, sessionWebhook, access);
}
}
this.#acceptedMessageIds.set(messageId, contextSnapshot === undefined ? captureContextEnhancement(
this.#contextEnhancement,
conversationType,
) : contextSnapshot);
if (sessionWebhook && direct) {
rememberConnectionTestTarget(this.#state, { sessionWebhook });
}
const statusReaction = sessionWebhook && addressed ? this.#startStatusReaction(message) : null;
const finish = (task) => Promise.resolve(task).then(
(value) => {
@ -855,6 +875,38 @@ export class DingtalkHarnessBridge {
return task;
}
#finishAccessDecision(message, messageId, sessionWebhook, access) {
let task;
task = Promise.resolve().then(async () => {
if (this.#state.hasSeen(messageId)) return;
await this.#state.markSeen(messageId);
if (access.reason === 'command-not-allowed' && sessionWebhook) {
increment(this.#status, 'messagesReceived');
this.#status.lastMessageAt = new Date().toISOString();
await this.#send(
sessionWebhook,
t(COMMAND_PERMISSION_DENIED_MESSAGE),
this.#atUsersFor(message),
);
increment(this.#status, 'messagesReplied');
this.#status.lastReplyAt = new Date().toISOString();
} else {
increment(this.#status, 'messagesRejected');
this.#status.lastRejectedAt = new Date().toISOString();
}
this.#status.lastError = null;
}).catch((error) => {
if (this.#signal?.aborted) return;
this.#status.lastError = error?.message ?? String(error);
this.#logger.error?.('[dsh-dingtalk] failed to apply inbound access policy', error);
}).finally(() => {
this.#acceptedMessageIds.delete(messageId);
this.#commandTasks.delete(task);
});
this.#commandTasks.add(task);
return task;
}
async #process(message, messageId, sender, key, {
alreadyRecorded = false,
preparedMessage,

View file

@ -130,6 +130,7 @@ export class DingtalkRuntime {
#harness;
#state;
#contextEnhancement;
#accessPolicy;
#logger;
#replyTimeoutMs;
#maxMessageChars;
@ -152,6 +153,7 @@ export class DingtalkRuntime {
harness,
state,
contextEnhancement,
accessPolicy,
logger = console,
replyTimeoutMs = 600_000,
maxMessageChars = 4_000,
@ -170,6 +172,7 @@ export class DingtalkRuntime {
this.#harness = harness;
this.#state = state;
this.#contextEnhancement = contextEnhancement;
this.#accessPolicy = accessPolicy;
this.#logger = logger;
this.#replyTimeoutMs = replyTimeoutMs;
this.#maxMessageChars = maxMessageChars;
@ -238,6 +241,7 @@ export class DingtalkRuntime {
harness: this.#harness,
state: this.#state,
contextEnhancement: this.#contextEnhancement,
accessPolicy: this.#accessPolicy,
status: this.#status,
logger: this.#logger,
replyTimeoutMs: this.#replyTimeoutMs,

View file

@ -3,6 +3,7 @@ import { fetchFileStream } from '../shared/file-download.mjs';
import { fetchImageBuffer } from '../shared/image-prompt.mjs';
import { t } from '../shared/i18n.mjs';
import { captureContextEnhancement } from '../shared/context-enhancement.mjs';
import { evaluateInboundAccess } from '../shared/inbound-access.mjs';
import { DiscordApi } from './discord-api.mjs';
import { createDiscordBridgeStatus, DiscordHarnessBridge } from './discord-bridge.mjs';
@ -433,6 +434,7 @@ export class DiscordRuntime {
#harness;
#state;
#contextEnhancement;
#accessPolicy;
#logger;
#replyTimeoutMs;
#connectTimeoutMs;
@ -464,6 +466,7 @@ export class DiscordRuntime {
harness,
state,
contextEnhancement,
accessPolicy,
logger = console,
replyTimeoutMs = 600_000,
connectTimeoutMs = 20_000,
@ -480,6 +483,7 @@ export class DiscordRuntime {
this.#harness = harness;
this.#state = state;
this.#contextEnhancement = contextEnhancement;
this.#accessPolicy = accessPolicy;
this.#logger = logger;
this.#replyTimeoutMs = replyTimeoutMs;
this.#connectTimeoutMs = connectTimeoutMs;
@ -559,6 +563,7 @@ export class DiscordRuntime {
harness: this.#harness,
state: this.#state,
contextEnhancement: this.#contextEnhancement,
accessPolicy: this.#accessPolicy,
status: this.#status,
logger: this.#logger,
replyTimeoutMs: this.#replyTimeoutMs,
@ -730,6 +735,24 @@ export class DiscordRuntime {
async #acceptMessage(message, bridge) {
const messageId = String(message?.id ?? '');
if (!messageId || this.#state.hasSeen(messageId)) return;
const preflight = normalizeDiscordMessage(message, this.#config.platformId);
let accessDecision;
if (preflight?.kind === 'group' && preflight.addressed === true
&& preflight.senderIsBot !== true) {
accessDecision = evaluateInboundAccess(this.#accessPolicy, {
conversationType: 'group',
senderIds: [preflight.senderId],
text: preflight.content,
hasImages: preflight.images.length > 0,
hasFiles: preflight.files.length > 0,
});
if (!accessDecision.allowed) {
// Let the shared bridge apply its normal silent/command-denial behavior,
// but do so against the source channel before creating a Thread.
await bridge.accept(preflight, { accessDecision });
return;
}
}
let route = this.#routing.get(messageId);
if (!route) {
const contextSnapshot = captureContextEnhancement(
@ -750,7 +773,12 @@ export class DiscordRuntime {
}
try {
const normalized = await route.pendingRoute;
if (normalized) await bridge.accept(normalized, { contextSnapshot: route.contextSnapshot });
if (normalized) {
await bridge.accept(normalized, {
contextSnapshot: route.contextSnapshot,
...(accessDecision ? { accessDecision } : {}),
});
}
} catch (error) {
if (error?.code === 'discord-thread-create-uncertain') {
await this.#state.markSeen(messageId);

View file

@ -59,6 +59,11 @@ import {
setLastMessageFailure,
} from '../shared/message-failure.mjs';
import { beginStatusReaction } from '../shared/status-reaction.mjs';
import {
COMMAND_PERMISSION_DENIED_MESSAGE,
evaluateInboundAccess,
} from '../shared/inbound-access.mjs';
import { isSharedLocalCommand } from '../shared/command-permission.mjs';
import {
MENU_PAGE_SIZE,
PRESET_FOLLOW_DEFAULT_SENTINEL,
@ -135,6 +140,17 @@ const ARCHIVED_COMMAND = /^\/archived(?:\s+(on|off))?$/i;
/** Matches fast card commands that should not be queued behind a running task. */
const CARD_COMMAND = /^\/(?:m(?:enu)?|new|help|status|compact|(?:sessionlist|sessions)(?:\s|$)|workspacelist|watchlist|archived(?:\s+(on|off))?)$/i;
function isFeishuLocalCommand(text, { hasImages = false, hasFiles = false } = {}) {
if (hasImages || hasFiles || typeof text !== 'string') return false;
const command = text.trim();
return MENU_COMMAND.test(command)
|| REPAIR_COMMAND_PREFIX.test(command)
|| WATCH_COMMAND.test(command)
|| UNWATCH_COMMAND.test(command)
|| WATCHLIST_COMMAND.test(command)
|| ARCHIVED_COMMAND.test(command);
}
/** Canonical workspace/session help advertised by every bridge family. */
const WORKSPACE_HELP_LINES = [
'/session Session ID 或当前工作区序号 将当前聊天绑定到指定会话',
@ -385,6 +401,7 @@ export class FeishuHarnessBridge {
#harness;
#state;
#contextEnhancement;
#accessPolicy;
#queues = new Map();
#batchInputs = new BatchInputManager();
#pendingInteractions = new Map();
@ -451,6 +468,7 @@ export class FeishuHarnessBridge {
harness,
state,
contextEnhancement,
accessPolicy,
status,
allowedSenderOpenIds = new Set(),
botId,
@ -488,6 +506,7 @@ export class FeishuHarnessBridge {
this.#harness = harness;
this.#state = state;
this.#contextEnhancement = contextEnhancement;
this.#accessPolicy = accessPolicy;
this.#status = status;
this.#allowedSenderOpenIds = allowedSenderOpenIds;
this.#botId = nonEmptyString(botId);
@ -526,10 +545,10 @@ export class FeishuHarnessBridge {
if (this.#signal?.aborted) return Promise.resolve();
const messageId = nonEmptyString(event?.message?.message_id);
if (!messageId || isBotSender(event)) return Promise.resolve();
if (!isAllowedSender(event, this.#allowedSenderOpenIds)) {
if (!this.#accessPolicy && !isAllowedSender(event, this.#allowedSenderOpenIds)) {
this.#status.messagesRejected += 1;
this.#status.lastRejectedAt = new Date().toISOString();
this.#logger.warn?.('[dsh-feishu] ignored a message from a sender outside the allowlist');
this.#logger.warn?.('[dsh-feishu] ignored a message from a sender outside the legacy allowlist');
return Promise.resolve();
}
const addressed = this.#isAddressed(event);
@ -551,6 +570,28 @@ export class FeishuHarnessBridge {
return Promise.resolve();
}
const commandMessage = extractInboundMessage(event, this.#client);
const commandText = nonEmptyString(commandMessage.content) ?? '';
const hasImages = hasInboundImages(commandMessage);
const hasFiles = hasInboundFiles(commandMessage);
const conversationType = event.message.chat_type === 'p2p' ? 'direct'
: event.message.chat_type === 'group' ? 'group' : null;
const access = evaluateInboundAccess(this.#accessPolicy, {
conversationType,
senderIds: senderOpenId(event),
text: commandText,
hasImages,
hasFiles,
isCommand: isSharedLocalCommand(commandText, {
hasImages,
hasFiles,
}) || isFeishuLocalCommand(commandText, { hasImages, hasFiles })
|| (!hasImages && !hasFiles && NUMBER_REPLY.test(commandText) && this.#menus.has(key)),
});
if (!access.allowed) {
this.#acceptedMessageIds.set(messageId, null);
return this.#finishAccessDecision(event, messageId, access);
}
if (event.message.chat_type === 'p2p') {
const chatId = nonEmptyString(event.message.chat_id);
if (chatId) rememberConnectionTestTarget(this.#state, { chatId });
@ -558,11 +599,9 @@ export class FeishuHarnessBridge {
this.#acceptedMessageIds.set(messageId, captureContextEnhancement(
this.#contextEnhancement,
event.message.chat_type === 'p2p' ? 'direct' : event.message.chat_type === 'group' ? 'group' : null,
conversationType,
));
const processingReaction = this.#beginReaction(messageId);
const commandMessage = extractInboundMessage(event, this.#client);
const commandText = nonEmptyString(commandMessage.content) ?? '';
const batchText = event.message.message_type === 'text'
? nonEmptyString(extractText(event)) ?? ''
: '';
@ -792,6 +831,38 @@ export class FeishuHarnessBridge {
return current;
}
#finishAccessDecision(event, messageId, access) {
let current;
current = Promise.resolve().then(async () => {
if (this.#state.hasSeen(messageId)) return;
await this.#state.markSeen(messageId);
if (access.reason === 'command-not-allowed') {
this.#status.lastMessageAt = new Date().toISOString();
this.#status.messagesReceived += 1;
await this.#send(
event.message.chat_id,
t(COMMAND_PERMISSION_DENIED_MESSAGE),
{ replyTo: event.message.message_id },
);
this.#status.messagesReplied += 1;
this.#status.lastReplyAt = new Date().toISOString();
} else {
this.#status.messagesRejected += 1;
this.#status.lastRejectedAt = new Date().toISOString();
}
this.#status.lastError = null;
}).catch((error) => {
if (this.#signal?.aborted) return;
this.#status.lastError = error?.message ?? String(error);
this.#logger.warn?.('[dsh-feishu] failed to apply inbound access policy');
}).finally(() => {
this.#acceptedMessageIds.delete(messageId);
this.#commandTasks.delete(current);
});
this.#commandTasks.add(current);
return current;
}
#enqueueMessage(event, messageId, key, processingReaction, {
releaseMessageId = true,
alreadyRecorded = false,
@ -1490,10 +1561,11 @@ export class FeishuHarnessBridge {
?? nonEmptyString(event?.operator?.operator_id?.user_id)
?? nonEmptyString(event?.open_id)
?? nonEmptyString(event?.user_id);
const operatorAllowed = operatorOpenId !== null
&& (this.#allowedSenderOpenIds.has('*') || this.#allowedSenderOpenIds.has(operatorOpenId));
if (!operatorAllowed) {
this.#logger.warn?.('[dsh-feishu] ignoring card action from an unallowed sender');
if (!operatorOpenId) return Promise.resolve();
if (!this.#accessPolicy
&& !this.#allowedSenderOpenIds.has('*')
&& !this.#allowedSenderOpenIds.has(operatorOpenId)) {
this.#logger.warn?.('[dsh-feishu] ignoring card action from an unallowed legacy sender');
return Promise.resolve();
}
const actionValue = callbackObject(event?.action?.value);
@ -1532,6 +1604,11 @@ export class FeishuHarnessBridge {
?? nonEmptyString(event?.message_id);
const route = messageId ? this.#cardKeys.get(messageId) : null;
if (!route) {
// A route is also the trusted direct/group scope for the unified policy.
// Without it, fail closed instead of producing an unauthorised side effect.
if (this.#accessPolicy) return Promise.resolve();
// Legacy callers without a unified policy still receive the current
// expired-card guidance introduced by the upstream thread-reply fix.
// The card predates this process (the in-memory mapping resets on
// restart) or never came from us: nudge instead of staying silent.
const chatId = nonEmptyString(event?.context?.open_chat_id)
@ -1542,6 +1619,21 @@ export class FeishuHarnessBridge {
}
return Promise.resolve();
}
const conversationType = route.key.startsWith('p2p:') ? 'direct'
: route.key.startsWith('group:') ? 'group' : null;
const access = evaluateInboundAccess(this.#accessPolicy, {
conversationType,
senderIds: operatorOpenId,
isCommand: true,
});
if (!access.allowed) {
if (access.reason === 'command-not-allowed') {
return this.#send(route.chatId, t(COMMAND_PERMISSION_DENIED_MESSAGE))
.catch(() => undefined);
}
this.#logger.warn?.('[dsh-feishu] ignoring card action blocked by access policy');
return Promise.resolve();
}
// A used card is recent even if it was first created long ago.
this.#cardKeys.delete(messageId);
this.#cardKeys.set(messageId, route);

View file

@ -114,6 +114,7 @@ export class FeishuRuntime {
#harness;
#state;
#contextEnhancement;
#accessPolicy;
#replyTimeoutMs;
#connectTimeoutMs;
#requestTimeoutMs;
@ -143,6 +144,7 @@ export class FeishuRuntime {
harness,
state,
contextEnhancement,
accessPolicy,
repair,
replyTimeoutMs = 600000,
connectTimeoutMs = 15000,
@ -176,6 +178,7 @@ export class FeishuRuntime {
this.#harness = harness;
this.#state = state;
this.#contextEnhancement = contextEnhancement;
this.#accessPolicy = accessPolicy;
this.#repair = repair ?? null;
this.#replyTimeoutMs = replyTimeoutMs;
this.#connectTimeoutMs = connectTimeoutMs;
@ -276,6 +279,7 @@ export class FeishuRuntime {
harness: this.#harness,
state: this.#state,
contextEnhancement: this.#contextEnhancement,
accessPolicy: this.#accessPolicy,
status: this.#status,
allowedSenderOpenIds: new Set(this.#ownerOpenIds),
botId: this.#botId,

View file

@ -54,6 +54,10 @@ import {
messageFailureText,
setLastMessageFailure,
} from '../shared/message-failure.mjs';
import {
COMMAND_PERMISSION_DENIED_MESSAGE,
evaluateInboundAccess,
} from '../shared/inbound-access.mjs';
import { sendMarkdownReply } from './markdown-reply.mjs';
import { t } from '../shared/i18n.mjs';
@ -110,9 +114,6 @@ function conversationKey(message) {
}
function senderAllowed(message, ownerUserOpenid) {
// QR binding yields a C2C user_openid, while group events identify senders
// with a group-scoped member_openid. Treat group membership plus @mention as
// the access boundary, and keep the scanner restriction for private chats.
return message?.kind === 'group'
|| ownerUserOpenid === '*'
|| message?.senderId === ownerUserOpenid;
@ -376,6 +377,7 @@ export class QqHarnessBridge {
#harness;
#state;
#contextEnhancement;
#accessPolicy;
#status;
#logger;
#replyTimeoutMs;
@ -398,6 +400,7 @@ export class QqHarnessBridge {
harness,
state,
contextEnhancement,
accessPolicy,
status = createQqBridgeStatus(),
logger = console,
replyTimeoutMs = 600_000,
@ -417,6 +420,7 @@ export class QqHarnessBridge {
this.#harness = harness;
this.#state = state;
this.#contextEnhancement = contextEnhancement;
this.#accessPolicy = accessPolicy;
this.#status = status;
this.#logger = logger;
this.#replyTimeoutMs = replyTimeoutMs;
@ -439,6 +443,26 @@ export class QqHarnessBridge {
|| this.#state.hasSeen(messageId)
|| this.#acceptedMessageIds.has(messageId)) return Promise.resolve();
const key = conversationKey(message);
const addressed = message.kind !== 'group'
|| message.rawEventType === 'GROUP_AT_MESSAGE_CREATE';
const commandText = safeText(message);
if (addressed) {
const access = this.#accessPolicy
? evaluateInboundAccess(this.#accessPolicy, {
conversationType: message.kind === 'c2c' ? 'direct' : 'group',
senderIds: sender,
text: commandText,
hasImages: hasQqImageAttachments(message),
hasFiles: hasQqFileAttachments(message),
})
: senderAllowed(message, this.#ownerUserOpenid)
? { allowed: true, reason: 'legacy-owner' }
: { allowed: false, reason: 'sender-not-allowed' };
if (!access.allowed) {
this.#acceptedMessageIds.set(messageId, null);
return this.#finishAccessDecision(message, messageId, access);
}
}
this.#acceptedMessageIds.set(messageId, captureContextEnhancement(
this.#contextEnhancement,
message.kind === 'c2c' ? 'direct' : 'group',
@ -450,20 +474,16 @@ export class QqHarnessBridge {
rememberConnectionTestTarget(this.#state, message.replyTarget);
}
const pending = this.#pendingInteractions.get(key);
const commandText = safeText(message);
const allowed = senderAllowed(message, this.#ownerUserOpenid);
const addressed = message.kind !== 'group'
|| message.rawEventType === 'GROUP_AT_MESSAGE_CREATE';
const batchCommand = isBatchInputCommand(commandText);
const batchStatus = this.#batchInputs.status(key);
if (batchCommand && allowed && addressed && message.kind === 'group') {
if (batchCommand && addressed && message.kind === 'group') {
return this.#finishBatchResult(
message,
messageId,
{ message: batchInputGroupUnsupportedMessage() },
);
}
if (allowed && message.kind === 'c2c'
if (message.kind === 'c2c'
&& (batchCommand || batchStatus.phase === 'collecting')) {
const exactBatchStart = /^\/batch$/iu.test(commandText);
const result = exactBatchStart
@ -493,7 +513,7 @@ export class QqHarnessBridge {
: (isModelCommand(commandText)
? runModelCommand
: (isPresetCommand(commandText) ? runPresetCommand : null));
if (commandRunner && allowed && addressed) {
if (commandRunner && addressed) {
let task;
task = this.#processFastCommand(
message,
@ -578,10 +598,9 @@ export class QqHarnessBridge {
alreadyRecorded = false,
batchSubmission = null,
} = {}) {
const allowed = senderAllowed(message, this.#ownerUserOpenid);
const addressed = message.kind !== 'group'
|| message.rawEventType === 'GROUP_AT_MESSAGE_CREATE';
const preparedMessage = allowed && addressed
const preparedMessage = addressed
? prefetchInboundFiles(
qqInboundMessage(message, { fetchImpl: this.#fetchImpl }),
{ signal: this.#signal },
@ -668,6 +687,34 @@ export class QqHarnessBridge {
return task;
}
#finishAccessDecision(message, messageId, access) {
let task;
task = Promise.resolve().then(async () => {
if (this.#state.hasSeen(messageId)) return;
await this.#state.markSeen(messageId);
if (access.reason === 'command-not-allowed') {
this.#status.messagesReceived += 1;
this.#status.lastMessageAt = new Date().toISOString();
await this.#bot.sendText(message.replyTarget, t(COMMAND_PERMISSION_DENIED_MESSAGE));
this.#status.messagesReplied += 1;
this.#status.lastReplyAt = new Date().toISOString();
} else {
this.#status.messagesRejected += 1;
this.#status.lastRejectedAt = new Date().toISOString();
}
this.#status.lastError = null;
}).catch((error) => {
if (this.#signal?.aborted) return;
this.#status.lastError = error?.message ?? String(error);
this.#logger.error?.('[dsh-im:qq] failed to apply inbound access policy:', error);
}).finally(() => {
this.#acceptedMessageIds.delete(messageId);
this.#commandTasks.delete(task);
});
this.#commandTasks.add(task);
return task;
}
async #deliverArtifacts(target, replyTo, artifacts = [], baseReceipt = null) {
if (artifacts.length === 0) {
return { receipt: baseReceipt, failureNoticeVisible: false, artifactSendErrors: 0 };
@ -730,11 +777,6 @@ export class QqHarnessBridge {
await this.#state.markSeen(messageId);
messageRecorded = true;
};
if (!senderAllowed(message, this.#ownerUserOpenid)) {
this.#status.messagesRejected += 1;
this.#status.lastRejectedAt = new Date().toISOString();
return;
}
if (message.kind === 'group' && message.rawEventType !== 'GROUP_AT_MESSAGE_CREATE') return;
const target = message.replyTarget;

View file

@ -5,6 +5,7 @@ import {
connectionTestTargetUnavailable,
} from '../shared/connection-test.mjs';
import { t } from '../shared/i18n.mjs';
import { evaluateInboundAccess } from '../shared/inbound-access.mjs';
import { createQqBridgeStatus, QqHarnessBridge } from './qq-bridge.mjs';
function timeoutError() {
@ -32,6 +33,7 @@ export class QqRuntime {
#harness;
#state;
#contextEnhancement;
#accessPolicy;
#logger;
#replyTimeoutMs;
#connectTimeoutMs;
@ -50,6 +52,7 @@ export class QqRuntime {
harness,
state,
contextEnhancement,
accessPolicy,
logger = console,
replyTimeoutMs = 600_000,
connectTimeoutMs = 20_000,
@ -64,6 +67,7 @@ export class QqRuntime {
this.#harness = harness;
this.#state = state;
this.#contextEnhancement = contextEnhancement;
this.#accessPolicy = accessPolicy;
this.#logger = logger;
this.#replyTimeoutMs = replyTimeoutMs;
this.#connectTimeoutMs = connectTimeoutMs;
@ -166,6 +170,7 @@ export class QqRuntime {
harness: this.#harness,
state: this.#state,
contextEnhancement: this.#contextEnhancement,
accessPolicy: this.#accessPolicy,
status: this.#status,
logger: this.#logger,
replyTimeoutMs: this.#replyTimeoutMs,
@ -177,8 +182,21 @@ export class QqRuntime {
bot.use(contentSanitizer({ parseFaceTags: true }));
bot.use?.(this.#typingMiddleware({
keepAlive: true,
predicate: (ctx) => this.#config.ownerUserOpenid === '*'
|| ctx?.message?.senderId === this.#config.ownerUserOpenid,
predicate: (ctx) => {
const message = ctx?.message;
if (!message || (message.kind === 'group'
&& message.rawEventType !== 'GROUP_AT_MESSAGE_CREATE')) return false;
if (!this.#accessPolicy) {
return message.kind === 'group'
|| this.#config.ownerUserOpenid === '*'
|| message.senderId === this.#config.ownerUserOpenid;
}
return evaluateInboundAccess(this.#accessPolicy, {
conversationType: message.kind === 'c2c' ? 'direct' : 'group',
senderIds: message.senderId,
text: typeof message.content === 'string' ? message.content.trim() : '',
}).allowed;
},
}));
let readyResolve;

View file

@ -0,0 +1,210 @@
// Shared by the Host and settings UI; keep this module browser-compatible.
export const ACCESS_POLICY_MODES = Object.freeze(['open', 'allowlist']);
export const ACCESS_POLICY_CONVERSATION_TYPES = Object.freeze(['direct', 'group']);
export const ACCESS_POLICY_USER_ID_MAX_LENGTH = 256;
const POLICY_KEYS = ['direct', 'group'];
const SCOPE_KEYS = ['mode', 'open', 'allowlist'];
const OPEN_KEYS = ['defaultCanExecuteCommands', 'commandPermissionOverrides'];
const ALLOWLIST_KEYS = ['users'];
const USER_KEYS = ['id', 'canExecuteCommands'];
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/;
function invalidAccessPolicy(message) {
const error = new TypeError(message);
error.code = 'access-policy-invalid';
return error;
}
function hasExactKeys(input, keys) {
return input && typeof input === 'object' && !Array.isArray(input)
&& [Object.prototype, null].includes(Object.getPrototypeOf(input))
&& Reflect.ownKeys(input).length === keys.length
&& keys.every((key) => Object.hasOwn(input, key));
}
/** Normalize one opaque channel identity without interpreting its contents. */
export function normalizeAccessPolicyUserId(value) {
if (typeof value === 'number') {
if (!Number.isFinite(value)) throw invalidAccessPolicy('用户标识无效。');
value = String(value);
} else if (typeof value === 'bigint') {
value = String(value);
}
if (typeof value !== 'string') throw invalidAccessPolicy('用户标识必须是字符串。');
const id = value.trim();
if (!id || id.length > ACCESS_POLICY_USER_ID_MAX_LENGTH || CONTROL_CHARACTERS.test(id)) {
throw invalidAccessPolicy(`用户标识不能为空、包含控制字符或超过 ${ACCESS_POLICY_USER_ID_MAX_LENGTH} 个字符。`);
}
return id;
}
function validateAccessPolicyUser(input) {
if (!hasExactKeys(input, USER_KEYS)) {
throw invalidAccessPolicy('用户条目必须包含用户标识和命令权限。');
}
if (typeof input.canExecuteCommands !== 'boolean') {
throw invalidAccessPolicy('命令权限必须是布尔值。');
}
return Object.freeze({
id: normalizeAccessPolicyUserId(input.id),
canExecuteCommands: input.canExecuteCommands,
});
}
function validateUsers(input, { listMessage, duplicateMessage }) {
if (!Array.isArray(input)) throw invalidAccessPolicy(listMessage);
const users = input.map(validateAccessPolicyUser);
if (new Set(users.map(({ id }) => id)).size !== users.length) {
throw invalidAccessPolicy(duplicateMessage);
}
return Object.freeze(users);
}
function validateOpenSettings(input) {
if (!hasExactKeys(input, OPEN_KEYS)) {
throw invalidAccessPolicy('开放模式设置必须完整。');
}
if (typeof input.defaultCanExecuteCommands !== 'boolean') {
throw invalidAccessPolicy('开放模式默认命令权限必须是布尔值。');
}
return Object.freeze({
defaultCanExecuteCommands: input.defaultCanExecuteCommands,
commandPermissionOverrides: validateUsers(input.commandPermissionOverrides, {
listMessage: '开放模式命令权限覆盖用户必须是数组。',
duplicateMessage: '开放模式命令权限覆盖用户不能包含重复的用户标识。',
}),
});
}
function validateAllowlistSettings(input) {
if (!hasExactKeys(input, ALLOWLIST_KEYS)) {
throw invalidAccessPolicy('白名单模式设置必须完整。');
}
return Object.freeze({
users: validateUsers(input.users, {
listMessage: '白名单模式用户必须是数组。',
duplicateMessage: '白名单模式用户不能包含重复的用户标识。',
}),
});
}
function validateAccessPolicyScope(input) {
if (!hasExactKeys(input, SCOPE_KEYS)) {
throw invalidAccessPolicy('访问场景设置必须同时包含模式、开放模式设置和白名单模式设置。');
}
if (!ACCESS_POLICY_MODES.includes(input.mode)) {
throw invalidAccessPolicy('访问模式只能是 open 或 allowlist。');
}
return Object.freeze({
mode: input.mode,
open: validateOpenSettings(input.open),
allowlist: validateAllowlistSettings(input.allowlist),
});
}
/** Validate one canonical direct + group atomic save. */
export function validateAccessPolicy(input) {
if (!hasExactKeys(input, POLICY_KEYS)) {
throw invalidAccessPolicy('请同时提交完整的私聊和群聊访问设置。');
}
return Object.freeze({
direct: validateAccessPolicyScope(input.direct),
group: validateAccessPolicyScope(input.group),
});
}
/** Normalize canonical persisted/runtime data; damaged settings fail closed. */
export function normalizeAccessPolicy(input) {
try {
return validateAccessPolicy(input);
} catch {
return null;
}
}
/** Small canonical constructor used by channel initialization. */
export function createAccessPolicyScope(options) {
return validateAccessPolicyScope(options === undefined ? {
mode: 'allowlist',
open: {
defaultCanExecuteCommands: false,
commandPermissionOverrides: [],
},
allowlist: { users: [] },
} : options);
}
const DENY_SCOPE = createAccessPolicyScope();
export const DEFAULT_ACCESS_POLICY = Object.freeze({
direct: DENY_SCOPE,
group: DENY_SCOPE,
});
export const DENY_ALL_ACCESS_POLICY = DEFAULT_ACCESS_POLICY;
export function createAccessPolicy({
direct = DEFAULT_ACCESS_POLICY.direct,
group = DEFAULT_ACCESS_POLICY.group,
} = {}) {
return validateAccessPolicy({ direct, group });
}
const ALLOWED = Object.freeze({ allowed: true, reason: 'allowed' });
const POLICY_UNAVAILABLE = Object.freeze({ allowed: false, reason: 'policy-unavailable' });
const INVALID_CONTEXT = Object.freeze({ allowed: false, reason: 'invalid-context' });
const SENDER_UNAVAILABLE = Object.freeze({ allowed: false, reason: 'sender-unavailable' });
const SENDER_NOT_ALLOWED = Object.freeze({ allowed: false, reason: 'sender-not-allowed' });
const COMMAND_NOT_ALLOWED = Object.freeze({ allowed: false, reason: 'command-not-allowed' });
/**
* Decide access for one ordinary message or one already-recognized command.
* `senderIds` accepts multiple identities so WhatsApp can reuse its JID aliases.
* Privileged/owner bypass is intentionally handled by the inbound adapter.
*/
export function evaluateAccessPolicy(policy, {
conversationType,
senderIds,
isCommand = false,
equals = (left, right) => left === right,
} = {}) {
const normalizedPolicy = normalizeAccessPolicy(policy);
if (!normalizedPolicy) return POLICY_UNAVAILABLE;
if (!ACCESS_POLICY_CONVERSATION_TYPES.includes(conversationType)
|| typeof isCommand !== 'boolean' || typeof equals !== 'function') {
return INVALID_CONTEXT;
}
const candidates = (Array.isArray(senderIds) ? senderIds : [senderIds])
.flatMap((candidate) => {
try {
return [normalizeAccessPolicyUserId(candidate)];
} catch {
return [];
}
});
if (candidates.length === 0) return SENDER_UNAVAILABLE;
const scope = normalizedPolicy[conversationType];
const users = scope.mode === 'open'
? scope.open.commandPermissionOverrides
: scope.allowlist.users;
let matchedUsers;
try {
matchedUsers = users.filter((user) => (
candidates.some((candidate) => equals(candidate, user.id) === true)
));
} catch {
return INVALID_CONTEXT;
}
if (scope.mode === 'allowlist' && matchedUsers.length === 0) return SENDER_NOT_ALLOWED;
if (isCommand) {
const canExecuteCommands = scope.mode === 'open'
? (matchedUsers.length > 0
? matchedUsers.every((user) => user.canExecuteCommands)
: scope.open.defaultCanExecuteCommands)
: matchedUsers.every((user) => user.canExecuteCommands);
if (!canExecuteCommands) return COMMAND_NOT_ALLOWED;
}
return ALLOWED;
}

View file

@ -13,6 +13,10 @@ import {
normalizeAgentPresetCatalog,
validateAgentPresetId,
} from './agent-preset.mjs';
import {
normalizeAccessPolicy,
validateAccessPolicy,
} from './access-policy.mjs';
import { CONNECTION_TEST_STATE_IDENTITY } from './connection-test.mjs';
import {
DEFAULT_CONTEXT_ENHANCEMENT_CONFIG,
@ -131,6 +135,26 @@ function normalizeDeliveryTargets(value) {
return deliveryTargets;
}
function normalizeAccessPolicies(value, workspaces) {
const accessPolicies = Object.create(null);
if (value === undefined) return accessPolicies;
if (!value || typeof value !== 'object' || Array.isArray(value)) {
// Preserve the distinction between a missing policy (eligible for startup
// initialization) and damaged persisted data (fail closed).
for (const botId of Object.keys(workspaces)) accessPolicies[botId] = null;
return accessPolicies;
}
for (const [botId, policy] of Object.entries(value)) {
try {
botIdOf(botId);
accessPolicies[botId] = normalizeAccessPolicy(policy);
} catch {
// An invalid key cannot identify a bot, so it is isolated and ignored.
}
}
return accessPolicies;
}
function normalizeDocument(value) {
if (!value || ![1, 2].includes(value.version) || !value.workspaces
|| typeof value.workspaces !== 'object' || Array.isArray(value.workspaces)) return null;
@ -168,15 +192,52 @@ function normalizeDocument(value) {
if (value.version === 1 && value.deliveryTargets !== undefined) return null;
const deliveryTargets = normalizeDeliveryTargets(value.deliveryTargets);
if (!deliveryTargets) return null;
const accessPolicies = normalizeAccessPolicies(value.accessPolicies, workspaces);
const version = value.accessPolicies === undefined ? value.version : 2;
return {
version: value.version,
// A v1 file cannot be emitted with this optional v2 section. If one is
// recovered from an interrupted/manual edit, retain it on the next write.
version,
workspaces,
agentPresets,
contextEnhancement,
deliveryTargets,
accessPolicies,
};
}
function storedDocument({
version,
workspaces,
agentPresets,
contextEnhancement,
deliveryTargets,
accessPolicies,
}) {
const document = { version, workspaces };
if (Object.keys(agentPresets).length > 0) document.agentPresets = agentPresets;
if (Object.keys(contextEnhancement).length > 0) {
document.contextEnhancement = contextEnhancement;
}
if (version >= 2 && Object.keys(deliveryTargets).length > 0) {
document.deliveryTargets = deliveryTargets;
}
if (version >= 2 && Object.keys(accessPolicies).length > 0) {
document.accessPolicies = accessPolicies;
}
return document;
}
async function writeStoredDocument(path, document) {
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
const temporary = `${path}.tmp`;
await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, {
encoding: 'utf8',
mode: 0o600,
});
await rename(temporary, path);
}
export async function validateWorkspacePath(value) {
if (typeof value !== 'string' || !value.trim() || !isAbsolute(value.trim())) {
const error = new Error('工作区必须是绝对路径。');
@ -208,6 +269,7 @@ export class BotWorkspaceStore {
#agentPresets = {};
#contextEnhancement = {};
#deliveryTargets = Object.create(null);
#accessPolicies = Object.create(null);
#generations = new Map();
#nextGeneration = 1;
#incarnations = new Map();
@ -233,6 +295,7 @@ export class BotWorkspaceStore {
this.#agentPresets = normalized.agentPresets;
this.#contextEnhancement = normalized.contextEnhancement;
this.#deliveryTargets = normalized.deliveryTargets;
this.#accessPolicies = normalized.accessPolicies;
} catch (error) {
if (error?.code !== 'ENOENT') throw error;
this.#version = 1;
@ -240,6 +303,7 @@ export class BotWorkspaceStore {
this.#agentPresets = {};
this.#contextEnhancement = {};
this.#deliveryTargets = Object.create(null);
this.#accessPolicies = Object.create(null);
}
this.#generations.clear();
this.#nextGeneration = 1;
@ -278,6 +342,13 @@ export class BotWorkspaceStore {
: DEFAULT_CONTEXT_ENHANCEMENT_CONFIG;
}
accessPolicyFor(botId) {
const id = botIdOf(botId);
return this.has(id) && Object.hasOwn(this.#accessPolicies, id)
? this.#accessPolicies[id]
: null;
}
listDeliveryTargets(botId) {
const id = botIdOf(botId);
if (!this.has(id)) throw deliveryTargetError('unknown-bot', 'Unknown bot');
@ -373,28 +444,53 @@ export class BotWorkspaceStore {
}
}
async ensure(botId, { workspace = this.#defaultWorkspace, defaultAgentPreset } = {}) {
async ensure(botId, {
workspace = this.#defaultWorkspace,
defaultAgentPreset,
initialAccessPolicy,
} = {}) {
const id = botIdOf(botId);
const initialWorkspace = resolve(workspace);
return this.#enqueue(id, async () => {
if (!this.#workspaces[id]) {
const agentPreset = validateAgentPresetId(defaultAgentPreset);
const createsBot = !this.#workspaces[id];
const initializesAccessPolicy = initialAccessPolicy !== undefined
&& !Object.hasOwn(this.#accessPolicies, id);
if (createsBot || initializesAccessPolicy) {
const accessPolicy = initializesAccessPolicy
? validateAccessPolicy(initialAccessPolicy)
: undefined;
const agentPreset = createsBot ? validateAgentPresetId(defaultAgentPreset) : null;
const hadAgentPreset = Object.hasOwn(this.#agentPresets, id);
const previousAgentPreset = this.#agentPresets[id];
this.#workspaces[id] = initialWorkspace;
if (agentPreset) this.#agentPresets[id] = agentPreset;
this.#generations.set(id, this.#freshGeneration());
this.#incarnations.set(id, this.#freshIncarnation());
const nextAccessPolicies = initializesAccessPolicy
? { ...this.#accessPolicies, [id]: accessPolicy }
: this.#accessPolicies;
if (createsBot) {
this.#workspaces[id] = initialWorkspace;
if (agentPreset) this.#agentPresets[id] = agentPreset;
this.#generations.set(id, this.#freshGeneration());
this.#incarnations.set(id, this.#freshIncarnation());
}
const nextVersion = initializesAccessPolicy ? 2 : this.#version;
try {
await this.#persist();
await this.#persist(
this.#contextEnhancement,
this.#deliveryTargets,
nextVersion,
nextAccessPolicies,
);
} catch (error) {
delete this.#workspaces[id];
if (hadAgentPreset) this.#agentPresets[id] = previousAgentPreset;
else delete this.#agentPresets[id];
this.#generations.delete(id);
this.#incarnations.delete(id);
if (createsBot) {
delete this.#workspaces[id];
if (hadAgentPreset) this.#agentPresets[id] = previousAgentPreset;
else delete this.#agentPresets[id];
this.#generations.delete(id);
this.#incarnations.delete(id);
}
throw error;
}
this.#accessPolicies = nextAccessPolicies;
this.#version = nextVersion;
} else if (!this.#generations.has(id)) {
this.#generations.set(id, this.#freshGeneration());
}
@ -487,6 +583,30 @@ export class BotWorkspaceStore {
});
}
async setAccessPolicy(botId, value, { incarnation } = {}) {
const id = botIdOf(botId);
const expectedIncarnation = incarnation === undefined ? this.incarnationFor(id) : incarnation;
const policy = validateAccessPolicy(value);
return this.#enqueue(id, async () => {
if (!this.has(id) || expectedIncarnation !== this.incarnationFor(id)) {
const error = new Error('找不到要修改的机器人。');
error.code = 'workspace-bot-not-found';
throw error;
}
const next = { ...this.#accessPolicies, [id]: policy };
// Inbound messages keep the previous committed snapshot until rename succeeds.
await this.#persist(
this.#contextEnhancement,
this.#deliveryTargets,
2,
next,
);
this.#accessPolicies = next;
this.#version = 2;
return policy;
});
}
async bindWorkspaceSession(botId, value, {
conversationKey,
sessionId,
@ -646,6 +766,7 @@ export class BotWorkspaceStore {
...Object.keys(this.#agentPresets),
...Object.keys(this.#contextEnhancement),
...Object.keys(this.#deliveryTargets),
...Object.keys(this.#accessPolicies),
...this.#dirtyRemovals,
]);
for (const botId of candidates) {
@ -663,6 +784,7 @@ export class BotWorkspaceStore {
workspace: this.workspaceFor(bot.botId),
agentPreset: this.agentPresetFor(bot.botId),
contextEnhancement: this.contextEnhancementFor(bot.botId),
accessPolicy: this.accessPolicyFor(bot.botId),
}
: bot),
};
@ -694,12 +816,14 @@ export class BotWorkspaceStore {
const hadPreset = Object.hasOwn(this.#agentPresets, id);
const hadContextEnhancement = Object.hasOwn(this.#contextEnhancement, id);
const hadDeliveryTargets = Object.hasOwn(this.#deliveryTargets, id);
const hadAccessPolicy = Object.hasOwn(this.#accessPolicies, id);
const needsCleanup = hadWorkspace || hadPreset || hadContextEnhancement
|| hadDeliveryTargets || this.#dirtyRemovals.has(id);
|| hadDeliveryTargets || hadAccessPolicy || this.#dirtyRemovals.has(id);
delete this.#workspaces[id];
delete this.#agentPresets[id];
delete this.#contextEnhancement[id];
delete this.#deliveryTargets[id];
delete this.#accessPolicies[id];
this.#generations.delete(id);
this.#incarnations.delete(id);
if (!needsCleanup) return {
@ -733,24 +857,16 @@ export class BotWorkspaceStore {
contextEnhancement = this.#contextEnhancement,
deliveryTargets = this.#deliveryTargets,
version = this.#version,
accessPolicies = this.#accessPolicies,
) {
const document = { version, workspaces: this.#workspaces };
if (Object.keys(this.#agentPresets).length > 0) {
document.agentPresets = this.#agentPresets;
}
if (Object.keys(contextEnhancement).length > 0) {
document.contextEnhancement = contextEnhancement;
}
if (version >= 2 && Object.keys(deliveryTargets).length > 0) {
document.deliveryTargets = deliveryTargets;
}
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);
await writeStoredDocument(this.#path, storedDocument({
version,
workspaces: this.#workspaces,
agentPresets: this.#agentPresets,
contextEnhancement,
deliveryTargets,
accessPolicies,
}));
this.#dirtyRemovals.clear();
}
@ -758,7 +874,8 @@ export class BotWorkspaceStore {
if (Object.keys(this.#workspaces).length > 0
|| Object.keys(this.#agentPresets).length > 0
|| Object.keys(this.#contextEnhancement).length > 0
|| Object.keys(this.#deliveryTargets).length > 0) {
|| Object.keys(this.#deliveryTargets).length > 0
|| Object.keys(this.#accessPolicies).length > 0) {
await this.#persist();
return;
}
@ -1280,6 +1397,31 @@ export function createWorkspaceAwareController(controller, { workspaces, stateFo
return result;
});
};
const updateAccessPolicy = (botId, value, projectStatus) => {
const incarnation = workspaces.incarnationFor(botId);
const policy = validateAccessPolicy(value);
return withBotTransition(botId, async () => {
const snapshot = await controller.status();
if (!snapshot?.bots?.some((bot) => bot?.botId === botId)) {
const error = new Error('找不到要修改的机器人。');
error.code = 'workspace-bot-not-found';
throw error;
}
const catalog = await resolveAgentPresetCatalog(agentPresetCatalog);
const decorated = workspaces.decorateStatus(snapshot);
const updated = {
...decorated,
bots: decorated.bots.map((bot) => bot?.botId === botId
? { ...bot, accessPolicy: policy } : bot),
...(catalog ? { agentPresetCatalog: catalog } : {}),
};
// Prepare the complete channel-specific response before commit. Failed
// projections and disk writes must leave the live policy unchanged.
const result = projectStatus ? await projectStatus(updated) : updated;
await workspaces.setAccessPolicy(botId, policy, { incarnation });
return result;
});
};
const deleteWithWorkspace = (botId, invokeDelete) => withBotTransition(botId, async () => {
// Fence the old runtime without changing the durable mapping. A crash
// before the controller removes its config therefore keeps the bot's
@ -1320,6 +1462,7 @@ export function createWorkspaceAwareController(controller, { workspaces, stateFo
if (property === 'updateWorkspace') return updateWorkspace;
if (property === 'updateAgentPreset') return updateAgentPreset;
if (property === 'updateContextEnhancement') return updateContextEnhancement;
if (property === 'updateAccessPolicy') return updateAccessPolicy;
const value = Reflect.get(target, property, target);
if (typeof value !== 'function') return value;
if (property === 'deleteBot') {

View file

@ -0,0 +1,32 @@
import { isBatchInputCommand } from './batch-input.mjs';
import { isCompactCommand } from './compact-command.mjs';
import { isControlCommand } from './control-command.mjs';
import { isHistoryCommand } from './history-command.mjs';
import { isModelCommand } from './model-command.mjs';
import { isPresetCommand } from './preset-command.mjs';
import { isWorkspaceCommand } from './workspace-command.mjs';
const SIMPLE_TEXT_COMMANDS = new Set(['/help', '/status', '/new']);
/**
* Match only commands that the shared bridges already execute locally.
* Unknown slash-prefixed text remains an ordinary prompt.
*/
export function isSharedLocalCommand(text, {
hasImages = false,
hasFiles = false,
} = {}) {
if (typeof text !== 'string') return false;
const command = text.trim();
if (!command) return false;
if (isBatchInputCommand(command) || isHistoryCommand(command)) return true;
if (!hasFiles && (
isControlCommand(command)
|| isModelCommand(command)
|| isPresetCommand(command)
)) return true;
if (hasImages || hasFiles) return false;
return SIMPLE_TEXT_COMMANDS.has(command.toLowerCase())
|| isWorkspaceCommand(command)
|| isCompactCommand(command);
}

View file

@ -70,9 +70,8 @@ function compactErrorMessage(error) {
* Unknown input returns null so the caller may continue ordinary message routing.
*/
export async function runCompactCommand(text, harness, state, conversationKey, options = {}) {
if (typeof text !== 'string') return null;
if (!isCompactCommand(text)) return null;
const match = COMPACT_COMMAND.exec(text.trim());
if (!match) return null;
if (match[1].trim()) return commandResult(t(COMPACT_USAGE));
if (typeof state?.sessionFor !== 'function') {
return commandResult(t('当前机器人没有可用的会话状态。'));
@ -94,3 +93,7 @@ export async function runCompactCommand(text, harness, state, conversationKey, o
return commandResult(compactErrorMessage(error));
}
}
export function isCompactCommand(text) {
return typeof text === 'string' && COMPACT_COMMAND.test(text.trim());
}

View file

@ -185,4 +185,6 @@ export default {
'Collected {count}/{limit} messages. The batch is full; send /send or /cancel.',
'批量内容提交失败,已保留 {count} 条消息。\n请再次发送 /send 重试或 /cancel 取消。':
'Batch submission failed; {count} messages were retained.\nSend /send to retry or /cancel to cancel.',
'你可以发送普通消息,但没有执行命令的权限。':
'You can send regular messages, but you do not have permission to run commands.',
};

View file

@ -0,0 +1,43 @@
import { evaluateAccessPolicy } from './access-policy.mjs';
import { isSharedLocalCommand } from './command-permission.mjs';
export const COMMAND_PERMISSION_DENIED_MESSAGE = '你可以发送普通消息,但没有执行命令的权限。';
const POLICY_NOT_CONFIGURED = Object.freeze({ allowed: true, reason: 'policy-not-configured' });
const PRIVILEGED_SENDER = Object.freeze({ allowed: true, reason: 'privileged-sender' });
/**
* Read one committed policy snapshot and decide a single inbound event.
* A missing provider is kept backward-compatible for direct bridge fixtures;
* production always injects a provider, whose missing/damaged value fails closed.
*/
export function evaluateInboundAccess(accessPolicy, {
conversationType,
senderIds,
text = '',
hasImages = false,
hasFiles = false,
isCommand = isSharedLocalCommand(text, { hasImages, hasFiles }),
} = {}) {
if (!accessPolicy) return POLICY_NOT_CONFIGURED;
try {
if (typeof accessPolicy.isPrivileged === 'function'
&& accessPolicy.isPrivileged(senderIds, conversationType) === true) {
return PRIVILEGED_SENDER;
}
} catch {
// A broken privilege lookup must not bypass the persisted policy.
}
let policy = null;
try {
policy = accessPolicy.getSettings();
} catch {
// Provider failures are equivalent to an unavailable persisted policy.
}
return evaluateAccessPolicy(policy, {
conversationType,
senderIds,
isCommand,
...(typeof accessPolicy.equals === 'function' ? { equals: accessPolicy.equals } : {}),
});
}

View file

@ -1,4 +1,8 @@
import { t } from './i18n.mjs';
import {
COMMAND_PERMISSION_DENIED_MESSAGE,
evaluateInboundAccess,
} from './inbound-access.mjs';
import { captureContextEnhancement, enhanceContextContent } from './context-enhancement.mjs';
import { runWorkspaceCommand } from './workspace-command.mjs';
import { runCompactCommand } from './compact-command.mjs';
@ -129,6 +133,7 @@ export class TextHarnessBridge {
#harness;
#state;
#contextEnhancement;
#accessPolicy;
#status;
#logger;
#replyTimeoutMs;
@ -149,6 +154,7 @@ export class TextHarnessBridge {
harness,
state,
contextEnhancement,
accessPolicy,
status = createTextBridgeStatus(),
logger = console,
replyTimeoutMs = 600_000,
@ -162,6 +168,7 @@ export class TextHarnessBridge {
this.#harness = harness;
this.#state = state;
this.#contextEnhancement = contextEnhancement;
this.#accessPolicy = accessPolicy;
this.#status = status;
this.#logger = logger;
this.#replyTimeoutMs = replyTimeoutMs;
@ -176,7 +183,7 @@ export class TextHarnessBridge {
return structuredClone(this.#status);
}
accept(message, { contextSnapshot } = {}) {
accept(message, { contextSnapshot, accessDecision } = {}) {
if (this.#signal?.aborted) return Promise.resolve();
const conversationId = cleanText(message?.conversationId);
const kind = message?.kind === 'group' ? 'group' : 'direct';
@ -187,6 +194,40 @@ export class TextHarnessBridge {
|| this.#state.hasSeen(messageId) || this.#acceptedMessageIds.has(messageId)) {
return Promise.resolve();
}
// Preserve the channel trigger boundary. Access policy never turns an
// unaddressed group message into a denial reply.
if (kind === 'group' && normalized.addressed !== true) {
this.#status.messagesRejected += 1;
this.#status.lastRejectedAt = new Date().toISOString();
this.#acceptedMessageIds.set(messageId, null);
return this.#finishLocalMessage(normalized, messageId, null);
}
if (this.#accessPolicy || accessDecision) {
const hasImages = hasInboundImages(normalized);
const hasFiles = hasInboundFiles(normalized);
const decision = accessDecision ?? evaluateInboundAccess(this.#accessPolicy, {
conversationType: kind,
senderIds: [senderId, cleanText(normalized.senderAlternateId)].filter(Boolean),
text: normalized.content,
hasImages,
hasFiles,
});
if (!decision.allowed) {
this.#status.messagesRejected += 1;
this.#status.lastRejectedAt = new Date().toISOString();
// Mark policy denials so a webhook replay cannot repeat local work or
// a command-permission notice.
this.#acceptedMessageIds.set(messageId, null);
return this.#finishLocalMessage(
normalized,
messageId,
decision.reason === 'command-not-allowed'
? t(COMMAND_PERMISSION_DENIED_MESSAGE)
: null,
{ recordReceived: decision.reason === 'command-not-allowed' },
);
}
}
this.#acceptedMessageIds.set(messageId, contextSnapshot === undefined
? captureContextEnhancement(this.#contextEnhancement, message?.kind)
: contextSnapshot);
@ -350,13 +391,15 @@ export class TextHarnessBridge {
return this.#enqueueMessage(normalized, messageId, senderId, key);
}
#finishLocalMessage(message, messageId, reply) {
#finishLocalMessage(message, messageId, reply, { recordReceived = true } = {}) {
let task;
task = (async () => {
if (this.#state.hasSeen(messageId)) return;
await this.#state.markSeen(messageId);
this.#status.messagesReceived += 1;
this.#status.lastMessageAt = new Date().toISOString();
if (recordReceived) {
this.#status.messagesReceived += 1;
this.#status.lastMessageAt = new Date().toISOString();
}
if (reply) await this.#bot.sendText(message.replyTarget, reply);
this.#status.lastError = null;
})().catch(async (error) => {

View file

@ -353,7 +353,7 @@ async function runSessionBindCommand(command, harness, conversationKey) {
}
export async function runWorkspaceCommand(text, harness, conversationKey) {
if (typeof text !== 'string') return null;
if (!isWorkspaceCommand(text)) return null;
const command = text.trim();
if (SESSION_BIND_PREFIX.test(command)) {
return runSessionBindCommand(command, harness, conversationKey);
@ -385,3 +385,12 @@ export async function runWorkspaceCommand(text, harness, conversationKey) {
throw error;
}
}
export function isWorkspaceCommand(text) {
if (typeof text !== 'string') return false;
const command = text.trim();
return SESSION_BIND_PREFIX.test(command)
|| SESSION_LIST_COMMAND.test(command)
|| WORKSPACE_LIST_COMMAND.test(command)
|| WORKSPACE_COMMAND.test(command);
}

View file

@ -347,6 +347,7 @@ export class SlackRuntime {
#harness;
#state;
#contextEnhancement;
#accessPolicy;
#logger;
#replyTimeoutMs;
#connectTimeoutMs;
@ -371,6 +372,7 @@ export class SlackRuntime {
harness,
state,
contextEnhancement,
accessPolicy,
logger = console,
replyTimeoutMs = 600_000,
connectTimeoutMs = 20_000,
@ -387,6 +389,7 @@ export class SlackRuntime {
this.#harness = harness;
this.#state = state;
this.#contextEnhancement = contextEnhancement;
this.#accessPolicy = accessPolicy;
this.#logger = logger;
this.#replyTimeoutMs = replyTimeoutMs;
this.#connectTimeoutMs = connectTimeoutMs;
@ -464,6 +467,7 @@ export class SlackRuntime {
harness: this.#harness,
state: this.#state,
contextEnhancement: this.#contextEnhancement,
accessPolicy: this.#accessPolicy,
status: this.#status,
logger: this.#logger,
replyTimeoutMs: this.#replyTimeoutMs,

View file

@ -14,7 +14,6 @@ import {
} from './telegram-rich-message.mjs';
import {
TELEGRAM_ACCESS_MODES,
normalizeTelegramAccessPolicy,
} from './config-store.mjs';
export const TELEGRAM_COMMAND_MENU = Object.freeze([
@ -669,12 +668,11 @@ export class TelegramRuntime {
#harness;
#state;
#contextEnhancement;
#accessPolicy;
#logger;
#replyTimeoutMs;
#createApi;
#createHttpTransport;
#accessMode;
#allowedPrivateUserIds;
#status = createTelegramRuntimeStatus();
#httpTransport = null;
#api = null;
@ -689,6 +687,7 @@ export class TelegramRuntime {
harness,
state,
contextEnhancement,
accessPolicy,
logger = console,
replyTimeoutMs = 600_000,
createApi = (options) => new TelegramApi(options),
@ -702,13 +701,11 @@ export class TelegramRuntime {
this.#harness = harness;
this.#state = state;
this.#contextEnhancement = contextEnhancement;
this.#accessPolicy = accessPolicy;
this.#logger = logger;
this.#replyTimeoutMs = replyTimeoutMs;
this.#createApi = createApi;
this.#createHttpTransport = createHttpTransport;
const accessPolicy = normalizeTelegramAccessPolicy(config);
this.#accessMode = accessPolicy.accessMode;
this.#allowedPrivateUserIds = new Set(accessPolicy.allowedUsers);
}
get status() {
@ -805,6 +802,7 @@ export class TelegramRuntime {
harness: this.#harness,
state: this.#state,
contextEnhancement: this.#contextEnhancement,
accessPolicy: this.#accessPolicy,
status: this.#status,
logger: this.#logger,
replyTimeoutMs: this.#replyTimeoutMs,
@ -865,10 +863,7 @@ export class TelegramRuntime {
loadFile: (fileId, options) => this.#api.downloadFile({ fileId, ...options }),
loadFileStream: (fileId, options) => this.#api.downloadFileStream({ fileId, ...options }),
});
if (message && telegramInboundAllowed(message, {
accessMode: this.#accessMode,
allowedPrivateUserIds: this.#allowedPrivateUserIds,
})) {
if (message) {
void this.#bridge.accept(message, { contextSnapshot }).catch((error) => {
if (signal.aborted) return;
this.#logger.error?.(
@ -876,9 +871,6 @@ export class TelegramRuntime {
error,
);
});
} else if (message) {
this.#status.messagesRejected += 1;
this.#status.lastRejectedAt = new Date().toISOString();
}
cursor = update.update_id + 1;
await this.#state.setCursor(cursor);

View file

@ -51,6 +51,10 @@ import {
messageFailureText,
setLastMessageFailure,
} from '../shared/message-failure.mjs';
import {
COMMAND_PERMISSION_DENIED_MESSAGE,
evaluateInboundAccess,
} from '../shared/inbound-access.mjs';
import { t } from '../shared/i18n.mjs';
const DEFAULT_FILE_UPLOAD_TIMEOUT_MS = 120_000;
@ -490,6 +494,7 @@ export class WecomHarnessBridge {
#harness;
#state;
#contextEnhancement;
#accessPolicy;
#status;
#logger;
#replyTimeoutMs;
@ -512,6 +517,7 @@ export class WecomHarnessBridge {
harness,
state,
contextEnhancement,
accessPolicy,
status = createWecomBridgeStatus(),
logger = console,
replyTimeoutMs = 600_000,
@ -530,6 +536,7 @@ export class WecomHarnessBridge {
this.#harness = harness;
this.#state = state;
this.#contextEnhancement = contextEnhancement;
this.#accessPolicy = accessPolicy;
this.#status = status;
this.#logger = logger;
this.#replyTimeoutMs = replyTimeoutMs;
@ -557,16 +564,28 @@ export class WecomHarnessBridge {
|| this.#acceptedMessageIds.has(messageId)) return Promise.resolve();
const key = conversationKey(frame);
const pending = this.#pendingInteractions.get(key);
const commandMessage = wecomInboundMessage(frame, this.#client);
const commandText = nonEmptyString(commandMessage.content) ?? '';
const conversationType = body.chattype === 'single' ? 'direct' : 'group';
const access = evaluateInboundAccess(this.#accessPolicy, {
conversationType,
senderIds: senderId,
text: commandText,
hasImages: hasInboundImages(commandMessage),
hasFiles: hasInboundFiles(commandMessage),
});
if (!access.allowed) {
this.#acceptedMessageIds.set(messageId, null);
return this.#finishAccessDecision(frame, messageId, chatId, access);
}
this.#acceptedMessageIds.set(messageId, captureContextEnhancement(
this.#contextEnhancement,
body.chattype === 'single' ? 'direct' : 'group',
conversationType,
));
if (body.chattype === 'single') {
rememberConnectionTestTarget(this.#state, { chatId });
}
const pending = this.#pendingInteractions.get(key);
const commandMessage = wecomInboundMessage(frame, this.#client);
const commandText = nonEmptyString(commandMessage.content) ?? '';
const batchCommand = isBatchInputCommand(commandText);
const batchStatus = this.#batchInputs.status(key);
if (batchCommand && body.chattype === 'group') {
@ -760,6 +779,34 @@ export class WecomHarnessBridge {
return task;
}
#finishAccessDecision(frame, messageId, chatId, access) {
let task;
task = Promise.resolve().then(async () => {
if (this.#state.hasSeen(messageId)) return;
await this.#state.markSeen(messageId);
if (access.reason === 'command-not-allowed') {
this.#status.messagesReceived += 1;
this.#status.lastMessageAt = new Date().toISOString();
await this.#sendImmediate(frame, chatId, t(COMMAND_PERMISSION_DENIED_MESSAGE));
this.#status.messagesReplied += 1;
this.#status.lastReplyAt = new Date().toISOString();
} else {
this.#status.messagesRejected += 1;
this.#status.lastRejectedAt = new Date().toISOString();
}
this.#status.lastError = null;
}).catch((error) => {
if (this.#signal?.aborted) return;
this.#status.lastError = error?.message ?? String(error);
this.#logger.error?.('[dsh-im:wecom] failed to apply inbound access policy', error);
}).finally(() => {
this.#acceptedMessageIds.delete(messageId);
this.#commandTasks.delete(task);
});
this.#commandTasks.add(task);
return task;
}
async waitForIdle() {
await Promise.allSettled([
...this.#queues.values(),

View file

@ -29,6 +29,7 @@ export class WecomRuntime {
#harness;
#state;
#contextEnhancement;
#accessPolicy;
#logger;
#replyTimeoutMs;
#connectTimeoutMs;
@ -47,6 +48,7 @@ export class WecomRuntime {
harness,
state,
contextEnhancement,
accessPolicy,
logger = console,
replyTimeoutMs = 600_000,
connectTimeoutMs = 20_000,
@ -61,6 +63,7 @@ export class WecomRuntime {
this.#harness = harness;
this.#state = state;
this.#contextEnhancement = contextEnhancement;
this.#accessPolicy = accessPolicy;
this.#logger = logger;
this.#replyTimeoutMs = replyTimeoutMs;
this.#connectTimeoutMs = connectTimeoutMs;
@ -111,6 +114,7 @@ export class WecomRuntime {
harness: this.#harness,
state: this.#state,
contextEnhancement: this.#contextEnhancement,
accessPolicy: this.#accessPolicy,
status: this.#status,
logger: this.#logger,
replyTimeoutMs: this.#replyTimeoutMs,

View file

@ -57,6 +57,10 @@ import {
messageFailureText,
setLastMessageFailure,
} from '../shared/message-failure.mjs';
import {
COMMAND_PERMISSION_DENIED_MESSAGE,
evaluateInboundAccess,
} from '../shared/inbound-access.mjs';
import { t } from '../shared/i18n.mjs';
const INTERACTION_RESOLVED_TEXT = () => t('这个问题已在其他客户端处理,无需再次回答。');
@ -260,6 +264,7 @@ export class WeixinHarnessBridge {
#harness;
#state;
#contextEnhancement;
#accessPolicy;
#status;
#logger;
#replyTimeoutMs;
@ -292,6 +297,7 @@ export class WeixinHarnessBridge {
harness,
state,
contextEnhancement,
accessPolicy,
status = createWeixinBridgeStatus(),
logger = console,
replyTimeoutMs = 600_000,
@ -312,6 +318,7 @@ export class WeixinHarnessBridge {
this.#harness = harness;
this.#state = state;
this.#contextEnhancement = contextEnhancement;
this.#accessPolicy = accessPolicy;
this.#status = status;
this.#logger = logger;
this.#replyTimeoutMs = replyTimeoutMs;
@ -332,6 +339,22 @@ export class WeixinHarnessBridge {
const sender = nonEmptyString(message?.from_user_id);
if (!messageId || !sender || this.#state.hasSeen(messageId)
|| this.#acceptedMessageIds.has(messageId)) return Promise.resolve();
const commandText = nonEmptyString(extractWeixinText(message)) ?? '';
const access = this.#accessPolicy
? evaluateInboundAccess(this.#accessPolicy, {
conversationType: 'direct',
senderIds: sender,
text: commandText,
hasImages: hasWeixinImageItems(message),
hasFiles: hasWeixinFileItems(message),
})
: sender === this.#ownerUserId
? { allowed: true, reason: 'legacy-owner' }
: { allowed: false, reason: 'sender-not-allowed' };
if (!access.allowed) {
this.#acceptedMessageIds.set(messageId, null);
return this.#finishAccessDecision(messageId, sender, message, access);
}
this.#acceptedMessageIds.set(messageId, captureContextEnhancement(
this.#contextEnhancement,
'direct',
@ -343,11 +366,9 @@ export class WeixinHarnessBridge {
const contextToken = nonEmptyString(message?.context_token) ?? undefined;
const runId = nonEmptyString(message?.run_id) ?? undefined;
const pending = this.#pendingInteractions.get(key);
const commandText = nonEmptyString(extractWeixinText(message)) ?? '';
const batchCommand = isBatchInputCommand(commandText);
const batchStatus = this.#batchInputs.status(key);
if (sender === this.#ownerUserId
&& (batchCommand || batchStatus.phase === 'collecting')) {
if (batchCommand || batchStatus.phase === 'collecting') {
const exactBatchStart = /^\/batch$/iu.test(commandText);
const result = exactBatchStart
&& batchStatus.phase === 'idle'
@ -380,7 +401,7 @@ export class WeixinHarnessBridge {
: (isModelCommand(commandText)
? runModelCommand
: (isPresetCommand(commandText) ? runPresetCommand : null));
if (commandRunner && sender === this.#ownerUserId) {
if (commandRunner) {
let task;
task = this.#processFastCommand(
message,
@ -467,12 +488,10 @@ export class WeixinHarnessBridge {
alreadyRecorded = false,
batchSubmission = null,
} = {}) {
const preparedMessage = message.from_user_id === this.#ownerUserId
? prefetchInboundFiles(
weixinInboundMessage(message, this.#api),
{ signal: this.#signal },
)
: undefined;
const preparedMessage = prefetchInboundFiles(
weixinInboundMessage(message, this.#api),
{ signal: this.#signal },
);
const previous = this.#queues.get(key) ?? Promise.resolve();
const current = previous
.catch(() => undefined)
@ -518,6 +537,36 @@ export class WeixinHarnessBridge {
return task;
}
#finishAccessDecision(messageId, sender, message, access) {
const contextToken = nonEmptyString(message?.context_token) ?? undefined;
const runId = nonEmptyString(message?.run_id) ?? undefined;
let task;
task = Promise.resolve().then(async () => {
if (this.#state.hasSeen(messageId)) return;
await this.#state.markSeen(messageId);
if (access.reason === 'command-not-allowed') {
this.#status.messagesReceived += 1;
this.#status.lastMessageAt = new Date().toISOString();
await this.#send(sender, t(COMMAND_PERMISSION_DENIED_MESSAGE), contextToken, runId);
this.#status.messagesReplied += 1;
this.#status.lastReplyAt = new Date().toISOString();
} else {
this.#status.messagesRejected += 1;
this.#status.lastRejectedAt = new Date().toISOString();
}
this.#status.lastError = null;
}).catch((error) => {
if (this.#signal?.aborted) return;
this.#status.lastError = error?.message ?? String(error);
this.#logger.error?.('[dsh-weixin] failed to apply inbound access policy:', error);
}).finally(() => {
this.#acceptedMessageIds.delete(messageId);
this.#commandTasks.delete(task);
});
this.#commandTasks.add(task);
return task;
}
async waitForIdle() {
await Promise.allSettled([
...this.#queues.values(),
@ -590,12 +639,6 @@ export class WeixinHarnessBridge {
this.#status.messagesReceived += 1;
this.#status.lastMessageAt = new Date().toISOString();
}
if (sender !== this.#ownerUserId) {
this.#status.messagesRejected += 1;
this.#status.lastRejectedAt = new Date().toISOString();
return;
}
const contextToken = typeof message.context_token === 'string' ? message.context_token : undefined;
const runId = typeof message.run_id === 'string' ? message.run_id : undefined;
let batchSettled = batchSubmission === null;

View file

@ -109,6 +109,7 @@ export class WeixinRuntime {
#harness;
#state;
#contextEnhancement;
#accessPolicy;
#logger;
#replyTimeoutMs;
#maxMessageChars;
@ -126,6 +127,7 @@ export class WeixinRuntime {
harness,
state,
contextEnhancement,
accessPolicy,
logger = console,
replyTimeoutMs = 600_000,
maxMessageChars = DEFAULT_WEIXIN_MAX_MESSAGE_CHARS,
@ -140,6 +142,7 @@ export class WeixinRuntime {
this.#harness = harness;
this.#state = state;
this.#contextEnhancement = contextEnhancement;
this.#accessPolicy = accessPolicy;
this.#logger = logger;
this.#replyTimeoutMs = replyTimeoutMs;
this.#maxMessageChars = maxMessageChars;
@ -182,6 +185,7 @@ export class WeixinRuntime {
harness: this.#harness,
state: this.#state,
contextEnhancement: this.#contextEnhancement,
accessPolicy: this.#accessPolicy,
status: this.#status,
logger: this.#logger,
replyTimeoutMs: this.#replyTimeoutMs,

View file

@ -3,6 +3,7 @@ import { createHash, randomBytes } from 'node:crypto';
import {
areJidsSameUser,
downloadMediaMessage,
jidDecode,
normalizeMessageContent,
} from '@whiskeysockets/baileys';
@ -13,7 +14,6 @@ import { trackOutboundArtifactProviderPromise } from '../shared/semantic/artifac
import { createWhatsappBridgeStatus, WhatsappHarnessBridge } from './whatsapp-bridge.mjs';
import {
WHATSAPP_ACCESS_MODES,
normalizeWhatsappAccessPolicy,
} from './config-store.mjs';
import { createWhatsappWebSession } from './whatsapp-web-session.mjs';
@ -37,6 +37,36 @@ const VIEW_ONCE_WRAPPER_KEYS = new Set([
'viewOnceMessageV2',
'viewOnceMessageV2Extension',
]);
const WHATSAPP_ACCESS_POLICY_USER_SERVERS = new Set([
's.whatsapp.net',
'c.us',
'lid',
'hosted',
'hosted.lid',
]);
function normalizeWhatsappAccessPolicyId(value) {
if (typeof value !== 'string') return null;
const candidate = value.trim();
if (/^\+?\d+$/.test(candidate)) {
return `${candidate.replace(/^\+/, '')}@s.whatsapp.net`;
}
const decoded = jidDecode(candidate);
if (!decoded || !/^\d+$/.test(decoded.user)
|| !WHATSAPP_ACCESS_POLICY_USER_SERVERS.has(decoded.server)) return null;
return candidate;
}
export function whatsappAccessPolicyIdsEqual(left, right) {
const normalizedLeft = normalizeWhatsappAccessPolicyId(left);
const normalizedRight = normalizeWhatsappAccessPolicyId(right);
if (!normalizedLeft || !normalizedRight) return false;
try {
return areJidsSameUser(normalizedLeft, normalizedRight) === true;
} catch {
return false;
}
}
function hasViewOnceWrapper(content) {
let current = content;
@ -538,12 +568,11 @@ export class WhatsappRuntime {
#harness;
#state;
#contextEnhancement;
#accessPolicy;
#logger;
#replyTimeoutMs;
#connectTimeoutMs;
#mediaUploadTimeoutMs;
#accessMode;
#allowedPrivateNumbers;
#createSession;
#status = createWhatsappRuntimeStatus();
#abortController = null;
@ -558,6 +587,7 @@ export class WhatsappRuntime {
harness,
state,
contextEnhancement,
accessPolicy,
logger = console,
replyTimeoutMs = 600_000,
connectTimeoutMs = 30_000,
@ -572,6 +602,7 @@ export class WhatsappRuntime {
this.#harness = harness;
this.#state = state;
this.#contextEnhancement = contextEnhancement;
this.#accessPolicy = accessPolicy;
this.#logger = logger;
this.#replyTimeoutMs = replyTimeoutMs;
this.#connectTimeoutMs = connectTimeoutMs;
@ -583,21 +614,12 @@ 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;
@ -636,14 +658,6 @@ export class WhatsappRuntime {
});
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 }) => {
@ -678,6 +692,14 @@ export class WhatsappRuntime {
harness: this.#harness,
state: this.#state,
contextEnhancement: this.#contextEnhancement,
accessPolicy: this.#accessPolicy ? {
botId: this.#accessPolicy.botId,
getSettings: (...args) => this.#accessPolicy.getSettings(...args),
...(typeof this.#accessPolicy.isPrivileged === 'function' ? {
isPrivileged: (...args) => this.#accessPolicy.isPrivileged(...args),
} : {}),
equals: whatsappAccessPolicyIdsEqual,
} : undefined,
status: this.#status,
logger: this.#logger,
replyTimeoutMs: this.#replyTimeoutMs,

View file

@ -0,0 +1,219 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
accessPolicyProvider,
initialAccessPolicyFor,
privilegedSenderIdsFor,
} from '../plugin-src/host/channels/shared/access-policy-production.mjs';
import {
SET_ACCESS_POLICY_ENDPOINT,
validAccessPolicyPayload,
} from '../plugin-src/host/channels/shared/access-policy-rpc.mjs';
import { createWeixinRpcHandler, WEIXIN_ENDPOINTS } from '../plugin-src/host/channels/weixin/rpc.mjs';
import { createFeishuRpcHandler, FEISHU_ENDPOINTS } from '../plugin-src/host/channels/feishu/rpc.mjs';
import { createDingtalkRpcHandler, DINGTALK_ENDPOINTS } from '../plugin-src/host/channels/dingtalk/rpc.mjs';
import { createWecomRpcHandler, WECOM_ENDPOINTS } from '../plugin-src/host/channels/wecom/rpc.mjs';
import { createQqRpcHandler, QQ_ENDPOINTS } from '../plugin-src/host/channels/qq/rpc.mjs';
import { createSlackRpcHandler, SLACK_ENDPOINTS } from '../plugin-src/host/channels/slack/rpc.mjs';
import { createTelegramRpcHandler, TELEGRAM_ENDPOINTS } from '../plugin-src/host/channels/telegram/rpc.mjs';
import { createDiscordRpcHandler, DISCORD_ENDPOINTS } from '../plugin-src/host/channels/discord/rpc.mjs';
import { createWhatsappRpcHandler, WHATSAPP_ENDPOINTS } from '../plugin-src/host/channels/whatsapp/rpc.mjs';
const users = (values = []) => values.map((value) => (
value && typeof value === 'object'
? value
: { id: value, canExecuteCommands: true }
));
const scope = ({
mode,
defaultCanExecuteCommands,
commandPermissionOverrides = [],
allowlistUsers = [],
}) => ({
mode,
open: {
defaultCanExecuteCommands,
commandPermissionOverrides: users(commandPermissionOverrides),
},
allowlist: { users: users(allowlistUsers) },
});
const open = (allowlistUsers = [], defaultCanExecuteCommands = true) => scope({
mode: 'open', defaultCanExecuteCommands, allowlistUsers,
});
const allowlist = (allowlistUsers = []) => scope({
mode: 'allowlist', defaultCanExecuteCommands: false, allowlistUsers,
});
const policy = (direct = open(), group = open()) => ({ direct, group });
test('Host initialization preserves the nine channel access baselines and legacy migrations', () => {
assert.deepEqual(initialAccessPolicyFor('weixin', { ownerUserId: 'wx-owner' }),
policy(allowlist(), allowlist()));
assert.deepEqual(initialAccessPolicyFor('feishu', { ownerOpenIds: ['ou_owner'] }),
policy(allowlist(), allowlist()));
assert.deepEqual(initialAccessPolicyFor('feishu', { ownerOpenIds: ['*'] }), policy());
for (const channel of ['dingtalk', 'wecom', 'slack', 'discord']) {
assert.deepEqual(initialAccessPolicyFor(channel), policy());
}
assert.deepEqual(initialAccessPolicyFor('qq', { ownerUserOpenid: 'qq-owner' }),
policy(allowlist(), open()));
assert.deepEqual(initialAccessPolicyFor('qq', { ownerUserOpenid: '*' }), policy());
assert.deepEqual(initialAccessPolicyFor('telegram', {
accessMode: 'compatible', allowedUsers: ['101'],
}), policy(open(['101']), open()));
assert.deepEqual(initialAccessPolicyFor('telegram', {
accessMode: 'private-allowlist', allowedUsers: ['101'],
}), policy(allowlist(['101']), allowlist()));
assert.deepEqual(initialAccessPolicyFor('whatsapp', {
accessMode: 'self-only', accountJid: '886900000000@s.whatsapp.net',
}), policy(allowlist(), allowlist()));
assert.deepEqual(initialAccessPolicyFor('whatsapp', {
accessMode: 'private-allowlist',
accountJid: '886900000000@lid',
allowedNumbers: ['16505550999'],
}), policy(allowlist(['16505550999@s.whatsapp.net']), allowlist()));
assert.deepEqual(initialAccessPolicyFor('whatsapp', {
accessMode: 'open', allowedNumbers: ['16505550999'],
}), policy(open(['16505550999@s.whatsapp.net']), open()));
});
test('Host-only owner identities are not copied into public access-policy rows', () => {
const cases = [
['weixin', { ownerUserId: 'wx-private-owner' }, 'wx-private-owner'],
['feishu', { ownerOpenIds: ['ou_private_owner'] }, 'ou_private_owner'],
['qq', { ownerUserOpenid: 'qq-private-owner' }, 'qq-private-owner'],
['whatsapp', {
accessMode: 'private-allowlist',
accountJid: '886900000000@lid',
allowedNumbers: ['16505550999'],
}, '886900000000@lid'],
];
for (const [channel, config, ownerId] of cases) {
assert.equal(JSON.stringify(initialAccessPolicyFor(channel, config)).includes(ownerId), false, channel);
}
});
test('Host access provider reads the latest committed workspace policy', () => {
let current = policy(allowlist(), allowlist());
const provider = accessPolicyProvider({ accessPolicyFor: () => current }, 'bot_one', {
channel: 'feishu', config: { ownerOpenIds: ['ou_owner', '*'] },
});
assert.equal(provider.botId, 'bot_one');
assert.equal(provider.getSettings(), current);
current = policy();
assert.equal(provider.getSettings(), current);
assert.equal(provider.isPrivileged(['ou_owner'], 'direct'), true);
assert.equal(provider.isPrivileged(['*'], 'group'), false);
assert.equal(provider.isPrivileged(['ou_other'], 'direct'), false);
assert.equal(provider.isPrivileged(['ou_owner'], 'unknown'), false);
const whatsapp = accessPolicyProvider({ accessPolicyFor: () => current }, 'bot_wa', {
channel: 'whatsapp',
config: { accountJid: '16505550100@s.whatsapp.net' },
equals: (left, right) => left.split('@')[0] === right.split('@')[0],
});
assert.equal(whatsapp.isPrivileged(['16505550100@lid'], 'group'), true);
});
test('Host privileged identities come only from durable owner or legacy authorization fields', () => {
assert.deepEqual(privilegedSenderIdsFor('weixin', { ownerUserId: 'wx-owner' }), ['wx-owner']);
assert.deepEqual(privilegedSenderIdsFor('feishu', { ownerOpenIds: ['*', 'ou_owner'] }), ['ou_owner']);
assert.deepEqual(privilegedSenderIdsFor('dingtalk', {
approvedSenders: [{ staffId: 'ding-owner' }, { staffId: 'ding-owner' }],
}), ['ding-owner']);
assert.deepEqual(privilegedSenderIdsFor('qq', { ownerUserOpenid: '*' }), []);
assert.deepEqual(privilegedSenderIdsFor('qq', { ownerUserOpenid: 'qq-owner' }), ['qq-owner']);
assert.deepEqual(privilegedSenderIdsFor('whatsapp', {
accountJid: '886900000000@s.whatsapp.net',
}), ['886900000000@s.whatsapp.net']);
for (const channel of ['wecom', 'slack', 'telegram', 'discord']) {
assert.deepEqual(privilegedSenderIdsFor(channel, { allowedUsers: ['legacy'] }), []);
}
});
test('shared access-policy RPC payload is exact and validates the full atomic policy', () => {
const value = policy(open([], false), allowlist());
assert.equal(validAccessPolicyPayload({ botId: 'bot_one', policy: value }), true);
assert.equal(validAccessPolicyPayload({ botId: 'bot_one', policy: value, extra: true }), false);
assert.equal(validAccessPolicyPayload({ botId: '../bad', policy: value }), false);
assert.equal(validAccessPolicyPayload({ botId: 'bot_one', accessMode: 'open' }), false);
assert.equal(validAccessPolicyPayload({
botId: 'bot_one',
policy: {
direct: { mode: 'open', defaultCanExecuteCommands: true, users: [] },
group: { mode: 'open', defaultCanExecuteCommands: true, users: [] },
},
}), false, 'RPC accepts canonical scopes only');
assert.equal(validAccessPolicyPayload({
botId: 'bot_one',
policy: { ...value, direct: { ...value.direct, mode: 'legacy' } },
}), false);
});
function controllerFixture() {
const calls = [];
const snapshot = (accessPolicy = policy()) => ({
schemaVersion: 2,
revision: 1,
bots: [{
botId: 'bot_one',
configured: true,
connected: true,
state: 'connected',
accessPolicy,
}],
});
const controller = {
status: async () => snapshot(),
bindCredentials: async () => snapshot(),
reconnectBot: async () => snapshot(),
deleteBot: async () => snapshot(),
startProvisioning: async () => ({}),
registrationStatus: async () => ({}),
cancelProvisioning: async () => ({}),
submitVerification: async () => ({}),
approveSender: async () => snapshot(),
revokeSender: async () => snapshot(),
startRegistration: async () => ({}),
cancelRegistration: async () => ({}),
disconnect: async () => snapshot(),
async updateAccessPolicy(botId, accessPolicy, projectStatus) {
calls.push({ botId, policy: accessPolicy });
const value = snapshot(accessPolicy);
return projectStatus ? projectStatus(value) : value;
},
};
return { controller, calls };
}
test('all nine Host RPCs accept only the unified bot.access-policy.set contract', async () => {
const factories = [
['weixin', createWeixinRpcHandler, WEIXIN_ENDPOINTS],
['feishu', createFeishuRpcHandler, FEISHU_ENDPOINTS],
['dingtalk', createDingtalkRpcHandler, DINGTALK_ENDPOINTS],
['wecom', createWecomRpcHandler, WECOM_ENDPOINTS],
['qq', createQqRpcHandler, QQ_ENDPOINTS],
['slack', createSlackRpcHandler, SLACK_ENDPOINTS],
['telegram', createTelegramRpcHandler, TELEGRAM_ENDPOINTS],
['discord', createDiscordRpcHandler, DISCORD_ENDPOINTS],
['whatsapp', createWhatsappRpcHandler, WHATSAPP_ENDPOINTS],
];
const next = policy(open([], false), allowlist(['operator']));
for (const [channel, createHandler, endpoints] of factories) {
const { controller, calls } = controllerFixture();
const handler = createHandler(controller);
assert.equal(endpoints.setAccessPolicy, SET_ACCESS_POLICY_ENDPOINT, channel);
const result = await handler(endpoints.setAccessPolicy, { botId: 'bot_one', policy: next });
assert.equal(result.ok, true, `${channel}: ${JSON.stringify(result)}`);
assert.deepEqual(calls, [{ botId: 'bot_one', policy: next }], channel);
assert.deepEqual(result.value?.bots?.[0]?.accessPolicy, next, `${channel} update projection`);
const status = await handler(endpoints.status, {});
assert.equal(status.ok, true, `${channel} status: ${JSON.stringify(status)}`);
assert.deepEqual(status.value?.bots?.[0]?.accessPolicy, policy(), `${channel} status projection`);
const legacy = await handler(endpoints.setAccessPolicy, {
botId: 'bot_one', accessMode: 'open', allowedUsers: [],
});
assert.equal(legacy.ok, false, channel);
assert.equal(legacy.error.code, 'bad-request', channel);
assert.equal(calls.length, 1, channel);
}
});

584
test/access-policy.test.mjs Normal file
View file

@ -0,0 +1,584 @@
import assert from 'node:assert/strict';
import {
mkdtemp,
mkdir,
readFile,
realpath,
rename,
rm,
writeFile,
} from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
import {
DEFAULT_ACCESS_POLICY,
createAccessPolicy,
createAccessPolicyScope,
evaluateAccessPolicy,
normalizeAccessPolicy,
validateAccessPolicy,
} from '../src/channels/shared/access-policy.mjs';
import {
BotWorkspaceStore,
createWorkspaceAwareController,
} from '../src/channels/shared/bot-workspace-store.mjs';
function user(id, canExecuteCommands = true) {
return { id, canExecuteCommands };
}
function scope({
mode = 'open',
defaultCanExecuteCommands = true,
commandPermissionOverrides = [],
users = [],
} = {}) {
return {
mode,
open: { defaultCanExecuteCommands, commandPermissionOverrides },
allowlist: { users },
};
}
function openScope({
defaultCanExecuteCommands = true,
commandPermissionOverrides = [],
allowlistUsers = [],
} = {}) {
return scope({
mode: 'open',
defaultCanExecuteCommands,
commandPermissionOverrides,
users: allowlistUsers,
});
}
function allowlistScope(users = [], {
defaultCanExecuteCommands = false,
commandPermissionOverrides = [],
} = {}) {
return scope({
mode: 'allowlist',
defaultCanExecuteCommands,
commandPermissionOverrides,
users,
});
}
function policy({ direct = openScope(), group = openScope() } = {}) {
return { direct, group };
}
async function fixture(t) {
const root = await realpath(await mkdtemp(join(tmpdir(), 'dsh-im-access-policy-')));
t.after(() => rm(root, { recursive: true, force: true }));
const defaultWorkspace = join(root, 'workspace');
await mkdir(defaultWorkspace);
return {
root,
defaultWorkspace,
path: join(root, 'workspaces.json'),
};
}
test('access policy validates and normalizes one complete atomic config', () => {
const normalized = validateAccessPolicy(policy({
direct: openScope({
defaultCanExecuteCommands: false,
commandPermissionOverrides: [user(' user-one ')],
allowlistUsers: [user('inactive-member', false)],
}),
group: allowlistScope([user(8672352515, false)], {
commandPermissionOverrides: [user('inactive-admin')],
}),
}));
assert.deepEqual(normalized, {
direct: {
mode: 'open',
open: {
defaultCanExecuteCommands: false,
commandPermissionOverrides: [user('user-one')],
},
allowlist: { users: [user('inactive-member', false)] },
},
group: {
mode: 'allowlist',
open: {
defaultCanExecuteCommands: false,
commandPermissionOverrides: [user('inactive-admin')],
},
allowlist: { users: [user('8672352515', false)] },
},
});
assert.equal(Object.isFrozen(normalized), true);
assert.equal(Object.isFrozen(normalized.direct.open.commandPermissionOverrides), true);
assert.equal(Object.isFrozen(normalized.direct.allowlist.users), true);
assert.equal(normalizeAccessPolicy({ damaged: true }), null);
const invalid = [
{},
{ ...policy(), extra: true },
policy({ direct: { ...openScope(), extra: true } }),
policy({ direct: { ...openScope(), mode: 'private' } }),
policy({ direct: { ...openScope(), open: { defaultCanExecuteCommands: true } } }),
policy({ direct: openScope({ defaultCanExecuteCommands: 'yes' }) }),
policy({ direct: openScope({ commandPermissionOverrides: 'user-one' }) }),
policy({ direct: { ...openScope(), allowlist: { users: 'user-one' } } }),
policy({ direct: openScope({ commandPermissionOverrides: [user('')] }) }),
policy({ direct: openScope({ commandPermissionOverrides: [user('bad\u0000id')] }) }),
policy({ direct: openScope({ commandPermissionOverrides: [user('x'.repeat(257))] }) }),
policy({ direct: openScope({ commandPermissionOverrides: [user('user', 'yes')] }) }),
policy({ direct: openScope({
commandPermissionOverrides: [{ ...user('user'), extra: true }],
}) }),
policy({ direct: openScope({
commandPermissionOverrides: [user(' duplicate '), user('duplicate', false)],
}) }),
policy({ direct: allowlistScope([user(' duplicate '), user('duplicate', false)]) }),
{
direct: { mode: 'open', defaultCanExecuteCommands: true, users: [] },
group: { mode: 'open', defaultCanExecuteCommands: true, users: [] },
},
];
for (const input of invalid) {
assert.throws(() => validateAccessPolicy(input), { code: 'access-policy-invalid' });
}
});
test('access policy constructors default closed and accept only canonical scopes', () => {
assert.deepEqual(DEFAULT_ACCESS_POLICY, policy({
direct: allowlistScope(),
group: allowlistScope(),
}));
assert.deepEqual(createAccessPolicy({
direct: createAccessPolicyScope(allowlistScope([
user('owner'),
user(1234n),
])),
group: createAccessPolicyScope(openScope()),
}), policy({
direct: allowlistScope([
user('owner'),
user('1234'),
]),
group: openScope(),
}));
assert.deepEqual(createAccessPolicyScope({
mode: 'open',
open: {
defaultCanExecuteCommands: false,
commandPermissionOverrides: [user('admin')],
},
allowlist: { users: [user('member')] },
}), openScope({
defaultCanExecuteCommands: false,
commandPermissionOverrides: [user('admin')],
allowlistUsers: [user('member')],
}));
assert.throws(() => createAccessPolicyScope({
mode: 'allowlist',
defaultCanExecuteCommands: false,
users: [],
}), { code: 'access-policy-invalid' });
});
test('access decisions keep direct, group, ordinary-message and command permissions separate', () => {
const settings = policy({
direct: openScope({
defaultCanExecuteCommands: false,
commandPermissionOverrides: [user('admin')],
allowlistUsers: [user('guest-deny', false)],
}),
group: allowlistScope([
user('member', false),
user('operator'),
], { commandPermissionOverrides: [user('unknown')] }),
});
assert.deepEqual(evaluateAccessPolicy(settings, {
conversationType: 'direct', senderIds: ['guest'],
}), { allowed: true, reason: 'allowed' });
assert.deepEqual(evaluateAccessPolicy(settings, {
conversationType: 'direct', senderIds: ['guest'], isCommand: true,
}), { allowed: false, reason: 'command-not-allowed' });
assert.deepEqual(evaluateAccessPolicy(settings, {
conversationType: 'direct', senderIds: ['admin'], isCommand: true,
}), { allowed: true, reason: 'allowed' });
assert.deepEqual(evaluateAccessPolicy(settings, {
conversationType: 'direct', senderIds: ['guest-deny'], isCommand: true,
}), { allowed: false, reason: 'command-not-allowed' },
'the inactive allowlist does not override open-mode command defaults');
assert.deepEqual(evaluateAccessPolicy(settings, {
conversationType: 'group', senderIds: ['unknown'],
}), { allowed: false, reason: 'sender-not-allowed' });
assert.deepEqual(evaluateAccessPolicy(settings, {
conversationType: 'group', senderIds: ['member'],
}), { allowed: true, reason: 'allowed' });
assert.deepEqual(evaluateAccessPolicy(settings, {
conversationType: 'group', senderIds: ['member'], isCommand: true,
}), { allowed: false, reason: 'command-not-allowed' });
assert.deepEqual(evaluateAccessPolicy(settings, {
conversationType: 'group',
senderIds: ['alternate', 'OPERATOR'],
isCommand: true,
equals: (left, right) => left.toLowerCase() === right.toLowerCase(),
}), { allowed: true, reason: 'allowed' });
const nonCanonical = {
direct: {
mode: 'allowlist',
defaultCanExecuteCommands: false,
users: [user('old-member', false)],
},
group: { mode: 'open', defaultCanExecuteCommands: true, users: [] },
};
assert.equal(normalizeAccessPolicy(nonCanonical), null);
assert.deepEqual(evaluateAccessPolicy(nonCanonical, {
conversationType: 'direct', senderIds: ['old-member'],
}), { allowed: false, reason: 'policy-unavailable' });
assert.deepEqual(evaluateAccessPolicy(null, {
conversationType: 'direct', senderIds: ['admin'],
}), { allowed: false, reason: 'policy-unavailable' });
assert.deepEqual(evaluateAccessPolicy(settings, {
conversationType: 'channel', senderIds: ['admin'],
}), { allowed: false, reason: 'invalid-context' });
assert.deepEqual(evaluateAccessPolicy(settings, {
conversationType: 'direct', senderIds: [null, 'bad\u0000id'],
}), { allowed: false, reason: 'sender-unavailable' });
});
test('access decisions read only the active scenario when one id exists in both lists', () => {
const sameId = policy({
direct: openScope({
defaultCanExecuteCommands: false,
commandPermissionOverrides: [user('same-user', true)],
allowlistUsers: [user('same-user', false)],
}),
group: allowlistScope([user('same-user', false)], {
defaultCanExecuteCommands: false,
commandPermissionOverrides: [user('same-user', true)],
}),
});
assert.doesNotThrow(() => validateAccessPolicy(sameId),
'the same id may appear once in each independent scenario');
assert.deepEqual(evaluateAccessPolicy(sameId, {
conversationType: 'direct', senderIds: ['same-user'], isCommand: true,
}), { allowed: true, reason: 'allowed' },
'open mode reads its override and ignores the conflicting allowlist row');
assert.deepEqual(evaluateAccessPolicy(sameId, {
conversationType: 'group', senderIds: ['same-user'], isCommand: true,
}), { allowed: false, reason: 'command-not-allowed' },
'allowlist mode reads its row and ignores the conflicting open override');
});
test('access decisions deny commands when equivalent sender aliases match conflicting rows', () => {
const settings = policy({
direct: openScope({
defaultCanExecuteCommands: true,
commandPermissionOverrides: [
user('configured-pn', true),
user('configured-lid', false),
],
}),
group: allowlistScope([
user('configured-pn', true),
user('configured-lid', false),
]),
});
const aliases = new Set([
'sender-pn', 'sender-lid', 'configured-pn', 'configured-lid',
]);
const equals = (left, right) => aliases.has(left) && aliases.has(right);
const senderIds = ['sender-pn', 'sender-lid'];
assert.deepEqual(evaluateAccessPolicy(settings, {
conversationType: 'direct', senderIds, isCommand: true, equals,
}), { allowed: false, reason: 'command-not-allowed' });
assert.deepEqual(evaluateAccessPolicy(settings, {
conversationType: 'group', senderIds, equals,
}), { allowed: true, reason: 'allowed' },
'any matching allowlist alias permits an ordinary message');
assert.deepEqual(evaluateAccessPolicy(settings, {
conversationType: 'group', senderIds, isCommand: true, equals,
}), { allowed: false, reason: 'command-not-allowed' });
const allAllowed = policy({
direct: openScope({
defaultCanExecuteCommands: false,
commandPermissionOverrides: [
user('configured-pn', true),
user('configured-lid', true),
],
}),
});
assert.deepEqual(evaluateAccessPolicy(allAllowed, {
conversationType: 'direct', senderIds, isCommand: true, equals,
}), { allowed: true, reason: 'allowed' },
'all equivalent matching rows must explicitly allow commands');
});
test('BotWorkspaceStore initializes a missing policy once and upgrades v1 to v2', async (t) => {
const { path, defaultWorkspace } = await fixture(t);
await writeFile(path, `${JSON.stringify({
version: 1,
workspaces: { bot_one: defaultWorkspace },
agentPresets: { bot_one: 'router-standard' },
})}\n`);
const store = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
const initial = policy({ direct: allowlistScope([
{ id: 'owner', canExecuteCommands: true },
]) });
await store.ensure('bot_one', { initialAccessPolicy: initial });
assert.deepEqual(store.accessPolicyFor('bot_one'), initial);
const saved = JSON.parse(await readFile(path, 'utf8'));
assert.equal(saved.version, 2);
assert.equal(saved.workspaces.bot_one, defaultWorkspace);
assert.equal(saved.agentPresets.bot_one, 'router-standard');
assert.deepEqual(saved.accessPolicies.bot_one, initial);
const replacementSeed = policy({ direct: openScope() });
await store.ensure('bot_one', { initialAccessPolicy: replacementSeed });
assert.deepEqual(store.accessPolicyFor('bot_one'), initial, 'initialization is idempotent');
const reloaded = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
assert.deepEqual(reloaded.accessPolicyFor('bot_one'), initial);
assert.deepEqual(reloaded.decorateStatus({ bots: [{ botId: 'bot_one' }] }).bots[0].accessPolicy, initial);
});
test('BotWorkspaceStore fail-closes non-canonical v2 policies without rewriting on load', async (t) => {
const { path, defaultWorkspace } = await fixture(t);
const valid = policy({
direct: allowlistScope([user('member')]),
group: openScope({ defaultCanExecuteCommands: false }),
});
const nonCanonical = {
direct: {
mode: 'open',
defaultCanExecuteCommands: false,
users: [user('old-override')],
},
group: {
mode: 'allowlist',
defaultCanExecuteCommands: true,
users: [user('old-member', false)],
},
};
const original = JSON.stringify({
version: 2,
workspaces: {
bot_good: defaultWorkspace,
bot_noncanonical: defaultWorkspace,
},
agentPresets: { bot_good: 'router-standard' },
contextEnhancement: {
bot_good: {
group: { enabled: false, fields: ['senderId'], guidance: '' },
direct: { enabled: true, fields: ['senderId', 'senderName'], guidance: 'direct' },
},
},
deliveryTargets: {
bot_good: { target: { kind: 'user', route: { userId: 'one' } } },
},
accessPolicies: {
bot_good: valid,
bot_noncanonical: nonCanonical,
},
});
await writeFile(path, original);
const store = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
assert.deepEqual(store.accessPolicyFor('bot_good'), valid);
assert.equal(store.accessPolicyFor('bot_noncanonical'), null);
assert.equal(store.agentPresetFor('bot_good'), 'router-standard');
assert.equal(store.deliveryTargetFor('bot_good', 'target').route.userId, 'one');
assert.equal(await readFile(path, 'utf8'), original,
'loading invalid policy data must be read-only');
await store.ensure('bot_noncanonical', { initialAccessPolicy: policy() });
assert.equal(store.accessPolicyFor('bot_noncanonical'), null,
'invalid policy data is not treated as a missing seed');
assert.equal(await readFile(path, 'utf8'), original);
});
test('BotWorkspaceStore isolates damaged policies and does not initialize over them', async (t) => {
const { path, defaultWorkspace } = await fixture(t);
const valid = policy({ group: allowlistScope([
{ id: 'member', canExecuteCommands: true },
]) });
await writeFile(path, `${JSON.stringify({
version: 2,
workspaces: {
bot_good: defaultWorkspace,
bot_damaged: defaultWorkspace,
bot_missing: defaultWorkspace,
},
deliveryTargets: {
bot_good: { target: { kind: 'user', route: { userId: 'one' } } },
},
accessPolicies: {
bot_good: valid,
bot_damaged: { direct: { mode: 'open' } },
},
})}\n`);
const store = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
assert.deepEqual(store.accessPolicyFor('bot_good'), valid);
assert.equal(store.accessPolicyFor('bot_damaged'), null);
assert.equal(store.accessPolicyFor('bot_missing'), null);
await store.ensure('bot_damaged', { initialAccessPolicy: policy() });
await store.ensure('bot_missing', { initialAccessPolicy: policy() });
assert.equal(store.accessPolicyFor('bot_damaged'), null, 'damaged is not treated as missing');
assert.deepEqual(store.accessPolicyFor('bot_missing'), policy());
assert.equal(store.deliveryTargetFor('bot_good', 'target').route.userId, 'one');
const saved = JSON.parse(await readFile(path, 'utf8'));
assert.equal(saved.accessPolicies.bot_damaged, null);
assert.deepEqual(saved.accessPolicies.bot_good, valid);
assert.deepEqual(saved.accessPolicies.bot_missing, policy());
});
test('BotWorkspaceStore fail-closes a damaged policy section without poisoning other bot data', async (t) => {
const { path, defaultWorkspace } = await fixture(t);
await writeFile(path, `${JSON.stringify({
version: 2,
workspaces: { bot_one: defaultWorkspace },
agentPresets: { bot_one: 'router-standard' },
deliveryTargets: {
bot_one: { target: { kind: 'user', route: { userId: 'one' } } },
},
accessPolicies: 'damaged-section',
})}\n`);
const store = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
assert.equal(store.workspaceFor('bot_one'), defaultWorkspace);
assert.equal(store.agentPresetFor('bot_one'), 'router-standard');
assert.equal(store.deliveryTargetFor('bot_one', 'target').route.userId, 'one');
assert.equal(store.accessPolicyFor('bot_one'), null);
await store.ensure('bot_one', { initialAccessPolicy: policy() });
assert.equal(store.accessPolicyFor('bot_one'), null,
'startup initialization must not overwrite a damaged policy section');
});
test('BotWorkspaceStore publishes policy snapshots only after atomic persistence', async (t) => {
const { root, defaultWorkspace } = await fixture(t);
const storeDirectory = join(root, 'store');
const storePath = join(storeDirectory, 'workspaces.json');
await mkdir(storeDirectory);
const store = await new BotWorkspaceStore(storePath, { defaultWorkspace }).load();
const initial = policy({ direct: allowlistScope([
{ id: 'owner', canExecuteCommands: true },
]) });
await store.ensure('bot_io', { initialAccessPolicy: initial });
await rename(storeDirectory, `${storeDirectory}-saved`);
await writeFile(storeDirectory, 'blocks policy persistence');
await assert.rejects(store.setAccessPolicy('bot_io', policy()));
assert.deepEqual(store.accessPolicyFor('bot_io'), initial);
await rm(storeDirectory, { force: true });
await rename(`${storeDirectory}-saved`, storeDirectory);
assert.deepEqual(JSON.parse(await readFile(storePath, 'utf8')).accessPolicies.bot_io, initial);
});
test('BotWorkspaceStore cleans policies on reconcile and fences same-id stale updates', async (t) => {
const { path, defaultWorkspace } = await fixture(t);
const store = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await store.ensure('bot_one', { initialAccessPolicy: policy() });
await store.ensure('bot_two', { initialAccessPolicy: policy({ group: allowlistScope() }) });
const staleIncarnation = store.incarnationFor('bot_one');
await store.remove('bot_one');
await store.ensure('bot_one', {
initialAccessPolicy: policy({ direct: allowlistScope([
{ id: 'new-owner', canExecuteCommands: true },
]) }),
});
await assert.rejects(store.setAccessPolicy('bot_one', policy(), {
incarnation: staleIncarnation,
}), { code: 'workspace-bot-not-found' });
assert.equal(store.accessPolicyFor('bot_one').direct.allowlist.users[0].id, 'new-owner');
await store.reconcile(['bot_one']);
const saved = JSON.parse(await readFile(path, 'utf8'));
assert.equal(saved.workspaces.bot_two, undefined);
assert.equal(saved.accessPolicies.bot_two, undefined);
assert.equal(saved.accessPolicies.bot_one.direct.allowlist.users[0].id, 'new-owner');
});
test('workspace-aware controller preprojects a full policy status before commit', async (t) => {
const { path, defaultWorkspace } = await fixture(t);
const store = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
const initial = policy({ direct: allowlistScope() });
const updated = policy({ direct: openScope({ defaultCanExecuteCommands: false }) });
await store.ensure('bot_one', { initialAccessPolicy: initial });
const base = {
status() { return { bots: [{ botId: 'bot_one', connected: true }] }; },
};
const controller = createWorkspaceAwareController(base, {
workspaces: store,
stateFor: async () => ({ async clearSessions() {} }),
});
const result = await controller.updateAccessPolicy('bot_one', updated, async (projected) => {
assert.deepEqual(projected.bots[0].accessPolicy, updated);
assert.deepEqual(store.accessPolicyFor('bot_one'), initial, 'projection happens before commit');
return { ...projected, projected: true };
});
assert.equal(result.projected, true);
assert.deepEqual(result.bots[0].accessPolicy, updated);
assert.deepEqual(store.accessPolicyFor('bot_one'), updated);
await assert.rejects(controller.updateAccessPolicy('bot_one', initial, async () => {
throw new Error('projection failed');
}), /projection failed/);
assert.deepEqual(store.accessPolicyFor('bot_one'), updated);
assert.throws(() => controller.updateAccessPolicy('bot_one', {
direct: { mode: 'open', defaultCanExecuteCommands: true, users: [] },
group: { mode: 'open', defaultCanExecuteCommands: true, users: [] },
}), {
code: 'access-policy-invalid',
});
assert.deepEqual(store.accessPolicyFor('bot_one'), updated,
'strict writes reject non-canonical payloads without changing the committed policy');
await assert.rejects(controller.updateAccessPolicy('missing', initial), {
code: 'workspace-bot-not-found',
});
});
test('workspace-aware controller cannot write a policy into a same-id rebound bot', async (t) => {
const { path, defaultWorkspace } = await fixture(t);
const store = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
const oldPolicy = policy({ direct: allowlistScope([
{ id: 'old-owner', canExecuteCommands: true },
]) });
const reboundPolicy = policy({ direct: allowlistScope([
{ id: 'new-owner', canExecuteCommands: true },
]) });
await store.ensure('bot_rebound', { initialAccessPolicy: oldPolicy });
let markStatusStarted;
let releaseStatus;
const statusStarted = new Promise((resolveStarted) => { markStatusStarted = resolveStarted; });
const statusGate = new Promise((resolveStatus) => { releaseStatus = resolveStatus; });
const controller = createWorkspaceAwareController({
async status() {
markStatusStarted();
await statusGate;
return { bots: [{ botId: 'bot_rebound' }] };
},
}, {
workspaces: store,
stateFor: async () => ({ async clearSessions() {} }),
});
const updating = controller.updateAccessPolicy('bot_rebound', policy());
await statusStarted;
await store.remove('bot_rebound');
await store.ensure('bot_rebound', { initialAccessPolicy: reboundPolicy });
releaseStatus();
await assert.rejects(updating, { code: 'workspace-bot-not-found' });
assert.deepEqual(store.accessPolicyFor('bot_rebound'), reboundPolicy);
});

View file

@ -0,0 +1,35 @@
export { COMMAND_PERMISSION_DENIED_MESSAGE } from '../../src/channels/shared/inbound-access.mjs';
function scope(users = []) {
return {
mode: 'allowlist',
open: {
defaultCanExecuteCommands: false,
commandPermissionOverrides: [],
},
allowlist: {
users: users.map(({ id, canExecuteCommands = false }) => ({
id,
canExecuteCommands,
})),
},
};
}
export function directAccessPolicy({
users = [],
privilegedIds = [],
} = {}) {
const privileged = new Set(privilegedIds);
const settings = {
direct: scope(users),
group: scope(),
};
return {
getSettings: () => settings,
isPrivileged: (senderIds) => (
(Array.isArray(senderIds) ? senderIds : [senderIds])
.some((senderId) => privileged.has(senderId))
),
};
}

View file

@ -26,6 +26,7 @@ test('client exposes the fixed DingTalk RPC channel and endpoint names', () => {
setWorkspace: 'bot.workspace.set',
setAgentPreset: 'bot.preset.set',
setContextEnhancement: 'bot.context-enhancement.set',
setAccessPolicy: 'bot.access-policy.set',
});
});

View file

@ -21,6 +21,10 @@ import {
createOutboundArtifactTool,
releaseOutboundArtifact,
} from '../../../src/channels/shared/semantic/artifact.mjs';
import {
COMMAND_PERMISSION_DENIED_MESSAGE,
directAccessPolicy,
} from '../access-policy-fixture.mjs';
function deferred() {
let resolve;
@ -681,6 +685,75 @@ test('DingTalk checks the group mention before downloading a picture', async ()
assert.equal(asks, 0);
});
test('DingTalk applies the unified access policy before attachments or Harness work', async () => {
const fixture = stateFixture();
fixture.sessions.set('p2p:staff-member', 'session-member');
let downloads = 0;
const harnessCalls = [];
const sent = [];
const accessPolicy = directAccessPolicy({
users: [{ id: 'staff-member', canExecuteCommands: false }],
privilegedIds: ['staff-owner'],
});
const bridge = new DingtalkHarnessBridge({
api: {
downloadImage: async () => {
downloads += 1;
return PNG_BYTES;
},
sendText: async ({ text }) => {
sent.push(text);
return { messageId: `dingtalk-policy-${sent.length}` };
},
},
clientId: 'ding-client',
clientSecret: 'host-secret',
accessPolicy,
harness: {
sessionExists: async (sessionId) => {
harnessCalls.push(['sessionExists', sessionId]);
return true;
},
ask: async (sessionId, prompt) => {
harnessCalls.push(['ask', sessionId, prompt]);
return '白名单消息已处理';
},
},
state: fixture.state,
});
await bridge.accept(message('policy-blocked-picture', '', {
senderStaffId: 'staff-blocked',
msgtype: 'picture',
text: undefined,
content: { downloadCode: 'blocked-picture' },
robotCode: 'robot-code',
}));
assert.equal(downloads, 0);
assert.deepEqual(harnessCalls, []);
assert.deepEqual(sent, []);
await bridge.accept(message('policy-member-text', '普通消息', {
senderStaffId: 'staff-member',
}));
assert.equal(harnessCalls.some(([operation]) => operation === 'ask'), true);
assert.deepEqual(sent, ['白名单消息已处理']);
const callsBeforeDeniedCommand = harnessCalls.length;
const repliesBeforeDeniedCommand = sent.length;
await bridge.accept(message('policy-member-command', '/help', {
senderStaffId: 'staff-member',
}));
assert.equal(harnessCalls.length, callsBeforeDeniedCommand);
assert.deepEqual(sent.slice(repliesBeforeDeniedCommand), [COMMAND_PERMISSION_DENIED_MESSAGE]);
accessPolicy.getSettings().direct.allowlist.users = [];
await bridge.accept(message('policy-owner-command', '/help', {
senderStaffId: 'staff-owner',
}));
assert.match(sent.at(-1), /\/help/);
});
test('DingTalk returns a specific retry message when picture download fails', async () => {
const fixture = stateFixture();
fixture.sessions.set('p2p:staff-approved', 'session-image');

View file

@ -21,6 +21,7 @@ import {
resolveDiscordMessageRoute,
} from '../../../src/channels/discord/discord-runtime.mjs';
import { setImHostLanguage } from '../../../src/channels/shared/i18n.mjs';
import { COMMAND_PERMISSION_DENIED_MESSAGE } from '../../../src/channels/shared/inbound-access.mjs';
import {
DISCORD_ENDPOINTS,
createDiscordRpcHandler,
@ -1442,9 +1443,12 @@ test('Discord captures context settings before asynchronous Thread routing and u
const routingStarted = deferred();
const releaseRouting = deferred();
const seen = new Set();
const deliveries = [];
const prompts = [];
let socket;
let reads = 0;
let accessReads = 0;
let threadStarts = 0;
let config = {
group: {
enabled: true,
@ -1453,6 +1457,24 @@ test('Discord captures context settings before asynchronous Thread routing and u
},
direct: { enabled: false, fields: [], guidance: 'direct must not leak' },
};
const allowedAccessSettings = {
direct: {
mode: 'open',
open: { defaultCanExecuteCommands: true, commandPermissionOverrides: [] },
allowlist: { users: [] },
},
group: {
mode: 'allowlist',
open: { defaultCanExecuteCommands: false, commandPermissionOverrides: [] },
allowlist: {
users: [
{ id: '333333333333333333', canExecuteCommands: true },
{ id: '333333333333333334', canExecuteCommands: false },
],
},
},
};
let accessSettings = allowedAccessSettings;
const runtime = new DiscordRuntime({
config: { botId: 'discord_internal', platformId: botId, name: 'Harness Discord' },
token: TOKEN,
@ -1460,6 +1482,12 @@ test('Discord captures context settings before asynchronous Thread routing and u
botId: 'discord_internal',
getSettings: () => { reads += 1; return config; },
},
accessPolicy: {
getSettings: () => {
accessReads += 1;
return accessSettings;
},
},
harness: {
ensureRunning: async () => true,
sessionExists: async () => true,
@ -1475,12 +1503,16 @@ test('Discord captures context settings before asynchronous Thread routing and u
getGatewayBot: async () => ({ url: 'wss://gateway.discord.gg' }),
getChannel: async () => assert.fail('The channel is already in the gateway cache'),
startThreadFromMessage: async () => {
threadStarts += 1;
routingStarted.resolve();
await releaseRouting.promise;
return { id: threadId, type: 11, parent_id: parentId, owner_id: botId };
},
sendTyping: async () => {},
createMessage: async () => ({ id: '888888888888888890' }),
createMessage: async (request) => {
deliveries.push(request);
return { id: '888888888888888890' };
},
editMessage: async ({ messageId }) => ({ id: messageId }),
}),
createWebSocket: () => {
@ -1498,13 +1530,37 @@ test('Discord captures context settings before asynchronous Thread routing and u
socket.emit('message', { data: JSON.stringify({ op: 0, t: 'GUILD_CREATE', s: 2,
d: { id: '444444444444444444', channels: [{ id: parentId, type: 0 }], threads: [] },
}) });
socket.emit('message', { data: JSON.stringify({ op: 0, t: 'MESSAGE_CREATE', s: 3, d: {
id: '111111111111111189', channel_id: parentId, guild_id: '444444444444444444',
author: { id: '333333333333333332', bot: false },
mentions: [{ id: botId }], content: `<@${botId}> denied before Thread`,
} }) });
await eventually(() => runtime.status.messagesRejected === 1);
assert.equal(threadStarts, 0, 'a denied member must not create a Discord Thread');
socket.emit('message', { data: JSON.stringify({ op: 0, t: 'MESSAGE_CREATE', s: 4, d: {
id: '111111111111111188', channel_id: parentId, guild_id: '444444444444444444',
author: { id: '333333333333333334', bot: false },
mentions: [{ id: botId }], content: `<@${botId}> /new`,
} }) });
await eventually(() => runtime.status.messagesRejected === 2);
assert.equal(threadStarts, 0, 'a command-denied member must not create a Discord Thread');
assert.equal(deliveries.at(-1)?.channelId, parentId);
assert.equal(deliveries.at(-1)?.content, COMMAND_PERMISSION_DENIED_MESSAGE);
socket.emit('message', { data: JSON.stringify({ op: 0, t: 'MESSAGE_CREATE', s: 3, d: {
id: threadId, channel_id: parentId, guild_id: '444444444444444444',
author: { id: '333333333333333333', bot: false, global_name: 'Global Name', username: 'username' },
member: { nick: 'Group Nick' }, mentions: [{ id: botId }], content: `<@${botId}> first`,
} }) });
await routingStarted.promise;
assert.equal(threadStarts, 1);
config = { ...config, group: { ...config.group, enabled: false } };
accessSettings = {
...allowedAccessSettings,
group: {
...allowedAccessSettings.group,
allowlist: { users: [] },
},
};
releaseRouting.resolve();
await eventually(() => runtime.status.messagesReplied === 1);
assert.match(prompts[0], /accepted before routing/);
@ -1513,7 +1569,10 @@ test('Discord captures context settings before asynchronous Thread routing and u
senderName: 'Group Nick', botId: 'discord_internal',
});
assert.equal(reads, 1, 'routing and Bridge share one accepted configuration read');
assert.equal(accessReads, 3,
'each source event reads access once and the Thread keeps its arrival decision');
accessSettings = allowedAccessSettings;
socket.emit('message', { data: JSON.stringify({ op: 0, t: 'MESSAGE_CREATE', s: 4, d: {
id: '111111111111111191', channel_id: threadId, guild_id: '444444444444444444',
author: { id: '333333333333333333', bot: false }, content: 'second without enhancement',
@ -1521,6 +1580,7 @@ test('Discord captures context settings before asynchronous Thread routing and u
await eventually(() => runtime.status.messagesReplied === 2);
assert.equal(prompts[1], 'second without enhancement');
assert.equal(reads, 2);
assert.equal(accessReads, 4, 'the next managed-Thread event reads the latest policy once');
});
test('Discord runtime records one uncertain Thread result and suppresses Gateway replays', async () => {

View file

@ -15,6 +15,10 @@ import {
OutboundArtifactRegistry,
createOutboundArtifactTool,
} from '../../../src/channels/shared/semantic/artifact.mjs';
import {
COMMAND_PERMISSION_DENIED_MESSAGE,
directAccessPolicy,
} from '../access-policy-fixture.mjs';
const PNG_1X1 = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
@ -894,6 +898,79 @@ test('bridge downloads an inbound Feishu image once and submits structured Harne
assert.deepEqual(sent, ['看到了一张图片']);
});
test('Feishu applies the unified access policy before attachments or Harness work', async () => {
const fixture = stateFixture([['p2p:ou_member', 'session-member']]);
let downloads = 0;
const harnessCalls = [];
const sent = [];
const accessPolicy = directAccessPolicy({
users: [{ id: 'ou_member', canExecuteCommands: false }],
privilegedIds: ['ou_owner'],
});
const client = {
im: { v1: {
messageResource: { get: async () => {
downloads += 1;
return { getReadableStream: () => Readable.from([PNG_1X1]) };
} },
message: { create: async (request) => {
sent.push(JSON.parse(request.data.content).text);
return { code: 0, data: { message_id: `om_policy_${sent.length}` } };
} },
} },
};
const bridge = new FeishuHarnessBridge({
client,
channel: {},
accessPolicy,
harness: {
sessionExists: async (sessionId) => {
harnessCalls.push(['sessionExists', sessionId]);
return true;
},
ask: async (sessionId, prompt) => {
harnessCalls.push(['ask', sessionId, prompt]);
return '白名单消息已处理';
},
},
state: fixture.state,
status: bridgeStatus(),
});
await bridge.accept(event('policy-blocked-image', '', {
senderOpenId: 'ou_blocked',
message_type: 'image',
content: JSON.stringify({ image_key: 'img_blocked' }),
}));
await bridge.waitForIdle();
assert.equal(downloads, 0);
assert.deepEqual(harnessCalls, []);
assert.deepEqual(sent, []);
await bridge.accept(event('policy-member-text', '普通消息', {
senderOpenId: 'ou_member',
}));
await bridge.waitForIdle();
assert.equal(harnessCalls.some(([operation]) => operation === 'ask'), true);
assert.deepEqual(sent, ['白名单消息已处理']);
const callsBeforeDeniedCommand = harnessCalls.length;
const repliesBeforeDeniedCommand = sent.length;
await bridge.accept(event('policy-member-command', '/help', {
senderOpenId: 'ou_member',
}));
await bridge.waitForIdle();
assert.equal(harnessCalls.length, callsBeforeDeniedCommand);
assert.deepEqual(sent.slice(repliesBeforeDeniedCommand), [COMMAND_PERMISSION_DENIED_MESSAGE]);
accessPolicy.getSettings().direct.allowlist.users = [];
await bridge.accept(event('policy-owner-command', '/help', {
senderOpenId: 'ou_owner',
}));
await bridge.waitForIdle();
assert.match(sent.at(-1), /\/status/);
});
test('bridge hands a native Feishu file source to the current Harness turn', async () => {
const fixture = stateFixture([['p2p:ou_user', 'session-file']]);
const bytes = Buffer.from('feishu-native-file');
@ -3533,6 +3610,32 @@ test('card buttons from an unallowed sender are ignored', async () => {
assert.equal(sent.length, 1, 'a card action without an operator must fail closed');
});
test('a card callback without a trusted route stays silent before access evaluation', async () => {
const sent = [];
const bridge = new FeishuHarnessBridge({
client: cardClient(async (outgoing) => sent.push(outgoing)),
channel: {},
accessPolicy: directAccessPolicy({
users: [{ id: 'ou_member', canExecuteCommands: true }],
privilegedIds: ['ou_owner'],
}),
harness: sessionsHarness(1),
state: stateFixture().state,
status: bridgeStatus(),
});
await bridge.onCardAction({
...cardActionEvent('om_stale_after_restart', 'new', 'ou_member'),
context: {
open_message_id: 'om_stale_after_restart',
open_chat_id: 'oc_untrusted_scope',
},
});
await bridge.waitForIdle();
assert.deepEqual(sent, [], 'missing direct/group scope must fail closed without a reply');
});
test('card buttons from an allowed sender work', async () => {
const fixture = stateFixture();
const sent = [];

View file

@ -17,6 +17,10 @@ import {
OutboundArtifactRegistry,
createOutboundArtifactTool,
} from '../../../src/channels/shared/semantic/artifact.mjs';
import {
COMMAND_PERMISSION_DENIED_MESSAGE,
directAccessPolicy,
} from '../access-policy-fixture.mjs';
function deferred() {
let resolve;
@ -475,6 +479,74 @@ test('QQ checks sender and group mention before downloading image attachments',
assert.equal(asks, 0);
});
test('QQ applies the unified access policy before attachments or Harness work', async () => {
const fixture = stateFixture([['c2c:member-openid', 'session-member']]);
let downloads = 0;
const harnessCalls = [];
const sent = [];
const accessPolicy = directAccessPolicy({
users: [{ id: 'member-openid', canExecuteCommands: false }],
privilegedIds: ['owner-openid'],
});
const bridge = new QqHarnessBridge({
bot: {
sendText: async (_target, text) => {
sent.push(text);
return { id: `qq-policy-${sent.length}` };
},
},
ownerUserOpenid: 'owner-openid',
accessPolicy,
harness: {
sessionExists: async (sessionId) => {
harnessCalls.push(['sessionExists', sessionId]);
return true;
},
ask: async (sessionId, prompt) => {
harnessCalls.push(['ask', sessionId, prompt]);
return '白名单消息已处理';
},
},
state: fixture.state,
fetchImpl: async () => {
downloads += 1;
return new Response(PNG_BYTES, { headers: { 'content-type': 'image/png' } });
},
});
const directMessage = (messageId, senderId, content, overrides = {}) => message({
messageId,
senderId,
content,
replyTarget: { scope: 'c2c', targetId: senderId, msgId: messageId },
...overrides,
});
await bridge.accept(directMessage('policy-blocked-image', 'blocked-openid', '', {
attachments: [{
content_type: 'image/png',
filename: 'blocked.png',
url: 'https://multimedia.nt.qq.com.cn/download/blocked',
}],
}));
assert.equal(downloads, 0);
assert.deepEqual(harnessCalls, []);
assert.deepEqual(sent, []);
await bridge.accept(directMessage('policy-member-text', 'member-openid', '普通消息'));
assert.equal(harnessCalls.some(([operation]) => operation === 'ask'), true);
assert.deepEqual(sent, ['白名单消息已处理']);
const callsBeforeDeniedCommand = harnessCalls.length;
const repliesBeforeDeniedCommand = sent.length;
await bridge.accept(directMessage('policy-member-command', 'member-openid', '/help'));
assert.equal(harnessCalls.length, callsBeforeDeniedCommand);
assert.deepEqual(sent.slice(repliesBeforeDeniedCommand), [COMMAND_PERMISSION_DENIED_MESSAGE]);
accessPolicy.getSettings().direct.allowlist.users = [];
await bridge.accept(directMessage('policy-owner-command', 'owner-openid', '/help'));
assert.match(sent.at(-1), /\/help/);
});
test('QQ rejects non-platform image URLs without fetching and returns a retryable image error', async () => {
const fixture = stateFixture([['c2c:owner-openid', 'session-image']]);
const sent = [];

View file

@ -0,0 +1,65 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { isSharedLocalCommand } from '../../../src/channels/shared/command-permission.mjs';
import { evaluateInboundAccess } from '../../../src/channels/shared/inbound-access.mjs';
test('isSharedLocalCommand matches existing local command families', () => {
for (const command of [
'/help', '/status', '/new', '/version', '/stop', '/steer more',
'/batch', '/send', '/cancel', '/history 3', '/workspace /tmp',
'/workspacelist', '/sessionlist', '/sessions /tmp', '/session 2',
'/compact', '/models', '/model 2', '/reasonings', '/reasoning high',
'/presetlist', '/preset default',
]) {
assert.equal(isSharedLocalCommand(command), true, command);
}
});
test('isSharedLocalCommand leaves unknown and channel-specific slash text as ordinary prompts', () => {
for (const text of [
'/foo', '/help me', 'hello', '/', '',
'/menu', '/repair verify', '/watch session-id', '/unwatch session-id',
'/watchlist', '/archived off',
]) {
assert.equal(isSharedLocalCommand(text), false, text);
}
});
test('isSharedLocalCommand follows current media command routing', () => {
assert.equal(isSharedLocalCommand('/history', { hasFiles: true }), true);
assert.equal(isSharedLocalCommand('/batch', { hasFiles: true }), true);
assert.equal(isSharedLocalCommand('/status', { hasImages: true }), false);
assert.equal(isSharedLocalCommand('/workspace /tmp', { hasFiles: true }), false);
assert.equal(isSharedLocalCommand('/stop', { hasImages: true }), true);
assert.equal(isSharedLocalCommand('/stop', { hasFiles: true }), false);
});
test('evaluateInboundAccess always preserves an original owner privilege', () => {
const deniedPolicy = {
direct: {
mode: 'allowlist',
open: { defaultCanExecuteCommands: false, commandPermissionOverrides: [] },
allowlist: { users: [] },
},
group: {
mode: 'allowlist',
open: { defaultCanExecuteCommands: false, commandPermissionOverrides: [] },
allowlist: { users: [] },
},
};
const accessPolicy = {
getSettings: () => deniedPolicy,
isPrivileged: (senderIds) => senderIds === 'owner-id',
};
assert.deepEqual(evaluateInboundAccess(accessPolicy, {
conversationType: 'group',
senderIds: 'owner-id',
text: '/status',
}), { allowed: true, reason: 'privileged-sender' });
assert.equal(evaluateInboundAccess(accessPolicy, {
conversationType: 'group',
senderIds: 'another-user',
text: '/status',
}).allowed, false);
});

View file

@ -7,6 +7,7 @@ import manifest from '../../../package.json' with { type: 'json' };
import { DiscordHarnessBridge } from '../../../src/channels/discord/discord-bridge.mjs';
import { connectionTestTarget } from '../../../src/channels/shared/connection-test.mjs';
import { COMMAND_PERMISSION_DENIED_MESSAGE } from '../../../src/channels/shared/inbound-access.mjs';
import { InboundFileError } from '../../../src/channels/shared/inbound-file.mjs';
import {
OUTBOUND_ARTIFACT_TOOL,
@ -88,6 +89,21 @@ function message(messageId, content, overrides = {}) {
};
}
function accessPolicy({ canExecuteCommands = false } = {}) {
return {
direct: {
mode: 'allowlist',
open: { defaultCanExecuteCommands: false, commandPermissionOverrides: [] },
allowlist: { users: [{ id: 'actor-a', canExecuteCommands }] },
},
group: {
mode: 'allowlist',
open: { defaultCanExecuteCommands: false, commandPermissionOverrides: [] },
allowlist: { users: [] },
},
};
}
function questionInteraction({
id = 'question-one',
sessionId = 'session-one',
@ -277,6 +293,84 @@ test('shared status reactions replace processing with success without joining th
]);
});
test('all four shared text channels enforce fail-closed live access before side effects', async () => {
for (const [name, Bridge] of [
['slack', SlackHarnessBridge],
['telegram', TelegramHarnessBridge],
['discord', DiscordHarnessBridge],
['whatsapp', WhatsappHarnessBridge],
]) {
const fixture = stateFixture();
const sent = [];
const asks = [];
let imageLoads = 0;
let sessionClears = 0;
let policyReadFails = true;
let settings = null;
const originalClearSession = fixture.state.clearSession.bind(fixture.state);
fixture.state.clearSession = async (...args) => {
sessionClears += 1;
return originalClearSession(...args);
};
const bridge = new Bridge({
accessPolicy: {
getSettings() {
if (policyReadFails) throw new Error('private policy read detail');
return settings;
},
isPrivileged: (senderIds) => senderIds.includes('owner-a'),
},
bot: { sendText: async (_target, text) => sent.push(text) },
state: fixture.state,
harness: {
createSession: async () => `session-access-${name}`,
sessionExists: async () => true,
ask: async (_sessionId, content) => {
asks.push(content);
return `${name} allowed reply`;
},
},
});
await bridge.accept(message(`access-blocked-${name}`, 'blocked attachment', {
images: [{
mediaType: 'image/png',
load: async () => {
imageLoads += 1;
return Buffer.from('must not load');
},
}],
}));
assert.equal(imageLoads, 0, `${name} authorizes before downloading attachments`);
assert.deepEqual(asks, [], `${name} fail-closed denial never reaches Harness`);
assert.equal(fixture.seen.has(`access-blocked-${name}`), true,
`${name} records a denial for replay suppression`);
await bridge.accept(message(`access-owner-${name}`, '/help', { senderId: 'owner-a' }));
assert.match(sent.at(-1), /\/help/, `${name} owner bypasses a failed policy read`);
assert.deepEqual(asks, [], `${name} owner command remains local`);
policyReadFails = false;
settings = accessPolicy();
await bridge.accept(message(`access-blocked-${name}`, 'replayed after policy update'));
assert.deepEqual(asks, [], `${name} a denied replay cannot bypass the new policy`);
await bridge.accept(message(`access-ordinary-${name}`, 'allowed ordinary message'));
assert.equal(asks.length, 1, `${name} applies the live policy to a new event`);
assert.equal(sent.at(-1), `${name} allowed reply`, `${name} keeps the normal reply path`);
await bridge.accept(message(`access-command-denied-${name}`, '/new'));
assert.equal(asks.length, 1, `${name} denied command never reaches Harness`);
assert.equal(sessionClears, 0, `${name} denied command has no command side effect`);
assert.equal(sent.at(-1), COMMAND_PERMISSION_DENIED_MESSAGE, `${name} explains command denial`);
settings = accessPolicy({ canExecuteCommands: true });
await bridge.accept(message(`access-command-allowed-${name}`, '/new'));
assert.equal(sessionClears, 1, `${name} policy hot-update applies without rebuilding the bridge`);
assert.equal(asks.length, 1, `${name} allowed local command is not a model prompt`);
}
});
test('runtime abort clears a queued interaction reply reaction instead of marking success', async () => {
const fixture = stateFixture();
const controller = new AbortController();

View file

@ -1053,6 +1053,78 @@ class FakeSocket {
}
}
test('Slack runtime forwards the live access policy provider into its shared bridge', async () => {
let socket;
let stateWrites = 0;
const runtime = new SlackRuntime({
config: {
botId: 'slack_access',
platformId: 'T12345678:U12345678',
name: 'DeepSeek Harness',
},
botToken: BOT_TOKEN,
appToken: APP_TOKEN,
harness: { ensureRunning: async () => true },
state: {
hasSeen: () => false,
markSeen: async () => { stateWrites += 1; },
},
accessPolicy: {
getSettings: () => ({
direct: {
mode: 'allowlist',
open: { defaultCanExecuteCommands: false, commandPermissionOverrides: [] },
allowlist: { users: [] },
},
group: {
mode: 'open',
open: { defaultCanExecuteCommands: true, commandPermissionOverrides: [] },
allowlist: { users: [] },
},
}),
},
createApi: () => ({
authTest: async () => ({ team_id: 'T12345678', user_id: 'U12345678' }),
openConnection: async () => ({ url: 'wss://wss-primary.slack.com/link/?ticket=test' }),
}),
createWebSocket: () => {
socket = new FakeSocket();
queueMicrotask(() => socket.emit('message', {
data: JSON.stringify({
type: 'hello',
connection_info: { app_id: 'A12345678' },
}),
}));
return socket;
},
logger: { warn() {}, error(...args) { assert.fail(args.join(' ')); } },
});
try {
await runtime.start();
socket.emit('message', {
data: JSON.stringify({
envelope_id: 'env-denied',
type: 'events_api',
payload: {
type: 'event_callback',
api_app_id: 'A12345678',
event_id: 'Ev-denied',
event: {
type: 'message', channel_type: 'im', channel: 'D12345678',
user: 'U00000000', ts: '1700000000.009', text: 'must stay local',
},
},
}),
});
await eventually(() => runtime.status.messagesRejected === 1);
assert.equal(stateWrites, 1, 'the denial is recorded only for replay suppression');
assert.deepEqual(socket.sent.at(-1), { envelope_id: 'env-denied' });
} finally {
await runtime.stop();
}
});
test('Slack runtime opens Socket Mode, acknowledges envelopes, and becomes ready', async () => {
let socket;
const abortMark = deferred();

View file

@ -4,16 +4,12 @@ import test from 'node:test';
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import TestRenderer from 'react-test-renderer';
import {
TelegramAccessSettings,
TelegramAccountCard,
TelegramSettingsTab,
} from '../../../plugin-src/client/channels/telegram/index.js';
const { act } = TestRenderer;
test('Telegram settings exposes a Bot Token action without a fake QR action', () => {
const markup = renderToStaticMarkup(React.createElement(TelegramSettingsTab, {
rpcCall: async () => ({ ok: true, value: { bots: [] } }),
@ -44,125 +40,16 @@ test('Telegram account card matches the unified compact card layout', () => {
assert.doesNotMatch(markup, /Bot API 长轮询|消息通道|dim-botMetric/);
assert.match(markup, />检查连接</);
assert.match(markup, />移除接入</);
assert.match(markup, />访问设置</);
assert.match(markup, /aria-label="Telegram 访问模式"/);
assert.match(markup, />兼容模式(默认)</);
assert.match(markup, /aria-label="更多机器人设置"/);
assert.doesNotMatch(markup, /Telegram 访问模式|兼容模式(默认)|安全模式(私聊白名单)/);
assert.doesNotMatch(markup, /dim-cardSummary/);
});
test('Telegram access settings edits and saves one bot policy', async () => {
const saved = [];
let renderer;
await act(async () => {
renderer = TestRenderer.create(React.createElement(TelegramAccessSettings, {
account: {
botId: 'telegram_test',
accessPolicy: { accessMode: 'compatible', allowedUsers: ['111111111'] },
},
onSave: async (policy) => saved.push(policy),
}));
});
const select = renderer.root.findByProps({ 'aria-label': 'Telegram 访问模式' });
let textarea = renderer.root.findByProps({
'aria-label': '允许私聊的 Telegram User ID',
});
assert.equal(textarea.props.disabled, true);
await act(async () => {
select.props.onChange({ target: { value: 'private-allowlist' } });
});
textarea = renderer.root.findByProps({
'aria-label': '允许私聊的 Telegram User ID',
});
assert.equal(textarea.props.disabled, false);
await act(async () => {
textarea.props.onChange({ target: { value: '6087707998\n1202499116\n6087707998' } });
});
await act(async () => {
select.props.onChange({ target: { value: 'compatible' } });
});
textarea = renderer.root.findByProps({
'aria-label': '允许私聊的 Telegram User ID',
});
assert.equal(textarea.props.disabled, true);
assert.equal(textarea.props.value, '6087707998\n1202499116\n6087707998');
await act(async () => {
select.props.onChange({ target: { value: 'private-allowlist' } });
});
textarea = renderer.root.findByProps({
'aria-label': '允许私聊的 Telegram User ID',
});
assert.equal(textarea.props.disabled, false);
assert.equal(textarea.props.value, '6087707998\n1202499116\n6087707998');
assert.deepEqual(
renderer.root.findByProps({ className: 'dtg-accessBadge' }).children,
['已生效:兼容模式'],
);
await act(async () => {
await renderer.root.findByType('form').props.onSubmit({ preventDefault() {} });
});
assert.deepEqual(saved, [{
accessMode: 'private-allowlist',
allowedUsers: ['6087707998', '1202499116'],
}]);
await act(async () => renderer.unmount());
});
test('Telegram access settings keeps both mode descriptions in an accessible help tooltip', async () => {
let renderer;
await act(async () => {
renderer = TestRenderer.create(React.createElement(TelegramAccessSettings, {
account: {
botId: 'telegram_test',
accessPolicy: { accessMode: 'compatible', allowedUsers: ['111111111'] },
},
onSave() {},
}));
});
const helpButton = renderer.root.findByProps({
'aria-label': '查看 Telegram 访问模式说明',
});
const tooltip = renderer.root.findByProps({ role: 'tooltip' });
const heading = renderer.root.findByProps({ className: 'dtg-accessHeading' });
assert.equal(helpButton.props.type, 'button');
assert.ok(tooltip.props.id);
assert.equal(helpButton.props['aria-describedby'], tooltip.props.id);
assert.equal(heading.findAllByType('p').length, 0);
const markup = renderToStaticMarkup(React.createElement(TelegramAccessSettings, {
account: {
botId: 'telegram_test',
accessPolicy: { accessMode: 'compatible', allowedUsers: ['111111111'] },
},
onSave() {},
}));
assert.match(markup, />兼容模式<\/strong>/);
assert.match(markup, />安全模式<\/strong>/);
assert.match(markup, /保持原有行为:私聊直接响应,群聊在被提及或回复时响应。/);
assert.match(markup, /群聊全部忽略,私聊仅允许白名单用户。/);
await act(async () => renderer.unmount());
});
test('Telegram access mode help opens for pointer hover and keyboard focus', async () => {
const styles = await readFile(
new URL('../../../plugin-src/client/channels/telegram/styles.js', import.meta.url),
test('Telegram cards shrink to a narrow English panel without horizontal scrolling', async () => {
const sharedStyles = await readFile(
new URL('../../../plugin-src/client/styles.js', import.meta.url),
'utf8',
);
assert.match(styles, /\.dtg-accessHeading \{[^}]*position: relative;/);
assert.match(styles, /\.dtg-accessHelp \{[^}]*position: static;/);
assert.match(styles, /\.dtg-accessTooltip \{[^}]*right: 0;[^}]*width: min\(300px, 100%\);[^}]*max-width: 100%;/);
assert.match(styles, /\.dtg-accessHelpButton:focus-visible \{/);
assert.match(styles, /\.dtg-accessHelp:hover \.dtg-accessTooltip, \.dtg-accessHelp:focus-within \.dtg-accessTooltip \{[^}]*opacity: 1;[^}]*visibility: visible;/);
});
test('Telegram cards shrink to a narrow English panel without horizontal scrolling', async () => {
const [sharedStyles, telegramStyles] = await Promise.all([
readFile(new URL('../../../plugin-src/client/styles.js', import.meta.url), 'utf8'),
readFile(
new URL('../../../plugin-src/client/channels/telegram/styles.js', import.meta.url),
'utf8',
),
]);
assert.match(sharedStyles, /\.dim-panel \.dim-botList \{[^}]*grid-template-columns: minmax\(0, 1fr\);/);
assert.match(sharedStyles, /\.dim-panel \.dim-botCard \{[^}]*min-width: 0;[^}]*width: 100%;[^}]*max-width: 100%;[^}]*overflow: hidden;/);
@ -170,19 +57,4 @@ test('Telegram cards shrink to a narrow English panel without horizontal scrolli
assert.doesNotMatch(sharedStyles, /\.dim-panel \.dim-botCardTop \{ flex-direction: column;/);
assert.match(sharedStyles, /\.dim-panel \.dim-workspacePath \{[^}]*overflow: hidden;[^}]*overflow-wrap: anywhere;[^}]*white-space: normal;/);
assert.doesNotMatch(sharedStyles, /\.dim-panel \.dim-workspacePath \{[^}]*overflow-x: auto;/);
assert.match(telegramStyles, /\.dtg-access \{[^}]*min-width: 0;[^}]*width: 100%;[^}]*max-width: 100%;/);
assert.match(telegramStyles, /\.dtg-accessHeading \{[^}]*flex-wrap: wrap;/);
assert.match(telegramStyles, /\.dtg-accessStatus \{[^}]*max-width: 100%;[^}]*flex-wrap: wrap;/);
assert.match(telegramStyles, /\.dtg-accessField select, \.dtg-accessField textarea \{[^}]*min-width: 0;[^}]*max-width: 100%;/);
});
test('Telegram access settings warns when safe mode has an empty allowlist', () => {
const markup = renderToStaticMarkup(React.createElement(TelegramAccessSettings, {
account: {
botId: 'telegram_test',
accessPolicy: { accessMode: 'private-allowlist', allowedUsers: [] },
},
onSave() {},
}));
assert.match(markup, /白名单为空;保存后该机器人会拒绝所有入站消息。/);
});

View file

@ -902,7 +902,7 @@ test('Telegram queued policy update cannot persist after controller close begins
assert.equal(configStore.get(botId).allowedUsers, undefined);
});
test('Telegram RPC accepts only token binding and strips credential internals', async () => {
test('Telegram RPC accepts the unified access policy and strips credential internals', async () => {
const calls = [];
const connectionTests = [];
const controller = {
@ -925,7 +925,7 @@ test('Telegram RPC accepts only token binding and strips credential internals',
}),
sendConnectionTest: async (botId) => { connectionTests.push(botId); },
deleteBot: async () => ({ bots: [], totals: { configured: 0, connected: 0 } }),
setAccessPolicy: async (botId, policy) => {
updateAccessPolicy: async (botId, policy) => {
calls.push({ botId, policy });
return {
bots: [{ botId, accessPolicy: policy }],
@ -973,28 +973,35 @@ test('Telegram RPC accepts only token binding and strips credential internals',
code: 'test-target-unavailable',
});
const unifiedPolicy = {
direct: {
mode: 'allowlist',
open: { defaultCanExecuteCommands: false, commandPermissionOverrides: [] },
allowlist: { users: [{ id: '6087707998', canExecuteCommands: true }] },
},
group: {
mode: 'open',
open: { defaultCanExecuteCommands: true, commandPermissionOverrides: [] },
allowlist: { users: [] },
},
};
const access = await handler(TELEGRAM_ENDPOINTS.setAccessPolicy, {
botId: 'telegram_123',
accessMode: TELEGRAM_ACCESS_MODES.privateAllowlist,
allowedUsers: ['6087707998', '6087707998'],
policy: unifiedPolicy,
});
assert.equal(access.ok, true);
assert.deepEqual(calls.at(-1), {
botId: 'telegram_123',
policy: {
accessMode: TELEGRAM_ACCESS_MODES.privateAllowlist,
allowedUsers: ['6087707998'],
},
policy: unifiedPolicy,
});
assert.equal((await handler(TELEGRAM_ENDPOINTS.setAccessPolicy, {
botId: 'telegram_123',
accessMode: TELEGRAM_ACCESS_MODES.privateAllowlist,
allowedUsers: ['@username'],
allowedUsers: ['6087707998'],
})).error.code, 'bad-request');
assert.equal((await handler(TELEGRAM_ENDPOINTS.setAccessPolicy, {
botId: 'telegram_123',
accessMode: TELEGRAM_ACCESS_MODES.compatible,
allowedUsers: [],
policy: unifiedPolicy,
extra: true,
})).error.code, 'bad-request');
});
@ -1009,7 +1016,7 @@ test('shared token RPC never sends a connection test after reconnect is cancelle
reconnectBot: async () => reconnect,
sendConnectionTest: async () => { sendCalls += 1; },
deleteBot: async () => ({ bots: [] }),
setAccessPolicy: async () => ({ bots: [] }),
updateAccessPolicy: async () => ({ bots: [] }),
};
const abort = new AbortController();
const result = createTelegramRpcHandler(controller)(TELEGRAM_ENDPOINTS.reconnectBot, {
@ -1446,7 +1453,7 @@ test('Telegram runtime still starts when the command menu setup fails', async ()
}
});
test('Telegram runtime enforces the selected bot private allowlist', async () => {
test('Telegram runtime enforces the unified direct and group access policy', async () => {
const directory = await mkdtemp(join(tmpdir(), 'dsh-im-telegram-allowlist-runtime-'));
const state = await new TelegramStateStore(join(directory, 'state.json')).load();
const asked = [];
@ -1508,8 +1515,10 @@ test('Telegram runtime enforces the selected bot private allowlist', async () =>
botId: 'telegram_allowlist',
platformId: '123456789',
username: 'HarnessBot',
// Kept deliberately contradictory: legacy fields are migration input,
// not a second active Runtime gate after unified policy injection.
accessMode: TELEGRAM_ACCESS_MODES.privateAllowlist,
allowedUsers: ['7'],
allowedUsers: ['999'],
},
token: TOKEN,
harness: {
@ -1521,6 +1530,20 @@ test('Telegram runtime enforces the selected bot private allowlist', async () =>
},
},
state,
accessPolicy: {
getSettings: () => ({
direct: {
mode: 'allowlist',
open: { defaultCanExecuteCommands: false, commandPermissionOverrides: [] },
allowlist: { users: [{ id: '7', canExecuteCommands: true }] },
},
group: {
mode: 'allowlist',
open: { defaultCanExecuteCommands: false, commandPermissionOverrides: [] },
allowlist: { users: [] },
},
}),
},
createApi: () => fakeApi,
});

View file

@ -17,6 +17,10 @@ import {
OutboundArtifactRegistry,
createOutboundArtifactTool,
} from '../../../src/channels/shared/semantic/artifact.mjs';
import {
COMMAND_PERMISSION_DENIED_MESSAGE,
directAccessPolicy,
} from '../access-policy-fixture.mjs';
const PNG_1X1 = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
@ -707,6 +711,78 @@ test('Enterprise WeChat exposes native file callbacks through the SDK downloader
}]);
});
test('Enterprise WeChat applies the unified access policy before attachments or Harness work', async () => {
const transport = testClient();
let downloads = 0;
const harnessCalls = [];
const accessPolicy = directAccessPolicy({
users: [{ id: 'member-1', canExecuteCommands: false }],
privilegedIds: ['owner-1'],
});
transport.client.downloadFile = async () => {
downloads += 1;
return { buffer: PNG_1X1, filename: 'blocked.png' };
};
const bridge = new WecomHarnessBridge({
client: transport.client,
generateStreamId: (() => {
let sequence = 0;
return () => `policy-stream-${++sequence}`;
})(),
accessPolicy,
harness: {
sessionExists: async (sessionId) => {
harnessCalls.push(['sessionExists', sessionId]);
return true;
},
ask: async (sessionId, prompt) => {
harnessCalls.push(['ask', sessionId, prompt]);
return '白名单消息已处理';
},
},
state: state(),
});
await bridge.accept(frame({
msgid: 'policy-blocked-image',
from: { userid: 'blocked-1' },
msgtype: 'image',
text: undefined,
image: { url: 'https://wecom.example/blocked', aeskey: 'blocked-key' },
}));
assert.equal(downloads, 0);
assert.deepEqual(harnessCalls, []);
assert.deepEqual(transport.streamed, []);
assert.deepEqual(transport.active, []);
await bridge.accept(frame({
msgid: 'policy-member-text',
text: { content: '普通消息' },
}));
assert.equal(harnessCalls.some(([operation]) => operation === 'ask'), true);
assert.equal(transport.streamed.at(-1).content, streamedAnswer('白名单消息已处理'));
const callsBeforeDeniedCommand = harnessCalls.length;
const repliesBeforeDeniedCommand = transport.streamed.length;
await bridge.accept(frame({
msgid: 'policy-member-command',
text: { content: '/help' },
}));
assert.equal(harnessCalls.length, callsBeforeDeniedCommand);
assert.deepEqual(transport.streamed.slice(repliesBeforeDeniedCommand).map(({ content, finish }) => ({
content,
finish,
})), [{ content: COMMAND_PERMISSION_DENIED_MESSAGE, finish: true }]);
accessPolicy.getSettings().direct.allowlist.users = [];
await bridge.accept(frame({
msgid: 'policy-owner-command',
from: { userid: 'owner-1' },
text: { content: '/help' },
}));
assert.match(transport.streamed.at(-1).content, /\/help/);
});
test('Enterprise WeChat bridge hands its prefetched native file to the current Harness turn', async () => {
const transport = testClient();
const bytes = Buffer.from('wecom-bridge-file');

View file

@ -16,6 +16,10 @@ import {
createOutboundArtifactTool,
releaseOutboundArtifact,
} from '../../../src/channels/shared/semantic/artifact.mjs';
import {
COMMAND_PERMISSION_DENIED_MESSAGE,
directAccessPolicy,
} from '../access-policy-fixture.mjs';
function deferred() {
let resolve;
@ -651,6 +655,68 @@ test('Weixin authorizes the sender before resolving encrypted image references',
assert.equal(asks, 0);
});
test('Weixin applies the unified access policy before attachments or Harness work', async () => {
const fixture = stateFixture();
fixture.sessions.set('p2p:member-user', 'session-member');
let imageExtractions = 0;
const harnessCalls = [];
const sent = [];
const accessPolicy = directAccessPolicy({
users: [{ id: 'member-user', canExecuteCommands: false }],
privilegedIds: ['owner-user'],
});
const bridge = new WeixinHarnessBridge({
api: {
inboundImages: (value) => {
imageExtractions += 1;
return value?.item_list?.some((item) => item?.image_item) ? [{ data: PNG_BYTES }] : [];
},
sendText: async (request) => sent.push(request.text),
},
baseUrl: 'https://ilinkai.weixin.qq.com/',
token: 'host-token',
ownerUserId: 'owner-user',
accessPolicy,
harness: {
sessionExists: async (sessionId) => {
harnessCalls.push(['sessionExists', sessionId]);
return true;
},
ask: async (sessionId, prompt) => {
harnessCalls.push(['ask', sessionId, prompt]);
return '白名单消息已处理';
},
},
state: fixture.state,
});
await bridge.accept(message('policy-blocked-image', '', {
from_user_id: 'blocked-user',
item_list: [{ type: 2, image_item: { media: {} } }],
}));
assert.equal(imageExtractions, 0);
assert.deepEqual(harnessCalls, []);
assert.deepEqual(sent, []);
await bridge.accept(message('policy-member-text', '普通消息', {
from_user_id: 'member-user',
}));
assert.equal(harnessCalls.some(([operation]) => operation === 'ask'), true);
assert.deepEqual(sent, ['白名单消息已处理']);
const callsBeforeDeniedCommand = harnessCalls.length;
const repliesBeforeDeniedCommand = sent.length;
await bridge.accept(message('policy-member-command', '/help', {
from_user_id: 'member-user',
}));
assert.equal(harnessCalls.length, callsBeforeDeniedCommand);
assert.deepEqual(sent.slice(repliesBeforeDeniedCommand), [COMMAND_PERMISSION_DENIED_MESSAGE]);
accessPolicy.getSettings().direct.allowlist.users = [];
await bridge.accept(message('policy-owner-command', '/help'));
assert.match(sent.at(-1), /\/help/);
});
test('Weixin returns a specific retry message when encrypted image loading fails', async () => {
const fixture = stateFixture();
fixture.sessions.set('p2p:owner-user', 'session-image');

View file

@ -10,7 +10,6 @@ import {
EmptyView,
ProvisionView,
QrPanel,
WhatsappAccessSettings,
WhatsappAccountCard,
WhatsappSettingsTab,
} from '../../../plugin-src/client/channels/whatsapp/index.js';
@ -72,79 +71,18 @@ 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, /已绑定账号自己发出的群聊消息/);
assert.match(markup, /aria-label="更多机器人设置"/);
assert.doesNotMatch(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.doesNotMatch(source, /WHATSAPP_ENDPOINTS\.setAccessPolicy/);
assert.match(source, /\[account\.botId\]: '连接检查失败,请稍后重试。'/);
assert.doesNotMatch(source, /连接检查失败:\$\{presentError\(error\)\.message\}/);
});

View file

@ -51,7 +51,10 @@ test('WhatsApp production has no per-bot result-file Gate', async (t) => {
const production = await createProductionController(ctx, { dataDir }, internals);
await controllerOptions.createRuntime({
botId: 'whatsapp_enabled',
config: { botId: 'whatsapp_enabled' },
config: {
botId: 'whatsapp_enabled',
accountJid: '16505550123@s.whatsapp.net',
},
authDir: '00000000-0000-4000-8000-000000000001',
});
await controllerOptions.createRuntime({
@ -62,6 +65,9 @@ test('WhatsApp production has no per-bot result-file Gate', async (t) => {
assert.equal(Object.hasOwn(runtimes[0], 'outboundArtifactsEnabled'), false);
assert.equal(Object.hasOwn(runtimes[1], 'outboundArtifactsEnabled'), false);
assert.equal(runtimes[0].accessPolicy.isPrivileged(['+16505550123'], 'direct'), true,
'production privileged matching uses the same bare-number normalization');
assert.equal(runtimes[0].accessPolicy.isPrivileged(['not-a-jid'], 'direct'), false);
await production.close();
const productionWithDefault = await createProductionController(ctx, { dataDir }, internals);

View file

@ -31,6 +31,7 @@ import {
WhatsappRuntime,
createWhatsappMediaDownloader,
normalizeWhatsappMessage,
whatsappAccessPolicyIdsEqual,
whatsappInboundAllowed,
} from '../../../src/channels/whatsapp/whatsapp-runtime.mjs';
import { createWhatsappWebSession } from '../../../src/channels/whatsapp/whatsapp-web-session.mjs';
@ -146,6 +147,29 @@ function linkedConfig(overrides = {}) {
};
}
test('WhatsApp access-policy equality accepts phone and user-JID aliases and rejects invalid ids', () => {
assert.equal(whatsappAccessPolicyIdsEqual(
'16505550999', '16505550999@s.whatsapp.net',
), true, 'a bare phone number matches its PN JID');
assert.equal(whatsappAccessPolicyIdsEqual(
'+16505550999', '16505550999@s.whatsapp.net',
), true, 'a +number matches its PN JID');
assert.equal(whatsappAccessPolicyIdsEqual(
'16505550999@s.whatsapp.net', '16505550999:4@s.whatsapp.net',
), true, 'full and device-qualified PN JIDs retain Baileys alias matching');
assert.equal(whatsappAccessPolicyIdsEqual(
'987654321098765@lid', '987654321098765@s.whatsapp.net',
), true, 'PN and LID aliases retain Baileys user matching');
assert.equal(whatsappAccessPolicyIdsEqual(
'16505550999', '16505550888@s.whatsapp.net',
), false);
for (const invalid of [undefined, null, '', 'not-a-jid', 'bad@', '@lid', '+']) {
assert.equal(whatsappAccessPolicyIdsEqual(invalid, invalid), false,
`invalid id must fail closed: ${String(invalid)}`);
assert.equal(whatsappAccessPolicyIdsEqual(invalid, ACCOUNT_JID), false);
}
});
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');
@ -616,8 +640,20 @@ test('WhatsApp keeps native and document images as images and exposes ordinary d
});
});
test('WhatsApp runtime filters messages before the bridge and applies policy updates live', async () => {
test('WhatsApp runtime uses live unified policy settings and existing JID alias matching', async () => {
let callbacks;
let accessSettings = {
direct: {
mode: 'allowlist',
open: { defaultCanExecuteCommands: false, commandPermissionOverrides: [] },
allowlist: { users: [] },
},
group: {
mode: 'allowlist',
open: { defaultCanExecuteCommands: false, commandPermissionOverrides: [] },
allowlist: { users: [] },
},
};
const calls = [];
const socket = {
sendPresenceUpdate: async (...args) => calls.push(['presence', ...args]),
@ -643,6 +679,10 @@ test('WhatsApp runtime filters messages before the bridge and applies policy upd
authDir: '/tmp/test-whatsapp-auth',
harness,
state,
accessPolicy: {
getSettings: () => accessSettings,
isPrivileged: (senderIds) => senderIds.includes(ACCOUNT_JID),
},
createSession: async (options) => {
callbacks = options;
return {
@ -661,13 +701,38 @@ test('WhatsApp runtime filters messages before the bridge and applies policy upd
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 },
key: { remoteJid: ACCOUNT_JID, id: 'owner-1', fromMe: true },
message: { conversation: 'owner bypass' },
});
assert.ok(calls.some((call) => call[0] === 'message'
&& call[2].text === 'Harness answer'), 'linked owner bypasses an empty allowlist');
accessSettings = {
...accessSettings,
direct: {
mode: 'allowlist',
open: { defaultCanExecuteCommands: false, commandPermissionOverrides: [] },
allowlist: {
users: [{ id: '16505550999', canExecuteCommands: true }],
},
},
};
const answerCountBeforeAlternate = calls.filter((call) => (
call[0] === 'message' && call[2].text === 'Harness answer'
)).length;
await callbacks.onMessage({
key: {
remoteJid: '987654321098765@lid',
remoteJidAlt: '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'));
assert.equal(calls.filter((call) => (
call[0] === 'message' && call[2].text === 'Harness answer'
)).length, answerCountBeforeAlternate + 1,
'a bare allowlist number matches the PN alternate for an inbound LID');
await runtime.stop();
});
@ -1424,7 +1489,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(),
updateAccessPolicy: async () => snapshot(),
sendConnectionTest: async () => {
sendCalls += 1;
if (sendFailure) throw new Error('private provider failure');
@ -1490,7 +1555,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: [] }),
updateAccessPolicy: async () => ({ bots: [] }),
};
const abort = new AbortController();
const result = createWhatsappRpcHandler(controller)(WHATSAPP_ENDPOINTS.reconnectBot, {
@ -1533,10 +1598,6 @@ 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),
});
@ -1544,6 +1605,17 @@ test('WhatsApp QR controller and RPC keep the raw QR and linked identity host-on
const handler = createWhatsappRpcHandler(controller, {
encodeQr: async () => 'data:image/png;base64,QUJDRA==',
});
controller.updateAccessPolicy = async (botId, policy, projectStatus) => {
appliedPolicies.push(policy);
const current = await controller.status();
const updated = {
...current,
bots: current.bots.map((bot) => bot.botId === botId
? { ...bot, accessPolicy: policy }
: bot),
};
return projectStatus ? projectStatus(updated) : updated;
};
const started = await handler(WHATSAPP_ENDPOINTS.beginProvisioning, {});
assert.equal(started.ok, true);
assert.match(started.value.qrCodeDataUrl, /^data:image\/png/);
@ -1562,20 +1634,30 @@ test('WhatsApp QR controller and RPC keep the raw QR and linked identity host-on
allowedNumbers: [],
});
assert.doesNotMatch(JSON.stringify(status.value), /16505550123@s\.whatsapp\.net|authDirectory/);
const unifiedPolicy = {
direct: {
mode: 'allowlist',
open: { defaultCanExecuteCommands: false, commandPermissionOverrides: [] },
allowlist: {
users: [{
id: '16505550999@s.whatsapp.net',
canExecuteCommands: true,
}],
},
},
group: {
mode: 'allowlist',
open: { defaultCanExecuteCommands: false, commandPermissionOverrides: [] },
allowlist: { users: [] },
},
};
const updated = await handler(WHATSAPP_ENDPOINTS.setAccessPolicy, {
botId: status.value.bots[0].botId,
accessMode: WHATSAPP_ACCESS_MODES.privateAllowlist,
allowedNumbers: ['+16505550999'],
policy: unifiedPolicy,
});
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'],
}]);
assert.deepEqual(updated.value.bots[0].accessPolicy, unifiedPolicy);
assert.deepEqual(appliedPolicies, [unifiedPolicy]);
const invalidPolicy = await handler(WHATSAPP_ENDPOINTS.setAccessPolicy, {
botId: status.value.bots[0].botId,
accessMode: 'compatible',

View file

@ -5,6 +5,10 @@ import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import TestRenderer from 'react-test-renderer';
import {
ACCESS_CHANNEL_DEFINITIONS,
ACCESS_POLICY_ENDPOINT,
} from '../plugin-src/client/access-policy-settings.js';
import {
BOT_SETTINGS_TABS,
DELIVERY_CHANNEL_DEFINITIONS,
@ -40,6 +44,11 @@ function button(root, label) {
return root.findAllByType('button').find((entry) => textOf(entry) === label);
}
function accessHelpButtons(root) {
return root.findAll((entry) => entry.type === 'button'
&& String(entry.props['aria-label'] ?? '').endsWith('查看访问权限说明'));
}
function deferred() {
let resolve;
let reject;
@ -69,10 +78,31 @@ const connectedAccount = Object.freeze({
botId: 'bot_feishu_01',
botName: '通知机器人',
connected: true,
accessPolicy: Object.freeze({
direct: Object.freeze({
mode: 'open',
open: Object.freeze({
defaultCanExecuteCommands: true,
commandPermissionOverrides: Object.freeze([]),
}),
allowlist: Object.freeze({ users: Object.freeze([]) }),
}),
group: Object.freeze({
mode: 'open',
open: Object.freeze({
defaultCanExecuteCommands: false,
commandPermissionOverrides: Object.freeze([]),
}),
allowlist: Object.freeze({ users: Object.freeze([]) }),
}),
}),
});
test('delivery settings define only the nine supported IM channel routes', () => {
assert.deepEqual(BOT_SETTINGS_TABS, [{ id: 'delivery', label: '投递设置' }]);
assert.deepEqual(BOT_SETTINGS_TABS, [
{ id: 'delivery', label: '投递设置' },
{ id: 'access', label: '访问设置' },
]);
assert.equal(DELIVERY_RPC_CHANNEL, '/dsh-im-delivery');
assert.deepEqual(Object.keys(DELIVERY_CHANNEL_DEFINITIONS), [
'weixin', 'feishu', 'dingtalk', 'wecom', 'qq',
@ -91,6 +121,8 @@ test('delivery settings define only the nine supported IM channel routes', () =>
['chatId', 'messageThreadId'],
);
assert.equal('office' in DELIVERY_CHANNEL_DEFINITIONS, false);
assert.deepEqual(Object.keys(ACCESS_CHANNEL_DEFINITIONS), Object.keys(DELIVERY_CHANNEL_DEFINITIONS));
assert.equal(ACCESS_CHANNEL_DEFINITIONS.weixin.groupSupported, false);
});
test('all nine robot cards add one accessible settings gear beside existing content', () => {
@ -147,6 +179,7 @@ test('the card gear opens a bot-scoped page in the current channel panel and ret
state: 'connected',
bot: { name: '微信通知助手', accountIdMasked: 'wx••01' },
health: { status: 'healthy', summary: '微信连接正常', lastCheckedAt: Date.now() },
accessPolicy: connectedAccount.accessPolicy,
};
const deliveryCalls = [];
const renderer = await (async () => {
@ -189,7 +222,9 @@ test('the card gear opens a bot-scoped page in the current channel panel and ret
page.findByProps({ className: 'dim-deliveryHeader' }).findAllByType('h2').length,
0,
);
const settingsTab = page.findByProps({ role: 'tab' });
const settingsTabs = page.findAllByProps({ role: 'tab' });
assert.deepEqual(settingsTabs.map(textOf), ['投递设置', '访问设置']);
const settingsTab = settingsTabs[0];
const settingsPanel = page.findByProps({ role: 'tabpanel' });
assert.equal(textOf(settingsTab), '投递设置');
assert.equal(settingsTab.props['aria-selected'], true);
@ -226,6 +261,266 @@ test('the card gear opens a bot-scoped page in the current channel panel and ret
assert.equal(renderer.root.findByProps({ id: 'dim-tab-weixin' }).props['aria-selected'], true);
});
test('access settings preserve independent mode drafts and save direct and group atomically', async (t) => {
const calls = [];
const renderer = await mount(t, {
channel: 'feishu',
account: connectedAccount,
rpcCall: async (endpoint) => {
assert.equal(endpoint, DELIVERY_ENDPOINTS.list);
return { ok: true, value: { targets: [] } };
},
accessRpcCall: async (endpoint, payload) => {
calls.push({ endpoint, payload });
return {
ok: true,
value: { bots: [{ botId: connectedAccount.botId, accessPolicy: payload.policy }] },
};
},
onBack() {},
});
assert.equal(accessHelpButtons(renderer.root).length, 0);
await act(async () => {
button(renderer.root, '访问设置').props.onClick();
await flush();
});
assert.equal(renderer.root.findAllByProps({ role: 'tab' }).length, 2);
assert.equal(renderer.root.findAllByProps({ className: 'dim-accessScene' }).length, 2);
assert.equal(renderer.root.findAllByProps({ className: 'dim-accessOwnerNotice' }).length, 0);
assert.equal(accessHelpButtons(renderer.root).length, 2);
for (const [scene, title] of [['direct', '私聊'], ['group', '群聊']]) {
const sceneEditor = renderer.root.findByProps({ 'data-scene': scene });
assert.equal(sceneEditor.props['aria-label'], title);
const accessHelpButton = sceneEditor.findByProps({
'aria-label': `${title} 查看访问权限说明`,
});
const accessHelpTooltip = sceneEditor.findByProps({
className: 'dim-channelTooltip dim-accessHelpTooltip',
});
assert.equal(accessHelpTooltip.props.role, 'tooltip');
assert.equal(accessHelpButton.props['aria-describedby'], accessHelpTooltip.props.id);
assert.match(textOf(accessHelpTooltip), /原所有者或扫码接入者始终可以访问并执行命令/);
}
assert.equal(accessHelpButtons(renderer.root.findByProps({ className: 'dim-accessActions' })).length, 0);
assert.equal(accessHelpButtons(renderer.root.findByProps({ role: 'tablist' })).length, 0);
assert.match(
textOf(renderer.root.findByProps({ 'data-scene': 'direct' })),
/命令权限例外/,
);
await act(async () => {
const direct = renderer.root.findByProps({ 'data-scene': 'direct' });
const addUser = direct.findByProps({ 'aria-label': '私聊 新增用户' });
assert.equal(addUser.props.title, '新增用户');
assert.match(addUser.props.className, /dim-accessAddUser/);
assert.equal(textOf(addUser), '+');
addUser.props.onClick();
await flush();
});
await act(async () => {
renderer.root.findByProps({ 'aria-label': '私聊 飞书 Open ID 1' }).props.onChange({
target: { value: ' ou_override ' },
});
renderer.root.findByProps({ 'aria-label': '群聊 默认命令权限' }).props.onChange({
target: { value: 'allow' },
});
await flush();
});
await act(async () => {
renderer.root.findByProps({ 'aria-label': '私聊 访问模式' }).props.onChange({
target: { value: 'allowlist' },
});
await flush();
});
const directAllowlistHelp = renderer.root.findByProps({
'aria-label': '私聊 查看白名单说明',
});
const directAllowlistTooltip = renderer.root.findByProps({
className: 'dim-channelTooltip dim-accessEmptyAllowlistTooltip',
});
assert.equal(directAllowlistTooltip.props.role, 'tooltip');
assert.equal(directAllowlistHelp.props['aria-describedby'], directAllowlistTooltip.props.id);
assert.equal(
textOf(directAllowlistTooltip),
'当前没有白名单用户,保存后普通用户将无法使用机器人。',
);
assert.equal(renderer.root.findAllByProps({ className: 'dim-accessWarning' }).length, 0);
assert.match(
textOf(renderer.root.findByProps({ 'data-scene': 'direct' })),
/白名单用户/,
);
assert.equal(
renderer.root.findAllByProps({ 'aria-label': '私聊 默认命令权限' }).length,
0,
);
await act(async () => {
const direct = renderer.root.findByProps({ 'data-scene': 'direct' });
direct.findByProps({ 'aria-label': '私聊 新增用户' }).props.onClick();
await flush();
});
assert.equal(
renderer.root.findAllByProps({ 'aria-label': '私聊 查看白名单说明' }).length,
0,
);
await act(async () => {
renderer.root.findByProps({ 'aria-label': '群聊 访问模式' }).props.onChange({
target: { value: 'allowlist' },
});
await flush();
});
assert.ok(renderer.root.findByProps({ 'aria-label': '群聊 查看白名单说明' }));
await act(async () => {
renderer.root.findByProps({ 'aria-label': '群聊 访问模式' }).props.onChange({
target: { value: 'open' },
});
await flush();
});
await act(async () => {
renderer.root.findByProps({ 'aria-label': '私聊 飞书 Open ID 1' }).props.onChange({
target: { value: ' ou_allowed ' },
});
await flush();
});
await act(async () => {
renderer.root.findByProps({ 'aria-label': '私聊 用户 1 命令权限' }).props.onChange({
target: { value: 'allow' },
});
await flush();
});
await act(async () => {
renderer.root.findByProps({ 'aria-label': '私聊 访问模式' }).props.onChange({
target: { value: 'open' },
});
await flush();
});
assert.equal(
renderer.root.findByProps({ 'aria-label': '私聊 飞书 Open ID 1' }).props.value,
' ou_override ',
);
assert.equal(
renderer.root.findByProps({ 'aria-label': '私聊 用户 1 命令权限' }).props.value,
'deny',
);
assert.equal(
renderer.root.findByProps({ 'aria-label': '私聊 默认命令权限' }).props.value,
'allow',
);
await act(async () => {
renderer.root.findByProps({ 'aria-label': '私聊 访问模式' }).props.onChange({
target: { value: 'allowlist' },
});
await flush();
});
assert.equal(
renderer.root.findByProps({ 'aria-label': '私聊 飞书 Open ID 1' }).props.value,
' ou_allowed ',
);
assert.equal(
renderer.root.findByProps({ 'aria-label': '私聊 用户 1 命令权限' }).props.value,
'allow',
);
await act(async () => {
renderer.root.findByProps({ className: 'dim-accessPage' }).props.onSubmit({
preventDefault() {},
});
await flush();
});
assert.deepEqual(calls, [{
endpoint: ACCESS_POLICY_ENDPOINT,
payload: {
botId: connectedAccount.botId,
policy: {
direct: {
mode: 'allowlist',
open: {
defaultCanExecuteCommands: true,
commandPermissionOverrides: [{ id: 'ou_override', canExecuteCommands: false }],
},
allowlist: {
users: [{ id: 'ou_allowed', canExecuteCommands: true }],
},
},
group: {
mode: 'open',
open: {
defaultCanExecuteCommands: true,
commandPermissionOverrides: [],
},
allowlist: { users: [] },
},
},
},
}]);
const feedback = renderer.root.findByProps({ className: 'dim-accessFeedback' });
assert.match(textOf(feedback), /访问设置已保存/);
assert.equal(feedback.props['data-tone'], 'success');
assert.equal(feedback.props.role, 'status');
await act(async () => {
button(renderer.root, '投递设置').props.onClick();
await flush();
});
assert.equal(accessHelpButtons(renderer.root).length, 0);
});
test('access settings keep a failed atomic save visible as an error', async (t) => {
const renderer = await mount(t, {
channel: 'feishu',
account: connectedAccount,
rpcCall: async () => ({ ok: true, value: { targets: [] } }),
accessRpcCall: async () => ({
ok: false,
error: { code: 'access-policy-invalid', message: '访问策略未保存。' },
}),
onBack() {},
});
await act(async () => {
button(renderer.root, '访问设置').props.onClick();
await flush();
});
await act(async () => {
renderer.root.findByProps({ className: 'dim-accessPage' }).props.onSubmit({
preventDefault() {},
});
await flush();
});
const feedback = renderer.root.findByProps({ className: 'dim-accessFeedback' });
assert.equal(textOf(feedback), '访问策略未保存。');
assert.equal(feedback.props['data-tone'], 'error');
assert.equal(feedback.props.role, 'alert');
});
test('WeChat keeps the shared access page but disables its unsupported group section', async (t) => {
const renderer = await mount(t, {
channel: 'weixin',
account: { ...connectedAccount, botId: 'wx_access_01' },
rpcCall: async () => ({ ok: true, value: { targets: [] } }),
accessRpcCall: async () => {
throw new Error('save should not run in this rendering test');
},
onBack() {},
});
await act(async () => {
button(renderer.root, '访问设置').props.onClick();
await flush();
});
const group = renderer.root.findByProps({ 'data-scene': 'group' });
assert.equal(group.props.disabled, true);
assert.match(textOf(group), /当前渠道不支持群聊/);
assert.equal(group.findAllByType('select').length, 0);
assert.ok(renderer.root.findByProps({ 'data-scene': 'direct' }));
});
test('new target waits for saved targets before generating aliases or checking duplicates', async (t) => {
const pending = deferred();
const renderer = await mount(t, {
@ -480,7 +775,10 @@ test('recent conversation names remain platform data in the English UI', async (
docsLink.props.href,
'https://github.com/xmanrui/dsh-im/blob/main/PROACTIVE_DELIVERY.en.md',
);
assert.equal(textOf(renderer.root.findByProps({ role: 'tab' })), 'Delivery settings');
assert.deepEqual(
renderer.root.findAllByProps({ role: 'tab' }).map(textOf),
['Delivery settings', 'Access settings'],
);
});
test('target create, edit, copy, and delete use the minimal RPC payloads', async (t) => {

View file

@ -32,6 +32,27 @@ const publicFailure = Object.freeze({
at: Date.UTC(2026, 7, 25, 7, 30),
});
const publicAccessPolicy = Object.freeze({
direct: Object.freeze({
mode: 'allowlist',
open: Object.freeze({
defaultCanExecuteCommands: false,
commandPermissionOverrides: Object.freeze([]),
}),
allowlist: Object.freeze({
users: Object.freeze([{ id: 'user_safe', canExecuteCommands: true }]),
}),
}),
group: Object.freeze({
mode: 'open',
open: Object.freeze({
defaultCanExecuteCommands: false,
commandPermissionOverrides: Object.freeze([]),
}),
allowlist: Object.freeze({ users: Object.freeze([]) }),
}),
});
function rawBot() {
return {
botId: 'bot_safe',
@ -40,6 +61,7 @@ function rawBot() {
state: 'connected',
workspace: '/workspace/current',
groupResponseMode: 'mention',
accessPolicy: publicAccessPolicy,
bot: {
name: 'Harness Bot',
username: 'harness_bot',
@ -107,6 +129,7 @@ test('all channel snapshot normalizers retain the same safe message failure', ()
for (const [channel, normalize] of normalizers) {
const snapshot = normalize({ bots: [rawBot()] });
assert.deepEqual(snapshot.bots[0].lastMessageError, publicFailure, channel);
assert.deepEqual(snapshot.bots[0].accessPolicy, publicAccessPolicy, channel);
assert.doesNotMatch(
JSON.stringify(snapshot.bots[0].lastMessageError),
/providerDetail|private|token/i,

View file

@ -286,9 +286,11 @@ test('all nine production channels use channel presets only as bot creation defa
for (const path of PRODUCTION_FILES) {
const source = await readFile(new URL(`../${path}`, import.meta.url), 'utf8');
assert.doesNotMatch(source, /\bagentPreset:\s*config\.agentPreset/, path);
const creationDefaults = source.match(
/workspaces\.ensure\([^;]*\{\s*defaultAgentPreset:\s*config\.agentPreset,?\s*\}\)/g,
) ?? [];
const creationDefaults = (source.match(
/workspaces\.ensure\((?:(?!workspaces\.ensure)[\s\S])*?\n\s*\}\)/g,
) ?? []).filter((call) => (
/\bdefaultAgentPreset:\s*config\.agentPreset\b/.test(call)
));
assert.equal(
creationDefaults.length,
2,