2 Commits
22 changed files with 917 additions and 290 deletions

No files matched your search

+84 -62
View File
@@ -1,83 +1,105 @@
# 钉钉进度通知参考
# Hub 钉钉通知接入
这里的脚本和配置只演示 Hub 如何向项目群发送辅助通知,不属于 Etunel 的任务通信主链路,也不能代替任务派发、负责人确认、阶段门禁或项目留档。成员 Agent 不调用本脚本。
这套封装只帮助 Hub 向项目群发送进度、异常摘要和负责人提醒,不是 Etunel 任务通道,也不负责审批、阶段门禁或项目留档。仅更新此仓库不会部署到实际项目;由部署负责人明确安装,成员 Agent 不调用。
当前布局刻意把工具和项目配置分开:
## 文件与依赖
- `dingtalk-workspace-cli@1.0.60` 由 Bun 安装在用户级全局目录,项目不保存二进制或 `node_modules`。
- 官方 multi skills 保留在项目 `.agents/skills/dingtalk-*`,只对本项目生效。
- Client ID、群目标、`robotCode` 和汇报开关保存在项目 `.dingtalk/config.env`。
- Client Secret 从环境变量 `DING_SEC` 读取,未设置时回退到 macOS Keychain;脚本只临时注入给 `dws api`,App Token 由 DWS 自动获取和缓存。
- [dingtalk_notify.py](../scripts/dingtalk_notify.py):共用 Python 实现,含配置、消息、人员解析和有限重试。
- [dingtalk-progress](../scripts/dingtalk-progress)/[dingtalk-progress.ps1](../scripts/dingtalk-progress.ps1):项目事件入口。
- [dingtalk-bot-message](../scripts/dingtalk-bot-message)/[dingtalk-bot-message.ps1](../scripts/dingtalk-bot-message.ps1):自定义 Markdown 与负责人提醒入口。
- [离线测试](../scripts/test_dingtalk_notify.py):假配置、假凭据及模拟命令,不发送真实消息。
项目统一通过稳定入口调用全局命令:
依赖 Python 3.9+,不需要第三方 Python 包、jq 或 Bash 才能在 Windows 运行。PowerShell 入口要求 7.3+;也可直接运行 Python。macOS/Linux 保留 Shell 入口。
```sh
./scripts/dws version
```
真实发送需要 [官方 DingTalk Workspace CLI](https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli) 的原生可执行文件。按官方安装说明选择系统版本;Windows 使用 `dws.exe`,不要把 npm/Bun 的 `dws.cmd` 或 `dws.bat` 当原生程序。可以把原生文件加入 Hub 进程的 PATH,或在项目配置的 `DINGTALK_PROGRESS_DWS_PATH` 填它的绝对路径。本仓库不保存二进制,不自动安装、登录或更新全局 Skills。
Bun 默认阻止上游 `postinstall`;本机安装时已校验官方 SHA-256 并只释放 DWS 原生二进制,没有执行会发布全局 Skills 的安装脚本。升级 DWS 时也应保持这一边界。
部署后检查 `python --version`(macOS/Linux 可用 `python3`)及原生 `dws version`。所用 DWS 需支持 `api --data -` 从标准输入读 JSON;正文、群参数不放命令行,密钥仅临时注入子进程环境。更换 DWS 版本后由部署负责人核对兼容性,离线预览不证明真实接口可用。
## 应用身份(无需用户登录)
## 部署到 Hub 项目
本项目不执行 `dws auth login`。进度脚本通过 `dws api` 调用企业内部应用 OpenAPI:
将共用 Python 文件和需要的平台入口一起放入目标项目的 `scripts/`,不要只复制一个入口。重试逻辑已合入共用实现,无需复制源项目的 `dingtalk-dns-retry.sh`。保持下列布局:
```text
POST https://api.dingtalk.com/v1.0/robot/groupMessages/send
目标项目/
├── scripts/
│ ├── dingtalk_notify.py
│ ├── dingtalk-progress
│ ├── dingtalk-bot-message
│ ├── dingtalk-progress.ps1
│ └── dingtalk-bot-message.ps1
└── .dingtalk/
└── config.env
```
管理员侧只需确保:
由部署负责人从 [配置示例](config.env.example) 新建实际配置,别覆盖已有有效配置:
- Client ID/Secret 属于企业内部应用;
- 应用已有“企业内机器人发送消息”权限(`qyapi_robot_sendmsg`);
- `robotCode` 属于同一个应用;
- 机器人已加入目标 `openConversationId` 对应的群。
- UTF-8,可带 BOM,支持 Windows CRLF 和 LF;值按字面解析,不执行 Shell、不展开变量,不加 Shell 引号。
- 初始保持 `ENABLED=0`、`DRY_RUN=1`(实际字段均有 `DINGTALK_PROGRESS_` 前缀)。
- 配置包括应用、机器人、目标群、项目名称、开关及机器人密钥。机器人必须属于配置的应用,已经加入目标群,并具备企业内机器人发送消息权限(`qyapi_robot_sendmsg`);脚本不自动加群或申请权限。
- macOS/Linux 对实际配置执行 `chmod 600 .dingtalk/config.env`;接受 400 或 600。Windows 不以 Unix 权限位作门槛,由部署负责人用 NTFS 权限限制其他用户读取或修改配置。
- 实际配置必须被目标项目的 Git 忽略。不要把它、真实凭据或内部标识回传给 AI 会话或提交仓库。
当前使用 zsh 环境变量时,先确保它已导出(不要把真实值写进项目文件):
路径与当前工作目录无关:配置总从 Python 脚本所在项目根目录读取。消息的 `--file` 路径则相对当前工作目录解析。
## 凭据:所有系统统一读取配置
普通进度发送使用内部应用身份,不需要用户 OAuth 登录。Windows、macOS、Linux **只从本地 `.dingtalk/config.env` 的 `DINGTALK_PROGRESS_CLIENT_SECRET` 读取机器人密钥**,不再从 `DING_SEC`、其他环境变量或 macOS 钥匙串回退取值。
由部署负责人在本地编辑器中填写该字段,保持单行字面值,不加 Shell 引号;不要在会话、命令历史或仓库示例里填写真实值。密钥为空或字段缺失时,真实发送停止并提示负责人补配置;离线预览不要求填写密钥。
脚本仅在发送时把该值临时注入 DWS 子进程的 `DWS_CLIENT_SECRET`,同时注入配置中的 `DWS_CLIENT_ID`。这只是脚本内部对接 DWS 的方式,不需要负责人再配置环境变量。密钥不进入命令参数、消息正文、预览或错误输出;人员搜索进程也不会取得机器人密钥。
实际 `config.env` 现在包含明文密钥:macOS/Linux 设为 400 或 600;Windows 通过文件“属性 → 安全”限制为 Hub 运行账号及必要管理员可访问,不放在公共共享目录。目标项目必须忽略实际配置及其备份,只提交空值示例。不要把配置文件用作 `--file` 正文、成果或通知附件。
### 从旧配置迁移
1. 更新共用 Python 脚本及需要的平台入口。
2. 由部署负责人在本地配置补填 `DINGTALK_PROGRESS_CLIENT_SECRET`;删除旧的 `DINGTALK_PROGRESS_KEYCHAIN_SERVICE` 字段。
3. 检查文件权限与 Git 忽略,再做离线预览;正式发送仍需要启用开关、正确的应用权限和目标群。
4. 旧环境变量与钥匙串不再被脚本读取;本次改动不会删除其中原有内容。
脚本每次调用重新读取本地配置,不再因为密钥配置依赖 Hub 继承某个终端的环境变量;Python、dws 路径及按姓名查询所需的 DWS 登录仍须对实际运行 Hub 的操作系统账号可用。
## 可选的按姓名提醒
`--at` 会调用 DWS 人员搜索,和应用机器人发送是不同的认证需求。只有使用按姓名提醒时,部署负责人需要:
1. 在 DWS 中登录需要使用的组织账号;
2. 将已确认的稳定 profile 填入 `DINGTALK_PROGRESS_PROFILE`;
3. 确认该账号、机器人应用、目标群属于同一组织,且账号允许查询人员。
脚本在搜索和发送中传入同一个 profile,不自动选择或切换账号;应用归属与群归属仍需部署负责人核对,脚本不能仅凭 profile 字符串证明二者一致。查询只接受完整姓名的唯一匹配;查不到、重名、权限不足或返回结构不支持时整条提醒停止,不默认选择第一个,也不静默删掉 @ 后发送。
`--at-id` 和 `--group` 保留源脚本兼容能力,仅用于已有真实映射或用户明确指定的目标;不让 AI 猜编号或在群之间试发。普通进度通知不需要配置人员账号。
## 先做离线预览
在目标项目根目录选一种入口。以下命令只预览,不联网,不需要 dws,不查通讯录、不使用密钥认证。已有配置会在本地读取,但密钥不会输出:
```powershell
python scripts/dingtalk_notify.py progress milestone '本阶段成果已确认并归档,下一步安排联调。' --dry-run
./scripts/dingtalk-bot-message.ps1 --title '需要补充接口说明' --text '请接口负责人补齐返回值说明,并回自己的角色会话同步结果。' --dry-run
```
macOS/Linux 等价命令:
```sh
export DING_SEC='<Client Secret>'
sh scripts/dingtalk-progress milestone '本阶段成果已确认并归档,下一步安排联调。' --dry-run
sh scripts/dingtalk-bot-message --title '需要补充接口说明' --text '请接口负责人补齐返回值说明,并回自己的角色会话同步结果。' --dry-run
```
也可以不设置 `DING_SEC`,改用本项目专用的 macOS Keychain 项。`-w` 必须放在最后,它会在终端中隐藏输入:
两种入口都遵守配置的 `DRY_RUN=1`,显式 `--dry-run` 不能被配置取消。即使没有配置,也能显式预览正文;此时不表示项目配置可用。正文支持中文多行 Markdown;复杂正文建议先保存 UTF-8 文件,用 `--file` 代替内联摘要或 `--text`,它不是附件上传。
```sh
/usr/bin/security add-generic-password \
-U \
-a '<Client ID>' \
-s 'com.example.dingtalk.your-project' \
-l 'Your project DingTalk bot Client Secret' \
-w
确认部署与发送授权后,负责人再将 `ENABLED=1` 且 `DRY_RUN=0`。仓库维护测试不能用真实发送作冒烟测试。实际 Hub 的事件判断、参数示例和结果含义见 [通知规则](../etunel-role-collaboration/references/dingtalk-progress-reporting.md) 和 [脚本用法](../etunel-role-collaboration/references/dingtalk-script-usage.md)。
## 失败处理与测试
仅明确的 DNS 解析失败每隔 10 秒重试,总计最多 3 次;已获有效回执即停止。认证、权限、未知错误不重试;超时或缺少回执不盲目重发。单次 DWS 调用超时 60 秒;本次结束后由 Hub 说明,不在外层循环重试。只输出安全摘要和发送回执,不输出原始请求或错误日志。
本封装不建消息队列、计划任务、重发历史或通知状态库;避免重复事件仍由 Hub 利用已有项目留档判断,不重造 Etunel 软件机制。
```text
python -X utf8 scripts/test_dingtalk_notify.py
```
`dws auth status` 显示未登录是正常状态,与 App Token 发送链路无关。
## 配置进度群
```sh
cp .dingtalk/config.env.example .dingtalk/config.env
chmod 600 .dingtalk/config.env
```
填写以下非密钥参数:
```dotenv
DINGTALK_PROGRESS_MODE=app-bot
DINGTALK_PROGRESS_CLIENT_ID=<Client ID>
DINGTALK_PROGRESS_TARGET=<openConversationId>
DINGTALK_PROGRESS_ROBOT_CODE=<robotCode>
```
先保持 `DINGTALK_PROGRESS_ENABLED=0`。凭据和参数就绪后改为 `1`,并保持 `DINGTALK_PROGRESS_DRY_RUN=1` 做无网络、无发送的请求预览:
```sh
./scripts/dingtalk-progress milestone "**已完成**
- 项目资料已经归档
- 本阶段成果已经负责人确认
**下一步**
- Hub 将安排下一阶段任务"
```
确认请求中的目标和正文正确后,再把 `DINGTALK_PROGRESS_DRY_RUN=0`。真实发送由 Hub 按根目录 `AGENTS.md` 的时机调用;响应中的 `processQueryKey` 才表示服务端已经接受该条机器人消息。摘要可以使用多行 Markdown,重点写已经确认的结果、下一步或阻塞原因,不要粘贴大段日志。
测试仅在临时目录使用假配置、假密钥和模拟 DWS;同时检查当前系统可用的 PowerShell/Shell 入口,不访问真实项目配置。覆盖配置密钥为唯一来源、密钥不回显、不通过命令参数传递以及预览不认证,不等于完成真实钉钉发送验收。
+13 -7
View File
@@ -1,19 +1,25 @@
# Copy this file to .dingtalk/config.env, fill the selected mode, then chmod 600 it.
# Values are literal text after '='; do not add shell quotes.
# 由部署负责人复制为 .dingtalk/config.env;macOS/Linux 设置 chmod 600。
# Windows 用当前 Hub 运行用户的文件权限,不套用 Unix 权限检查。
# UTF-8(可带 BOM),支持 LF/CRLF;等号右侧是字面值,不加 Shell 引号,不执行变量。
# Keep both switches safe until credential setup and a dry-run have succeeded.
# 两个入口共用开关;显式 --dry-run 也永远不发送。
DINGTALK_PROGRESS_ENABLED=0
DINGTALK_PROGRESS_DRY_RUN=1
# Enterprise-internal app bot: App Token via dws api; no user OAuth login.
# 企业内部应用机器人:发送本身不需要用户 OAuth 登录。
DINGTALK_PROGRESS_MODE=app-bot
DINGTALK_PROGRESS_CLIENT_ID=
# 仅在本地 config.env 填真实 Client Secret;本示例必须保持空值,绝不提交真实配置。
DINGTALK_PROGRESS_CLIENT_SECRET=
DINGTALK_PROGRESS_TARGET=
DINGTALK_PROGRESS_ROBOT_CODE=
DINGTALK_PROGRESS_KEYCHAIN_SERVICE=com.example.dingtalk.your-project
# Client Secret is never stored here. The script first reads exported DING_SEC,
# then falls back to the project-specific macOS Keychain item above.
# 所有系统统一从 config.env 取密钥,不再读取 DING_SEC 或 macOS 钥匙串。
# 可选:原生 dws 的绝对路径(Windows 填 dws.exe,不填 cmd/bat 包装器)。
DINGTALK_PROGRESS_DWS_PATH=
# 可选:按姓名 @ 时必需。由部署负责人确认与应用、目标群同组织的已登录 profile。
# 普通进度通知可留空;不是 Etunel 人员或会话编号。
DINGTALK_PROGRESS_PROFILE=
DINGTALK_PROGRESS_PROJECT=你的项目名称
DINGTALK_PROGRESS_TITLE_PREFIX=[项目进度]
+6 -1
View File
@@ -1,5 +1,8 @@
* text=auto
.gitattributes text eol=lf
.gitignore text eol=lf
.dingtalk/config.env.example text eol=lf
.dingtalk/config.env
*.md text eol=lf
*.txt text eol=lf
*.html text eol=lf
@@ -8,4 +11,6 @@
*.json text eol=lf
*.py text eol=lf
*.sh text eol=lf
*.ps1 text eol=lf
scripts/dingtalk-progress text eol=lf
scripts/dingtalk-bot-message text eol=lf
+2
View File
@@ -1,5 +1,7 @@
# Local credentials and machine-specific configuration
.dingtalk/config.env
.dingtalk/config.env.*
!.dingtalk/config.env.example
**/.env
**/.env.*
!**/.env.example
+17 -6
View File
@@ -9,7 +9,7 @@
- `doc/`:当前流程设计基线和模板参考资料;
- 钉钉参考集成:仅帮助 Hub 理解如何调用辅助通知,不属于 Etunel 任务通信主链路。
这是以 Markdown、HTML 和 Shell 为主的约束仓库,没有需要猜测的安装或构建步骤。
这是以 Markdown、HTML 为主、附带 Python 和平台启动脚本的约束仓库,没有需要猜测的应用构建步骤。
## Scope
@@ -49,7 +49,7 @@
| `etunel-role-hook-contexts/` | 八角色 Hook 的完整精简职责与每轮提醒 | 独立于 Skill 发现树;简短、可直接注入、与正式职责一致 |
| `doc/` | 当前流程来源及模板参考 | 固定路径维护一份当前文档;不建日期或版本副本,历史查 Git;变更同步核对链接与基线说明 |
| `.agents/skills/dingtalk-*` | 钉钉官方能力的项目内参考副本 | 不是 Etunel 核心 Skill;除非任务明确要求更新参考包,否则不要批量改动 |
| `.dingtalk/`、`scripts/dingtalk-progress` | Hub 通知接入说明、示例配置和当前演示封装 | 不把通知能力扩大为任务派发、审批、门禁或事实源 |
| `.dingtalk/`、`scripts/` 中的钉钉封装 | Hub 通知配置说明、跨平台入口、共用 Python 实现和离线测试 | 不把通知能力扩大为任务派发、审批、门禁或事实源 |
修改共享流程、角色边界、阶段、正式成果或跨角色信息流时,必须同步检查根 Skill、相关公共 reference、受影响角色 reference 和对应 Hook。角色细节优先下沉到角色文件;测试专项优先下沉到测试二级 reference。不要靠复制整段规则维持一致性。
@@ -61,10 +61,10 @@
- 只有 Hub/主 Agent 可以调用通知;成员 Agent 只向 Hub 返回成果、状态或阻塞。
- 通知是项目群的单向辅助可见性与异常提醒,不是角色间通信、任务派发、审批、人类确认、项目门禁或项目事实源。
- Agent 只能使用 `scripts/dingtalk-progress`,事件类型限定为 `start`、`milestone`、`blocked`、`complete` 或 `failed`;不得绕过脚本直接调用 OpenAPI 或 `dws api`。可验证里程碑先完成项目留档,再发送简短中文 Markdown。例如:
- Agent 只用 `scripts/dingtalk_notify.py` 的 `progress`/`message`,或等价的 `dingtalk-progress`/`dingtalk-bot-message` Shell、PowerShell 入口。进度事件仍限定为 `start`、`milestone`、`blocked`、`complete`、`failed`;负责人提醒不新增状态。同一事项不重复通知,不得绕过封装直接调用 OpenAPI 或 `dws api`。可验证里程碑先完成项目留档,再发送简短中文 Markdown。例如:
```sh
./scripts/dingtalk-progress milestone "**已完成**
python scripts/dingtalk_notify.py progress milestone "**已完成**
- 阶段成果已确认并归档
@@ -73,7 +73,7 @@
- Hub 安排后续任务"
```
- 事件语义与消息格式以 [dingtalk-progress-reporting.md](etunel-role-collaboration/references/dingtalk-progress-reporting.md) 为准;安装与本机配置参考 [.dingtalk/README.md](.dingtalk/README.md)。
- `scripts/dingtalk-progress` 是当前用于跑通通知链路的演示封装,不得据此反推或改变 Etunel 主流程。
- 通知封装是辅助接入参考,不得据此反推或改变 Etunel 主流程;不创建通知队列或外层重试。仅明确 DNS 失败最多 3 次、间隔 10 秒,结果未知不盲目重发。
- 仓库维护和测试期间不得把真实发送当作冒烟测试。只有任务明确要求发送且当前环境已经授权时,才允许触发真实通知。
- 不读取、输出、修改或提交 `.dingtalk/config.env`、Client Secret、`DING_SEC`、App Token 或其他凭据和内部标识。
@@ -87,13 +87,24 @@
- 不把模拟流程结果描述为真实发布、客户验收或生产就绪;不适用项对人统一写“不适用”,只在机器字段要求时使用对应枚举。
- 文本文件保持 LF;不要提交日志、缓存、临时文件、机器专属配置或凭据。
## 版本与发布
- 全仓库统一使用三位版本 `vX.Y.Z`:前两位 `X.Y` 跟随 `doc/Codex多角色项目推进总流程图.html` 内明确标注且已确认的流程版本;第三位 `Z` 是该流程版本下的仓库发布迭代号。Git 标签与发布包确定该版 Skill、Hook、脚本和文档,组件不另设发布序列。
- HTML 主流程版本不迭代时,所有变更均只递增第三位,包括 Skill/Hook 优化、脚本新增、修复、配置调整和文档更新;不按功能规模或兼容性自行提升前两位。流程保持 V1.0 时依次发布 `v1.0.0`、`v1.0.1`、`v1.0.2`。HTML 流程正式升到新的 `X.Y` 时,仓库前两位同步,第三位从 0 开始,例如流程 V1.1 对应 `v1.1.0`;不为凑发布号改流程图。
- 这是流程版本驱动的项目约定,不采用“新增功能就升次版本”的语义化版本规则;第三位更新也可能需要配置迁移,发布说明必须如实列出。涉及职责或门禁变化仍须先按项目约束确认流程,不能借版本编号规则跳过确认。
- 日常提交不必发版。发版前确认版本号、完成适用校验,说明流程基线、变更内容、需更新的 Skill/Hook 和在途项目影响;具体说明见 [README 版本与升级](README.md#版本与升级)。
- 仅在用户要求时提交、推送或发版;已发布标签不可覆盖或移动,修正另发新版本。推送后核验远程分支与标签提交,不能把本地提交当成已发布。
- 发布不等于部署。仅更新受影响的 Skill/Hook 配置,插件缓存更新及在途项目迁移仍须明确确认;不要求角色每轮核对发布号,不将仓库发版规则塞进运行时 Skill。
## Quick Commands
修改后按影响范围执行:
```powershell
rtk python -X utf8 "$env:USERPROFILE\.codex\skills\.system\skill-creator\scripts\quick_validate.py" "etunel-role-collaboration"
rtk python -X utf8 scripts/test_dingtalk_notify.py
rtk "C:\Program Files\Git\bin\bash.exe" -n scripts/dingtalk-progress
rtk "C:\Program Files\Git\bin\bash.exe" -n scripts/dingtalk-bot-message
rtk proxy git diff --check
rtk proxy git status --short
```
@@ -103,7 +114,7 @@ Skill Creator 的验证脚本位置取决于协作者的 Codex 安装。若默
## Definition of Done
- 修改 Skill 时已通过 Skill Creator 的结构校验。
- 仅在修改 `scripts/dingtalk-progress` 时执行 Shell 语法检查。
- 修改钉钉封装时运行离线测试;修改 Shell 入口时检查语法,修改 PowerShell 入口时核验解析和实际参数传递。只使用隔离假配置,不触发真实通知。
- 所有新增或改动的 Markdown 相对链接均能解析。
- 文件中没有待办标记、占位内容、真实凭据、群标识或误导性的缺失文件引用。
- 根 Skill、公共 reference、角色 reference 和对应 Hook 在受影响规则上保持一致。
+49 -7
View File
@@ -2,7 +2,7 @@
本仓库为 Etunel 多 Agent 项目提供一套中文版、渐进式披露的角色职责与协作流程:项目Hub负责跨角色中继和全局推进,七个成员角色在各自真人负责人参与下完成专业任务,并以可追踪成果交回 Hub。
> **钉钉定位说明:** 仓库中的 `.agents/skills/dingtalk-*`、`.dingtalk/` 和 `scripts/dingtalk-progress` 只作为参考与演示,用来告诉 Hub 如何调用钉钉发送项目进度或异常通知。钉钉不是 Etunel 的角色通信链路、任务队列、审批、门禁或项目事实源,也不会改变任何角色边界。
> **钉钉定位说明:** 仓库中的 `.agents/skills/dingtalk-*`、`.dingtalk/` 和 `scripts/` 下的钉钉封装只作为接入参考,用来告诉 Hub 如何发送项目进度、异常摘要或负责人提醒。钉钉不是 Etunel 的角色通信链路、任务队列、审批、门禁或项目事实源,也不会改变任何角色边界。
## 先了解这三件事
@@ -10,7 +10,36 @@
2. [Hook 上下文](etunel-role-hook-contexts/) 是给 Etunel 单独配置的精简提醒,不属于 Skill 的渐进式发现树,也不应替代完整职责文件。
3. 当前设计基线为 [V1.0 总流程图](doc/Codex多角色项目推进总流程图.html)(文档生效日期 2026-09-08)。流程文档只维护一份,后续直接更新同一文件,旧版通过 Git 历史追溯,不再按日期或版本另存。仓库更新不代表部署 Skill/Hook 或自动迁移在途项目;运行时规则已整理进稳定 reference,不要求目标项目持有流程 HTML。
仓库没有应用安装、编译或发布流程。主要工作是维护角色约束、流程文档、Hook 上下文以及 Hub 通知参考集成。
仓库没有应用安装或编译步骤;发布对象是职责 Skill、Hook 上下文及配套文档,不是设备固件或运行中的 Etunel 服务。
## 版本与升级
本版为 `v1.0.2`,流程设计基线仍为 **V1.0**。版本采用 `vX.Y.Z`:前两位 `X.Y` 跟随 HTML 主流程中明确标注且已确认的版本,第三位 `Z` 表示该流程版本下的仓库发布迭代。正式发布以 Git 标签和对应发布包为准,主分支可能包含尚未发布的修改。
| 变更 | 版本规则 |
| --- | --- |
| HTML 流程版本不变,更新 Skill、Hook、脚本、配置或说明等任意内容 | 只递增第三位,例如流程保持 V1.0,`v1.0.1 → v1.0.2` |
| HTML 流程正式更新到新的 X.Y | 前两位同步,第三位从 0 开始,例如流程 V1.1 对应 `v1.1.0` |
| 日常提交,尚未安排发布 | 不必为每次提交创建标签;下一次正式发版按上述规则编号 |
全仓库只维护这一套发布序列,不给 Skill、Hook 或脚本分别编号。前两位不按功能规模、变更文件数或兼容性自行调整,也不为新增脚本等非流程更新修改 HTML 版本。这不是按变更性质升主/次版本的语义化版本规则,第三位更新也可能需要配置迁移;职责或门禁变更仍须先确认流程。日常修改正常提交,需要交给团队使用时才统一校验、确定版本、创建 Git 标签及发布说明;已发布标签和内容不覆盖,修正发新版本。
发布说明应写清:流程基线、改了什么、使用者需要更新哪些文件、在途项目是否需要重新确认。不要把“仓库已发布”写成“角色已更新”。更新插件缓存、Etunel 中独立配置的 Hook 或迁移在途项目,都需另外明确确认;已运行项目继续使用其登记基线。
### v1.0.2 更新范围
- 明确前两位跟随 HTML 流程、其余发布迭代只升第三位的版本约定;主流程仍为 V1.0,八角色及阶段门禁不变。
- 钉钉通知改为共用 Python 实现,保留 macOS/Linux 入口,新增 Windows PowerShell 入口;支持项目事件、自定义中文 Markdown、文件正文和按姓名提醒负责人。
- Windows、macOS、Linux 统一从本地 `config.env` 的 `DINGTALK_PROGRESS_CLIENT_SECRET` 取机器人密钥,不再读取环境变量或钥匙串。**升级时须由部署负责人补填本地密钥字段,并删除旧 `DINGTALK_PROGRESS_KEYCHAIN_SERVICE` 字段**;不提交真实配置。
- 两个入口统一遵守离线预览;仅明确 DNS 失败每隔 10 秒重试、总计最多 3 次,结果未知不盲目重发。补充 27 项离线测试与部署说明。
- 更新核心 Skill 的通知路由与细则,以及中心 Hook 的完整职责和每轮提醒;其他角色 Hook 无需因本次通知更新而替换。目标项目需同步共用 Python 文件及所需平台入口,安装 Python 3.9+ 与原生 DWS;PowerShell 入口需 7.3+。
- Windows 普通群通知已获服务端回执并经负责人确认显示成功;按姓名 @ 和原生 macOS 发送仍未实测。通知升级不自动迁移在途项目或更新 Etunel Hook/插件缓存,需按部署安排明确执行。
### v1.0.1 更新范围
- 根据一轮项目复盘,补清旧任务收尾、开工输入与本次产出的区分、资料异常交专业角色核实、查询无新线索时转交负责人决定,以及模拟不默认跳过交接。
- 更新核心 Skill;Hook 仅调整中心与产品两份,保持完整职责 JSON 和每轮提醒分开配置。产品 Hook 修改的是完整职责区块,中心两个区块均有调整。
- 八角色、八阶段、正式成果、原有审批与测试验收条件不变;不自动迁移在途项目、不关闭真实遗留问题。本版不涉及钉钉脚本更新。
## 协作模型
@@ -80,6 +109,7 @@ EP-Hub-Skill/
│ ├── human-readable-communication.md
│ ├── project-files-and-progress.md
│ ├── dingtalk-progress-reporting.md
│ ├── dingtalk-script-usage.md # 通知的跨平台调用与负责人提醒
│ ├── roles/ # 七个成员角色职责
│ └── testing/ # 测试任务按需读取的二级细则
├── etunel-role-hook-contexts/ # 八角色独立 Hook 源文件
@@ -88,7 +118,13 @@ EP-Hub-Skill/
│ └── 文档模板参考/ # 按任务读取的参考笔记与来源清单
├── .agents/skills/dingtalk-*/ # 钉钉官方能力参考副本,不是核心 Skill
├── .dingtalk/ # 通知接入说明与无秘密示例配置
└── scripts/dingtalk-progress # Hub 钉钉通知的当前演示封装
└── scripts/ # Hub 辅助通知,不是 Etunel 任务机制
├── dingtalk_notify.py # Python 共用实现,含有限重试
├── dingtalk-progress # macOS/Linux 进度入口
├── dingtalk-bot-message # macOS/Linux 摘要与负责人提醒
├── dingtalk-progress.ps1 # Windows PowerShell 进度入口
├── dingtalk-bot-message.ps1 # Windows PowerShell 摘要与提醒
└── test_dingtalk_notify.py # 不联网的隔离测试
```
Skill 被用于实际项目时,Hub 在目标项目中维护统一的 `项目资料/`,而不是把项目成果写回本约束仓库:
@@ -139,15 +175,19 @@ Hook 文件需保持短小,并与完整 Skill 职责一致。它们由 Etunel
钉钉集成是 Hub 的辅助通知参考,不是 Etunel 协作系统的一部分。仓库保留它,是为了让 Hub 在满足事件条件时有一个清晰、安全、可替换的调用方法。
只有 Hub/主 Agent 可以使用下面的封装入口:
只有 Hub/主 Agent 可以使用下面的通用 Python 封装入口(Python 3.9+):
```sh
./scripts/dingtalk-progress <start|milestone|blocked|complete|failed> "<简短中文 Markdown 摘要>"
python scripts/dingtalk_notify.py progress <start|milestone|blocked|complete|failed> "<简短中文 Markdown 摘要>"
```
五类事件分别表示项目正式开始、可验证里程碑、真实阻塞、整体完成和最终失败。可验证里程碑由 Hub 先完成成果和进度留档,再发送钉钉消息。成员角色不得发送钉钉消息,只把结果交给 Hub。通知正文可使用多行 Markdown,只写可公开的已验证结论、下一步或阻塞原因,不发送密钥、个人数据、大段日志和未经证实的推断。
更详细的 Hub 事件语义、有限重试和结果判定见 [钉钉项目进度汇报](etunel-role-collaboration/references/dingtalk-progress-reporting.md);当前演示接入与本地配置见 [.dingtalk/README.md](.dingtalk/README.md)。`.agents/skills/dingtalk-*` 是钉钉官方多 Skill 的项目内参考副本,不应被当作 Etunel 核心 Skill,也不应被成员用来绕过 Hub。
新增 `message --title "中文标题" --text "问题和下一步"` 用于辅助摘要与负责人提醒,可按需用 `--at`;提醒结果回各自角色会话,不在群里另派任务或批准。两个入口均支持文件正文与离线 `--dry-run`,Windows 可用 `.ps1`,macOS/Linux 保留原 Shell 入口。它们是同一封装,不要重复调用。
各系统统一由封装读取本地 `config.env` 的 `DINGTALK_PROGRESS_CLIENT_SECRET`,不再从环境变量或 macOS 钥匙串取密钥;实际配置含明文密钥,须限制文件访问并排除出 Git。普通通知不需要 DWS 用户登录,按姓名提醒才额外需要同组织的已登录账号。
事件语义、有限重试和结果判定见 [钉钉项目进度汇报](etunel-role-collaboration/references/dingtalk-progress-reporting.md),命令细节见 [通知脚本用法](etunel-role-collaboration/references/dingtalk-script-usage.md);跨平台安装、原生 dws 与凭据配置见 [.dingtalk/README.md](.dingtalk/README.md)。`.agents/skills/dingtalk-*` 是钉钉官方多 Skill 的项目内参考副本,不应被当作 Etunel 核心 Skill,也不应被成员用来绕过 Hub。
本地 `.dingtalk/config.env` 已被 Git 忽略。不要读取、输出或提交其中的配置;不要在文档、Skill、Hook 或脚本中硬编码 Client Secret、Client ID、`robotCode`、`openConversationId`、App Token 或群名。仓库维护时也不要用真实发送作为普通测试。
@@ -170,10 +210,12 @@ git status --short
修改核心 Skill 后,使用已安装的 Skill Creator 对 `etunel-role-collaboration/` 运行 `scripts/quick_validate.py`。Skill Creator 的位置取决于协作者的 Codex 安装,不要为此把验证器复制进仓库。
修改钉钉演示脚本后再运行:
修改钉钉封装后运行隔离测试;Shell 入口有修改时再检查对应语法:
```sh
python -X utf8 scripts/test_dingtalk_notify.py
bash -n scripts/dingtalk-progress
bash -n scripts/dingtalk-bot-message
```
还应人工确认:
+2 -2
View File
@@ -56,10 +56,10 @@ Etunel 负责当前角色和会话绑定。不要向负责人询问或复述 rol
- 准备派发、回复、跨角色中继、完成或报告阻塞:读 [Etunel 任务消息流程](references/etunel-message-lifecycle.md)。
- 定义或校验成果、状态、版本、证据、额外产出或豁免:读 [成果与完成判定](references/artifacts-and-evidence.md)。
- 选择、复用或审核业务、产品、硬件、固件、测试及归档模板:读 [文档模板复用与审核](references/document-templates.md),再按实际任务定位原件;模板不自行改变职责、正式成果或门禁。
- 缺信息、错投、阻塞、超时升级、会议决定、返工、变更、流程跳转或模拟:读 [异常与协调](references/exceptions-and-coordination.md)。
- 缺信息、资料冲突、查询无新线索、旧任务收尾、错投、阻塞、超时升级、会议决定、返工、变更、流程跳转或模拟:读 [异常与协调](references/exceptions-and-coordination.md)。
- 编写给负责人、角色或群组的任务、提问、结果和状态消息:读 [人类可读沟通](references/human-readable-communication.md)。
- 创建项目资料目录、保存成员清单、归档成果、更新进度或阶段记录:读 [项目文件与进度留档](references/project-files-and-progress.md)。
- 仅当当前会话是 Hub 且发生钉钉汇报事件:读 [钉钉项目进度汇报](references/dingtalk-progress-reporting.md)。
- 仅当当前会话是 Hub 且需钉钉进度汇报或负责人辅助提醒:读 [钉钉项目进度汇报](references/dingtalk-progress-reporting.md),实际调用再读其中的脚本用法。
- 测试角色仅在任务类型匹配时,按 [测试职责](references/roles/testing.md) 中的二级链接读取更深细则。
不要为“全面了解”一次加载全部引用或全部角色文件。Etunel Hook 源文件独立配置,不属于本 Skill 的渐进式发现树。
@@ -1,17 +1,15 @@
# Hub 钉钉项目进度汇报
仅当当前会话是 Hub 且发生本文件定义的汇报事件时读取。钉钉是面向项目群组的单向辅助可见性与异常提醒,不是任务派发、跨角色沟通、审批、门禁、人类确认或项目记录的事实源。
仅当当前会话是 Hub 且发生汇报事件或需提醒负责人处理具体问题时读取。钉钉是面向项目群组的单向辅助可见性与异常提醒,不是任务派发、跨角色沟通、审批、门禁、人类确认或项目记录的事实源。
## 权限与唯一入口
## 权限与封装入口
- 只有 Hub/主 Agent 可以发送;成员 Agent 只把成果、状态或阻塞交给 Hub。
- 项目配置为启用时视为持续发送授权,不需每条消息再次询问。
- 只能调用项目封装入口:
`./scripts/dingtalk-progress <start|milestone|blocked|complete|failed> "<中文 Markdown 摘要>"`
- 只用项目封装:`dingtalk-progress` 处理五类项目事件,`dingtalk-bot-message` 处理自定义中文摘要或负责人提醒;二者共用 `scripts/dingtalk_notify.py`。Windows、macOS/Linux 的命令及文件正文、人员提醒用法见 [通知脚本用法](dingtalk-script-usage.md),实际调用前按需读取。
- Agent 不得直接调用底层 OpenAPI、`dws api` 或其他消息入口绕过封装脚本。
- Agent 不读取、修改、输出或请求 Client Secret、DING_SEC、App Token、Client ID、robotCode、openConversationId 等凭据和内部标识。
- Agent 不自行打开、展示或修改真实配置,不请求负责人在会话中提供密钥和内部标识;由封装在本地读取 `config.env` 中的 `DINGTALK_PROGRESS_CLIENT_SECRET` 并完成认证,不再使用环境变量或钥匙串取密钥。
- Skill 与消息正文不得硬编码项目 Client ID、robotCode、openConversationId、Client Secret 或群名;项目差异只由 `.dingtalk/config.env` 和封装脚本处理。
## 汇报单位与顺序
@@ -64,13 +62,15 @@ Hub 必须先确认进展真实有效,并完成成果文件、成果索引和
不在正文中写运行时 ID、英文成果名、机器状态码或内部字段。只写已验证结论,不得包含密钥、Token、个人数据、客户敏感信息、大段日志、完整内部对话或未经验证的根因推断。
负责人提醒只使用项目协作必需的已确认姓名;脚本为钉钉原生 @ 自动生成的人员标记是技术参数,不是要求 Hub 把人员编号写进摘要。需要负责人行动时,说明缺什么、影响什么、请谁做什么,以及回哪个角色会话补充结果。不要在群里建立另一套任务或审批流程。
## 调用与结果
1. Hub 判断事件并形成中文 Markdown 摘要。
2. 调用封装脚本一次;脚本自行处理配置检查、发送和有限重试。
3. 只有真实发送响应包含非空 `processQueryKey`,才表示钉钉服务端已经接受;不证明群成员已读。
4. 缺少或未启用配置时安全跳过;预览模式只检查请求,不视为真实发送。
5. 真实发送失败或没有有效 key 时,脚本最多总计尝试 3 次,间隔 10 秒;首次成功立即停止。
6. 三次仍失败只终止本次钉钉发送,Etunel 主任务继续。Hub 在本地结果中向负责人说明一次,不递归发送新的阻塞或失败通知,也不自动补发历史事件。
4. 缺少或未启用配置时不真实发送;预览在本地读取已有配置并生成正文,不查通讯录、不使用密钥认证、不输出密钥、不联网,也不证明目标配置和 @ 有效。
5. 只有明确的域名解析失败才自动重试,总计最多 3 次,间隔 10 秒;首次收到有效回执立即停止。认证、权限或未知错误不自动重试;超时、连接中断或缺少回执可能已发出,不盲目重发。单次命令最长等待 60 秒。
6. 有效回执优先于命令退出码,不因退出异常重复发送。发送停止或结果未知时,Etunel 主任务继续;Hub 在本地结果中向负责人说明一次,不递归发送新的阻塞或失败通知,不自动补发历史事件。
脚本输出和退出码只用于判断通知调用,不得把钉钉结果升级成项目门禁。Agent 不在脚本外追加重试循环。
@@ -0,0 +1,61 @@
# Hub 通知脚本用法
仅在 Hub 已按 [通知规则](dingtalk-progress-reporting.md) 判断需要通知、准备调用脚本时读取。脚本须由部署负责人放在目标 Hub 项目的 `scripts/`;安装 Skill 本身不会部署脚本、配置或凭据。找不到入口时说明未部署并继续主任务,不猜绝对路径、不临时绕过封装。
## 选一个入口,不重复调用
共用实现为 `scripts/dingtalk_notify.py`,依赖 Python 3.9+;真实发送还需原生 `dws`。相对命令从目标项目根目录执行。
所有系统由封装统一从目标项目 `.dingtalk/config.env` 的 `DINGTALK_PROGRESS_CLIENT_SECRET` 读取机器人密钥,不回退到环境变量或钥匙串。配置由部署负责人本地维护;Hub 不打开或展示真实配置、不在会话索取密钥。普通发送不需要 DWS 用户登录,按姓名提醒才额外需要同组织的已登录账号配置。
| 环境 | 项目事件 | 中文摘要/负责人提醒 |
| --- | --- | --- |
| 通用 Python | `python scripts/dingtalk_notify.py progress ...` | `python scripts/dingtalk_notify.py message ...` |
| Windows PowerShell 7.3+ | `./scripts/dingtalk-progress.ps1 ...` | `./scripts/dingtalk-bot-message.ps1 ...` |
| macOS/Linux | `sh scripts/dingtalk-progress ...` | `sh scripts/dingtalk-bot-message ...` |
Python 在 macOS/Linux 通常叫 `python3`;Windows 也可用 `py -3`。这是同一实现的不同入口,不是失败后依次尝试的备选发送渠道。原有 `./scripts/dingtalk-progress` 调用仍可使用,前提是该文件有执行权限。
## 项目事件通知
```text
python scripts/dingtalk_notify.py progress milestone "**已完成**:本阶段成果已确认并归档。下一步安排联调。"
```
事件仍只有 `start`、`milestone`、`blocked`、`complete`、`failed`。脚本自动加项目名、中文状态和本机带时区时间;不自动判断项目阶段或完成状态。
多行 Markdown 可直接作为一个参数传入。较长或含引号的摘要优先保存为 UTF-8 正文文件,再用 `--file`;不要把大段成果或日志当通知:
```text
python scripts/dingtalk_notify.py progress milestone --file "项目资料/00-项目管理/通知摘要.md"
```
这里的路径是调用示例,须先在实际项目中生成相应摘要,不能假定文件存在。文件路径相对当前工作目录解析;`--file` 是读取消息正文,不是上传附件。进度摘要和 `--file` 二选一。
## 自定义摘要与负责人提醒
```text
python scripts/dingtalk_notify.py message --title "联调需补充接口说明" --text "缺少接口返回值说明,联调暂时无法继续。请负责该接口的人补齐,并在自己的角色会话中同步结果。"
```
这个入口允许自定义标题,但不新增项目状态,不替代应该记录的阻塞事件。需要 @ 已知负责人时追加 `--at "完整姓名"`;多个姓名用逗号分隔或重复传入 `--at`。正文也可用 `--file` 代替 `--text`。
- 负责人称呼来自 Etunel 已绑定的 `member_alias`,不要把角色名称 `display_name` 当人员姓名,也不要让人每轮确认身份。
- 按姓名查找由封装执行,只使用部署时配置的同组织账号。没有配置账号、查不到、重名或返回无法识别时,本条不发送;请负责人澄清姓名或让部署负责人修正映射,不选择第一个、不换组织碰运气。
- `--at-id "姓名=userId"` 仅兼容已有且同组织的有效映射,由真实工具结果或部署负责人提供;不从 Etunel 角色标识推导,不主动索要人员编号。
- 默认发项目配置群。`--group` 仅保留给用户明确指定其他目标群的情形,不由 AI 猜群或扩大通知范围。
- 同一事项只形成一条合并通知。已经由事件通知说明清楚时,不额外发一遍提醒;没有变化时不循环催促。
- 通知必须指向实际下一步。负责人在线下讨论后回自己的角色会话补充结果,由角色完成原任务并经 Etunel 返回 Hub;群里已读或口头回复都不自动完成任务。
## 预览与结果
两个入口均支持 `--dry-run`;项目配置的预览开关也同时约束两者。预览不要求安装 `dws` 或填写密钥;会读取已有配置,但不使用密钥认证、不输出密钥、不查人员、不发送,只显示本地正文,@ 对象及目标配置未验证。
标准输出为精简 JSON,面向负责人时转述中文结果,不粘贴机器输出:
- `accepted`:服务端已接受,并返回有效 `processQueryKey`;不代表已读。
- `preview`:只有预览,未发送。
- `skipped`:未配置或未启用,未发送。
- `error`:本次停止;按说明区分失败和结果未知,不声称已发送或自行补发。
前三种退出码为 0,错误为 1;错误参数可能返回 2,因此不能只凭退出码 0 宣布已发送。封装内部处理有限重试,不要在脚本外再套重试。缺配置、凭据、权限或安装问题交部署负责人处理;通知失败不影响 Etunel 主流程。
@@ -1,6 +1,6 @@
# 异常与协调
出现缺失信息、错投、职责冲突、阻塞、超时、复杂线下会议、缺陷、返工、变更、流程跳转、成果豁免或模拟流程时读取。所有成员 Agent 跨角色通信都经 Hub;现阶段没有成员直连。
出现缺失信息、资料冲突、查找无新线索、旧任务收尾、错投、职责冲突、阻塞、超时、复杂线下会议、缺陷、返工、变更、流程跳转、成果豁免或模拟流程时读取。所有成员 Agent 跨角色通信都经 Hub;现阶段没有成员直连。
## 缺失信息
@@ -15,6 +15,22 @@
Hub 有登记答案时直接补足;没有时只向实际信息所有者角色创建必要查询或任务。取得确认结果后返回原任务,不广播无关角色。
## 查找无新线索
已检查当前有效资料、可访问文件并向负责人合理查找后,若仍无新线索,角色汇总已确认事实、无法证明的内容、影响、继续所需条件及可行选择,由 Hub 交有权负责人决定下一步。没有新来源或新条件,不重复派发同内容查询,也不设统一查询次数或时长。无关工作继续;批准限定用途或选择替代方案不等于证明未知事实,更不能替代必要质量、安全条件。
## 资料冲突与文档纠错
文档参数、其他有效资料与实际实现相互冲突时,发现角色说明具体差异及受影响内容,经 Hub 定向交对应专业角色核实。产品需求和参数由产品确认,技术实现由相应技术角色确认;涉及多个领域时分别取得必要结论,Hub 不凭文件名、到达先后或自己的推断选定答案。只暂停依赖该争议结论的受影响修改,无关工作继续。
确认是文档错误时,由原主责角色修正并取得本角色负责人确认,Hub 按现有版本规则更新有效资料、保留纠错依据并同步受影响角色,不新增可视化评审或审批步骤。若纠正实际改变已确认要求或下游行动,则按实质影响走既有变更流程,不能用“文档纠错”绕过必要批准;已经按旧资料开展的工作一并检查是否受影响。
## 新决定与旧任务收尾
有权负责人更改决定后,Hub 在继续派发受影响工作前,合并向实际受影响角色说明:哪些停止、哪些继续、改用哪些输入,以及已有成果如何处理;同步现有阶段记录、进度及适用的成果索引,不另建任务清理表,也不等待全员再次确认。
晚到结果按当前决定核对适用性,有用部分保留供复用;不因刚收到结果就恢复旧要求、覆盖当前有效成果或重新催办已经停止的事项。不能沿用的部分说明原因,只安排当前仍必要的后续工作。这里约束业务安排及结果处理,不宣称已取消运行时队列消息,不重造 Etunel 取消、投递或去重机制。
## 结果不完整
- 原任务目标不变;Hub 只列缺失成果、内容、证据、版本、自审或确认。
@@ -128,6 +144,7 @@ Etunel 投递、成员返回、会话或消息链路实际中断是流程阻塞
- 可以使用真实已知数据和为流程验证构造的数据,清楚区分真实与模拟;
- 模拟值影响结论时,相关成果整体标记为模拟;
- 启用模拟默认只改变数据和执行方式,不自动跳过角色交接;Hub 用一句话说明接下来演练的顺序,不新增审批文件;
- 可以按明确指令临时跳过或进入后续节点,并记录假设、风险和恢复项,不让门禁卡死流程验证;
- 负责人确认的是模拟使用和流程推进,不是数据真实性;
- 不静默切回正式模式,不让模拟结论进入客户承诺、真实测试或生产发布;
@@ -135,6 +152,8 @@ Etunel 投递、成员返回、会话或消息链路实际中断是流程阻塞
模拟文件和阶段记录必须明确标注,不能混入正式成果目录中的当前有效版本。
中途转模拟时,保留真实进度、未解决问题和恢复点,按已授权范围暂停真实工作;模拟完成不关闭真实缺陷。提前形成的下游模拟材料可保存待用,依赖满足后只补必要输入,不全部重做,也不提前登记阶段完成。恢复真实工作须明确授权,从真实未完成处继续,不能沿用模拟通过结论。
## 真实授权事项
需要真实授权时,Hub 提供事实、选项、建议、影响和最晚决定点。授权范围内的资源与排期调整交产品确认,可同步业务;涉及预算、采购、对外承诺、业务范围、项目暂停或取消时交有权业务负责人或项目发起人批准。未决定前保持真实等待或阻塞,不解释为默认批准。
@@ -56,6 +56,8 @@ Hub 只检查角色、必填内容、成果、版本、证据、负责人确认
不要把 role ID、member ID、session ID、消息 ID 或机器状态放进人类任务正文。纯信息、澄清或授权请求不要求空文件,但要给足背景、问题、影响和需要的结论。详细写法见 [人类可读沟通](human-readable-communication.md)。
沿用有效资料,明确本次是新做、复用还是修改,实际使用哪份工程或资料;区分开工必需输入与本次要制作的产出,不把后者当成缺件退回上游。本地未找到不代表项目没有现成资料,归属或位置不明时再向对应负责人核实;不另设开工审批或检查表。
期限优先继承已批准的《客户里程碑要求》《里程碑要求》或《项目计划》。没有明确期限时集中询问负责人一次;不影响当前依赖时可写“待确认”并登记风险,影响关键依赖或门禁时保持阻塞。模拟项目可使用明确标注为模拟的假设日期。
## Hub 中介的跨角色信息流
@@ -71,6 +73,8 @@ Hub 只检查角色、必填内容、成果、版本、证据、负责人确认
普通问题默认一组聚合问题和一组答复。出现新的实质问题才追加;复杂异常改走 [异常与协调](exceptions-and-coordination.md) 的线下会议与《会议决定记录》。
资料或参数冲突交对应专业角色核实;合理查找后仍无新线索时,汇总事实、未知和选择交有权负责人决定,不重复派发相同查询。决定变化后的旧任务及晚到结果按 [新决定与旧任务收尾](exceptions-and-coordination.md#新决定与旧任务收尾) 处理。
## 派发、等待与入站处理
按 [Etunel 任务消息流程](etunel-message-lifecycle.md) 派发和结算:
@@ -155,7 +159,7 @@ Hub 不把 AI 自主生成当成人类确认。Hub 自己的负责人在以下
## 钉钉进度
只有 Hub 可以发送项目进度。Hub 先完成成果和进度留档,再在项目开始、可验证里程碑、正式阻塞或 Etunel 中断、总体完成、总体最终失败时,按 [钉钉项目进度汇报](dingtalk-progress-reporting.md) 调用封装脚本。钉钉不参与任务派发、批准、门禁或完成判定。
只有 Hub 可以发送项目进度。Hub 先完成成果和进度留档,再在项目开始、可验证里程碑、正式阻塞或 Etunel 中断、总体完成、总体最终失败时,按 [钉钉项目进度汇报](dingtalk-progress-reporting.md) 调用进度封装。需要负责人处理具体问题或超过约定时间需提醒时,可按 [脚本用法](dingtalk-script-usage.md) 发送简短中文提醒;同一事项合并发送,不重复发进度和提醒。提醒负责人在自己的角色会话中补充结果,成员仍经 Etunel 向 Hub 返回;钉钉不参与任务派发、批准、门禁或完成判定,通知失败不阻塞主流程。
## Hub 禁止事项
@@ -25,6 +25,8 @@
同时理解 Hub 给出的本阶段完成约定,并在本角色范围内核对哪些是本阶段必须完成、哪些留到后续、哪些当前不需要。一次列出当前能预见的信息缺口。负责人确认理解、补足输入或明确可以开始后再执行。不要复述运行时 ID、英文状态和内部字段;详细写法见 [人类可读沟通](human-readable-communication.md)。
在这次对齐中分清新做、复用或修改,认准实际使用的工程或资料,区分应先取得的输入与本次要制作的产出。已有明确答案直接沿用,不因本地未找到就断言项目没有现成工程,也不要求上游先交出本角色尚待制作的成果。
## 执行本角色任务
1. 只使用 Hub 给出的当前有效基线,且只处理本角色范围。
@@ -32,7 +34,7 @@
3. 记录实际方法、环境、版本、构建、板卡、配置或其他可复查证据。
4. 分开说明已确认事实、证据、专业判断、假设、批准决定、开放问题、依赖和剩余风险。
5. 未执行验证说明原因和影响,不虚构构建、测试、设备结果或人类意见。
6. 发现范围、接口、版本、角色责任、日期或验收变化时不静默修改基线,提交 Hub 走《变更申请》。
6. 发现资料、参数与实际实现冲突时,列明差异,经 Hub 交对应专业角色核实,不自行选一个答案。仅属文档错误时由原主责修正;实际改变已确认范围、接口、版本、角色责任、日期或验收时,提交 Hub 走《变更申请》。详见 [资料冲突与文档纠错](exceptions-and-coordination.md#资料冲突与文档纠错)。
7. 产出完成前不频繁发进度;只有必要补充、正式阻塞或 Hub 明确要求的关键状态才发送非终态消息。
8. 发现后续阶段会需要的资料时向 Hub 登记,但不自动扩大当前任务,也不把它写成当前阻塞。
@@ -49,6 +51,8 @@
不要逐句追问或绕过 Hub。普通问题默认一问一答;复杂异常按《会议决定记录》流程处理。
合理查找后仍无新线索时,按 [查找无新线索](exceptions-and-coordination.md#查找无新线索) 汇总已知、未知及选择,不重复同样的查询。收到 Hub 更正安排后,仅继续仍有效的工作;旧任务已经产出的内容说明适用性并保留可复用部分,不自行恢复已停止工作,详见 [新决定与旧任务收尾](exceptions-and-coordination.md#新决定与旧任务收尾)。
只有缺失内容直接导致本次成果无法形成、完成条件无法判断、本角色无法作出当前决定,或构成当前必须处理的安全、合规、不可逆损失及已批准承诺风险时,才报告当前阻塞。仅供后续阶段使用的资料作为后续依赖返回,不阻塞本任务。
## 状态与超时答复
@@ -43,6 +43,8 @@
## 联调与最终提测版本
认准实际产品工程、配套软件及最终固件输出位置,不把演示框架或旧镜像当成本轮成果。完整编译与打包尽量早验证,具体时机由研发与技术负责人按工程条件安排,不新增“第一轮必须完整编译通过”的门禁;组件编译成功不等于完整固件已经生成。
候选统一固件与硬件就绪时启动联调(F5.8),此时不要求已提交最终提测版本。联调问题关闭后,应用层提交最终提测版本(F5.9),再经 Hub 交技术负责人确认版本匹配与可提测(F5.10);不能倒置这两个条件。
## 节点输出
@@ -48,6 +48,8 @@
5. 由 Hub 按产品指定范围派发同版本评估;产品汇总技术风险、时间风险及其他风险,形成并维护《项目风险》,再处置专业反馈,专业角色仍对自己的可行性和约束结论负责。仅供后续使用的资料不能自动成为当前阻塞。
6. 产品负责人确认基线;涉及客户范围、承诺或业务验收的内容取得业务批准。
产品对自己出具文档中的需求和参数把关。收到参数错误、同名资料内容不一致或实现与文档冲突的反馈时,核对确认依据;涉及技术事实,经 Hub 请对应专业角色核实,不自行猜测。产品修正自己的错误文档并经负责人确认后交 Hub 更新、定向同步;实质改变已确认要求或下游行动时按 [资料冲突与文档纠错](../exceptions-and-coordination.md#资料冲突与文档纠错) 处理,不把纠错变成额外评审。
《立项资料》纳入平台 SDK、个参资料。不另设《产品资料整合》成果,后续任务直接引用当前资料索引。器件、板框、串号及平台对接资料按实际范围以受控引用整合,不另立开发资料包,也不替代硬件设计包或测试计划。结构板框图在阶段 2 选填,进入依赖它的硬件任务前须补齐。
## 立项资料与里程碑
@@ -21,20 +21,21 @@
"核对成果可访问性、版本、依据、负责人确认及当前完成条件,不替专业角色作内容结论。按项目阶段目录留档,维护项目进度、成果索引和阶段记录;可直接交付的文件登记可查索引及接收确认,回退保留历史,已接受成果不因消息状态异常要求重复制作。",
"跳转、回退、暂缓由Hub负责人决定,记录原因、缺口、风险和补齐安排,不把跳过写成通过。选填成果不强制补交;必需成果豁免由主责提出,Hub对应真人负责人明确批准或拒绝,Hub AI只登记;批准仅满足该文件要求,其他测试、验收及阶段门禁仍须满足。模拟材料明确标注,不当成真实交付。",
"收到状态查询按范围返回有依据的快照;纠正先核对证据、版本及一致性,再保留历史并同步受影响角色。确认或任务超过约定时间时带逾期对象、时长、证据、影响、替代方案和新期望时间升级给对应角色,项目级组织授权交业务;接收方内部确认后回复,未获必要确认仍等待或阻塞。",
"聚合信息缺口、定向协调;阻塞时请相应真人负责人参与并必要时线下沟通,结果返回各自角色会话。每条入站消息按实际工具回复或结算;可验证里程碑先留档再向钉钉汇报,项目开始、阻塞或中断、完成及最终失败按通知事件处理。",
"聚合信息缺口,分清开工输入与本次产出;资料冲突交专业角色核实,无新线索交有权负责人决定,不反复查询。决定变化时说清哪些停止、继续及替换哪些输入,晚到结果不恢复旧要求。阻塞必要时请真人线下协调,结果回各自会话。入站消息按实际工具回复或结算;可验证里程碑先留档再报钉钉,开始、阻塞或中断、完成及最终失败按事件通知。",
"候选固件与硬件就绪启动联调,问题关闭后应用层提交最终提测版本,再由技术负责人确认。缺陷责任角色先返回原因与计划,Hub派发修复任务并同步确认结果给测试维护清单;回归只默认提供统一固件、版本矩阵、缺陷清单及必要变更说明,定位资料按问题提供;回归经Hub校验、技术负责人按需确认,不追加每轮必审。"
],
"non_goals": [
"不代专业角色开发、测试、验收或批准,不替成员完成任务,不自行批准豁免或创建Etunel角色。",
"不重造Etunel的队列、传输、重试和去重机制;不以通知送达或消息结算替代成果完成。",
"钉钉只作辅助通知,不作任务通道、审批或事实来源;只使用项目封装脚本,不读取输出凭据,通知失败不阻塞主任务。"
"钉钉只作辅助通知,不作任务通道、审批或事实来源;只用项目封装发送进度或负责人提醒,提醒讨论结果回各自角色会话;密钥由封装从本地config.env读取,不在会话索取或展示,不自行猜人员编号,通知失败不阻塞主任务。"
],
"completion_tools": [
"etunel_list_roles",
"etunel_get_project_status",
"etunel_send_to_role",
"etunel_complete_coordination",
"./scripts/dingtalk-progress"
"python scripts/dingtalk_notify.py progress",
"python scripts/dingtalk_notify.py message"
]
}
```
@@ -48,6 +49,6 @@
你是项目Hub,负责总体推进与成员Agent间的唯一中继,不替专业角色作结论。按需读取 etunel-role-collaboration 的相关细则;当前已确认流程和Etunel实际工具优先,不加载全部角色说明。
用 mcp__etunel__etunel_list_roles 获取实际角色;role用于路由,display_name用于显示,member_alias用于称呼已绑定负责人,不向人核对内部编号。按需用 mcp__etunel__etunel_get_project_status 查状态,但消息状态不等于成果完成。
派发或回复用 mcp__etunel__etunel_send_to_role,每次一个实际角色;依赖就绪可多任务派发,同角色相关任务可打包。给足输入并写清成果要求,要求成员先与真人对齐、完成后自审并取得确认。已处理且无需回复的消息用 mcp__etunel__etunel_complete_coordination 结算;参数依当前schema,标识从入站消息取。
只检查当前必需条件,复用已确认成果,阶段通过后推进,后续资料按需索取。按阶段留档并更新进度、成果索引和阶段记录;允许真人直交的测试文件登记索引、版本及产品接收确认。阻塞时让相应负责人参与,必要时请Hub负责人协调;跳转、回退或暂缓记录真实状态。
可验证里程碑留档后,调用 ./scripts/dingtalk-progress milestone "简短中文Markdown摘要" 汇报钉钉;开始、阻塞或中断、完成、最终失败按通知规则使用对应事件。用短段或列表说清结果、问题和下一步,不堆日志或中英混杂;失败不阻塞主流程,钉钉不能代替Etunel回复或结算。
只检查当前必需条件,复用成果,阶段通过后推进,后续资料按需索取。决定变化要说明旧任务停续和新输入;资料冲突交专业角色核实。按阶段留档并更新进度、成果索引和记录;直交测试文件登记索引、版本及产品收讫。阻塞请相应负责人协调;跳转、回退或暂缓如实记录,模拟默认不跳过交接、不关闭真实问题。
可验证里程碑留档后,用 python scripts/dingtalk_notify.py progress milestone "中文Markdown摘要" 报钉钉;其他事件按通知规则。需负责人行动时用 message --title "中文标题" --text "问题与下一步",可按需读通知脚本细则使用 --file、--at 或对应系统入口;同一事项合并,不重复提醒。结果回各自角色会话;配置和密钥由封装读取,不打开真实config.env、不索要编号或凭据,不在脚本外重试,预览不算发送,通知失败不阻塞主流程、不代替Etunel回复或结算。
```
@@ -11,7 +11,7 @@
"responsibilities": [
"与业务共同确认需求基线;在产品定义阶段按问题选择评审角色,把需确认的问题交项目Hub定向派发,不自行联系成员Agent。",
"形成立项资料(包括平台SDK、个参资料)、测试验收标准、里程碑要求和项目风险;组织产品定义确认并交业务批准范围,持续汇总各专业风险。立项和里程碑表的人力、费用、预算等仅作早期估算与适用性说明,不替代正式资源、排期和预算批准,也不自动增加阶段2门禁。",
"确认方案不偏离产品定义;在授权范围内确认实际人员、资源与排期,预算、采购和对外承诺交业务批准。",
"对产品文档中的需求和参数把关,异常经Hub交对应专业角色核实,修正本角色文档并由负责人确认;实质改变已确认要求仍走变更。确认方案不偏离产品定义;授权内确认人员、资源与排期,预算、采购和对外承诺交业务批准。",
"组织平台与客户两条验收线,平台在前、各自输入就绪时可并行;出具并确认平台验收报告或结论,形成客户验收通过报告交业务确认,由产品出具或更新报告和共用的一份标明来源的遗留问题清单,Hub汇总登记,归档表不改变职责。",
"由产品负责人接收测试负责人直接交付的报告及已提供的提测佐证,在本会话确认接收并向项目Hub返回索引、版本和接收结论;两条验收线针对同一对象及版本均通过才完成验收。",
"接到任务先与本角色真人负责人对齐目标、输入和成果要求;信息足够后执行,成果自审并经负责人实际确认后才正式返回。",
+8
View File
@@ -0,0 +1,8 @@
#!/bin/sh
# macOS/Linux entry; Windows can use the .ps1 or Python entry.
set -eu
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
if command -v python3 >/dev/null 2>&1; then
exec python3 "$script_dir/dingtalk_notify.py" message "$@"
fi
exec python "$script_dir/dingtalk_notify.py" message "$@"
+11
View File
@@ -0,0 +1,11 @@
# Requires PowerShell 7.3+ for native argument passing (quotes and Markdown).
$ErrorActionPreference = 'Stop'
if ($PSVersionTable.PSVersion -lt [version]'7.3') { throw 'Please use PowerShell 7.3+ or the Python entry.' }
$PSNativeCommandArgumentPassing = 'Standard'
$env:PYTHONIOENCODING = 'utf-8'
if (Get-Command python -CommandType Application -ErrorAction SilentlyContinue) {
& python "$PSScriptRoot/dingtalk_notify.py" message @args
} elseif (Get-Command py -CommandType Application -ErrorAction SilentlyContinue) {
& py -3 "$PSScriptRoot/dingtalk_notify.py" message @args
} else { throw 'Python 3.9+ is required.' }
exit $LASTEXITCODE
+5 -187
View File
@@ -1,190 +1,8 @@
#!/bin/sh
# macOS/Linux entry; Windows can use the .ps1 or Python entry.
set -eu
project_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
config_file="$project_root/.dingtalk/config.env"
usage() {
echo "用法:./scripts/dingtalk-progress <start|milestone|blocked|complete|failed> <中文 Markdown 摘要>" >&2
}
if [ "$#" -lt 2 ]; then
usage
exit 2
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
if command -v python3 >/dev/null 2>&1; then
exec python3 "$script_dir/dingtalk_notify.py" progress "$@"
fi
event_name=$1
shift
summary=$*
case "$event_name" in
start|milestone|blocked|complete|failed) ;;
*)
usage
exit 2
;;
esac
if [ ! -f "$config_file" ]; then
echo "DingTalk progress reporting is not configured; skipping." >&2
exit 0
fi
file_mode=$(stat -f '%Lp' "$config_file" 2>/dev/null || stat -c '%a' "$config_file" 2>/dev/null || true)
case "$file_mode" in
400|600) ;;
*)
echo "Refusing to read $config_file because its mode is ${file_mode:-unknown}; run chmod 600 on it." >&2
exit 2
;;
esac
while IFS= read -r line || [ -n "$line" ]; do
case "$line" in
''|'#'*) continue ;;
esac
key=${line%%=*}
value=${line#*=}
case "$key" in
DINGTALK_PROGRESS_ENABLED|DINGTALK_PROGRESS_DRY_RUN|DINGTALK_PROGRESS_MODE|DINGTALK_PROGRESS_CLIENT_ID|DINGTALK_PROGRESS_TARGET|DINGTALK_PROGRESS_ROBOT_CODE|DINGTALK_PROGRESS_KEYCHAIN_SERVICE|DINGTALK_PROGRESS_PROJECT|DINGTALK_PROGRESS_TITLE_PREFIX)
export "$key=$value"
;;
*)
echo "Unknown key in $config_file: $key" >&2
exit 2
;;
esac
done < "$config_file"
if [ "${DINGTALK_PROGRESS_ENABLED:-0}" != "1" ]; then
echo "DingTalk progress reporting is disabled; skipping." >&2
exit 0
fi
if [ "${DINGTALK_PROGRESS_MODE:-app-bot}" != "app-bot" ]; then
echo "DINGTALK_PROGRESS_MODE must be app-bot." >&2
exit 2
fi
if [ -z "${DINGTALK_PROGRESS_CLIENT_ID:-}" ] ||
[ -z "${DINGTALK_PROGRESS_TARGET:-}" ] ||
[ -z "${DINGTALK_PROGRESS_ROBOT_CODE:-}" ]; then
echo "App-bot mode requires DINGTALK_PROGRESS_CLIENT_ID, DINGTALK_PROGRESS_TARGET, and DINGTALK_PROGRESS_ROBOT_CODE." >&2
exit 2
fi
if ! command -v jq >/dev/null 2>&1; then
echo "jq is required to construct and validate the DingTalk OpenAPI request." >&2
exit 127
fi
if ! command -v dws >/dev/null 2>&1; then
echo "Global dws command was not found in PATH." >&2
exit 127
fi
dws=$(command -v dws)
if [ -n "${DING_SEC:-}" ]; then
client_secret=$DING_SEC
else
if [ ! -x /usr/bin/security ]; then
echo "DING_SEC is unset and macOS Keychain command /usr/bin/security is unavailable." >&2
exit 127
fi
keychain_service=${DINGTALK_PROGRESS_KEYCHAIN_SERVICE:-com.eapil.dingtalk.$(basename -- "$project_root")}
if ! client_secret=$(/usr/bin/security find-generic-password \
-a "$DINGTALK_PROGRESS_CLIENT_ID" \
-s "$keychain_service" \
-w 2>/dev/null); then
echo "DingTalk Client Secret was not found in DING_SEC or this project's macOS Keychain item." >&2
echo "See .dingtalk/README.md for credential setup." >&2
exit 2
fi
fi
if [ -z "$client_secret" ]; then
echo "The DingTalk Client Secret is empty." >&2
exit 2
fi
case "$event_name" in
start) event_label="开始推进" ;;
milestone) event_label="阶段里程碑" ;;
blocked) event_label="遇到阻塞" ;;
complete) event_label="本轮完成" ;;
failed) event_label="执行失败" ;;
esac
project_name=${DINGTALK_PROGRESS_PROJECT:-$(basename -- "$project_root")}
title_prefix=${DINGTALK_PROGRESS_TITLE_PREFIX:-[项目进度]}
title="$title_prefix $event_label"
timestamp=$(date '+%Y-%m-%d %H:%M:%S %Z')
body=$(printf '## %s\n\n**项目:** %s \n**状态:** %s \n**更新时间:** %s\n\n---\n\n%s' \
"$title" "$project_name" "$event_label" "$timestamp" "$summary")
msg_param=$(jq -cn --arg title "$title" --arg text "$body" '{title:$title,text:$text}')
request_body=$(jq -cn \
--arg msgParam "$msg_param" \
--arg openConversationId "$DINGTALK_PROGRESS_TARGET" \
--arg robotCode "$DINGTALK_PROGRESS_ROBOT_CODE" \
'{msgParam:$msgParam,msgKey:"sampleMarkdown",openConversationId:$openConversationId,robotCode:$robotCode}')
if [ "${DINGTALK_PROGRESS_DRY_RUN:-0}" = "1" ]; then
if DWS_CLIENT_ID="$DINGTALK_PROGRESS_CLIENT_ID" \
DWS_CLIENT_SECRET="$client_secret" \
"$dws" api POST /v1.0/robot/groupMessages/send \
--data "$request_body" --dry-run --yes --format json; then
send_status=0
else
send_status=$?
fi
unset client_secret DWS_CLIENT_SECRET
exit "$send_status"
fi
max_send_attempts=3
retry_delay_seconds=10
attempt=1
final_status=1
while [ "$attempt" -le "$max_send_attempts" ]; do
response=
if response=$(DWS_CLIENT_ID="$DINGTALK_PROGRESS_CLIENT_ID" \
DWS_CLIENT_SECRET="$client_secret" \
"$dws" api POST /v1.0/robot/groupMessages/send \
--data "$request_body" --yes --format json); then
send_status=0
else
send_status=$?
fi
if [ -n "$response" ]; then
printf '%s\n' "$response"
fi
if [ "$send_status" -eq 0 ] &&
printf '%s\n' "$response" |
jq -e '.processQueryKey | type == "string" and length > 0' >/dev/null 2>&1; then
unset client_secret DWS_CLIENT_SECRET
exit 0
fi
if [ "$send_status" -eq 0 ]; then
echo "DingTalk attempt $attempt returned no valid processQueryKey; service acceptance is unverified." >&2
final_status=1
else
echo "DingTalk attempt $attempt failed with status $send_status." >&2
final_status=$send_status
fi
if [ "$attempt" -lt "$max_send_attempts" ]; then
echo "Retrying DingTalk send in $retry_delay_seconds seconds." >&2
sleep "$retry_delay_seconds"
fi
attempt=$((attempt + 1))
done
unset client_secret DWS_CLIENT_SECRET
echo "DingTalk progress reporting failed after $max_send_attempts attempts; Etunel work should continue." >&2
exit "$final_status"
exec python "$script_dir/dingtalk_notify.py" progress "$@"
+11
View File
@@ -0,0 +1,11 @@
# Requires PowerShell 7.3+ for native argument passing (quotes and Markdown).
$ErrorActionPreference = 'Stop'
if ($PSVersionTable.PSVersion -lt [version]'7.3') { throw 'Please use PowerShell 7.3+ or the Python entry.' }
$PSNativeCommandArgumentPassing = 'Standard'
$env:PYTHONIOENCODING = 'utf-8'
if (Get-Command python -CommandType Application -ErrorAction SilentlyContinue) {
& python "$PSScriptRoot/dingtalk_notify.py" progress @args
} elseif (Get-Command py -CommandType Application -ErrorAction SilentlyContinue) {
& py -3 "$PSScriptRoot/dingtalk_notify.py" progress @args
} else { throw 'Python 3.9+ is required.' }
exit $LASTEXITCODE
+275
View File
@@ -0,0 +1,275 @@
#!/usr/bin/env python3
"""Hub-only DingTalk notifications. Python 3.9+, standard library, no shell eval."""
import argparse
import datetime as dt
import json
import os
from pathlib import Path
import re
import shutil
import stat
import subprocess
import sys
import time
ROOT = Path(__file__).resolve().parent.parent
PREFIX = "DINGTALK_PROGRESS_"
KEYS = {
"ENABLED", "DRY_RUN", "MODE", "CLIENT_ID", "TARGET", "ROBOT_CODE",
"CLIENT_SECRET", "PROJECT", "TITLE_PREFIX", "DWS_PATH", "PROFILE",
}
EVENTS = {
"start": "开始推进", "milestone": "阶段里程碑", "blocked": "遇到阻塞",
"complete": "本轮完成", "failed": "执行失败",
}
# Only an explicit DNS resolution failure is safe to replay automatically.
DNS_ERROR = re.compile(
r"lookup [\w.-]+(?: on [^:\s]+(?::\d+)?)?: [^\n]*"
r"(?:i/o timeout|no such host|server misbehaving|temporary failure in name resolution)",
re.IGNORECASE,
)
ATTEMPTS = 3
RETRY_SECONDS = 10
COMMAND_TIMEOUT = 60
class NoticeError(Exception):
"""A human-safe error: never include raw subprocess/config contents."""
def emit(status, message, **extra):
print(json.dumps({"status": status, "message": message, **extra}, ensure_ascii=False))
def read_config(root):
path = root / ".dingtalk" / "config.env"
if not path.exists():
return {}
if os.name != "nt" and stat.S_IMODE(path.stat().st_mode) not in (0o400, 0o600):
raise NoticeError("配置权限过宽,请部署负责人设为仅当前用户可读写(600 或 400)。")
result = {}
for line in path.read_text(encoding="utf-8-sig").splitlines():
if not line.strip() or line.lstrip().startswith("#"):
continue
key, sep, value = line.partition("=")
key = key.strip()
if not sep or key not in {PREFIX + name for name in KEYS}:
raise NoticeError("配置含不支持的字段或格式;请部署负责人对照示例检查,不要回传配置内容。")
if key in result:
raise NoticeError("配置存在重复字段,请部署负责人检查。")
result[key] = value.strip()
for name in ("ENABLED", "DRY_RUN"):
if result.get(PREFIX + name, "0") not in ("0", "1"):
raise NoticeError("通知开关只能填写 0 或 1。")
return {key[len(PREFIX):]: value for key, value in result.items()}
def parse_args(argv):
parser = argparse.ArgumentParser(description="Hub 钉钉辅助通知;不派发 Etunel 任务。")
commands = parser.add_subparsers(dest="command", required=True)
progress = commands.add_parser("progress", help="项目事件通知")
progress.add_argument("event", choices=EVENTS)
progress.add_argument("summary", nargs="*", help="简短中文 Markdown 摘要")
progress.add_argument("--file", help="UTF-8 正文文件,与摘要二选一")
message = commands.add_parser("message", help="自定义中文摘要或负责人提醒")
message.add_argument("--title", required=True)
body = message.add_mutually_exclusive_group(required=True)
body.add_argument("--text")
body.add_argument("--file", help="UTF-8 正文文件;不是上传附件")
message.add_argument("--at", action="append", default=[], help="完整姓名,逗号分隔,可重复")
message.add_argument("--at-id", action="append", default=[], help="兼容已核实的 姓名=userId;不得猜编号")
message.add_argument("--group", help="仅用于用户明确指定的其他群;默认使用部署配置")
for command in (progress, message):
command.add_argument("--dry-run", action="store_true", help="离线预览,不查人员、不认证、不输出密钥、不发送")
args = parser.parse_args(argv)
if args.command == "progress" and bool(args.summary) == bool(args.file):
parser.error("请提供摘要或 --file,且只能选择一种。")
return args
def body_text(args, root=ROOT):
if args.file:
body_path = Path(args.file)
config_path = root / ".dingtalk" / "config.env"
if config_path.exists() and body_path.samefile(config_path):
raise NoticeError("不能将通知配置文件作为正文发送或预览。")
value = body_path.read_text(encoding="utf-8-sig")
else:
value = " ".join(args.summary) if args.command == "progress" else args.text
if not value.strip():
raise NoticeError("通知正文不能为空。")
if len(value.encode("utf-8")) > 12000:
raise NoticeError("通知过长,请改为简短摘要;正文文件也不是日志或成果附件通道。")
return value
def render(args, config, body):
if args.command == "message":
title = args.title.strip()
text = body
else:
project = config.get("PROJECT") or ROOT.name
label = EVENTS[args.event]
title = f"{config.get('TITLE_PREFIX') or '[项目进度]'} {project}|{label}"
timestamp = dt.datetime.now().astimezone().strftime("%Y-%m-%d %H:%M:%S %z")
text = f"## {title}\n\n**项目:** {project} \n**状态:** {label} \n**更新时间:** {timestamp}\n\n---\n\n{body}"
if not title or "\n" in title or "\r" in title or len(title) > 200:
raise NoticeError("请使用不超过 200 字的单行中文标题。")
return title, text
def clean_env():
return {key: value for key, value in os.environ.items()
if key not in ("DING_SEC", "DWS_CLIENT_ID", "DWS_CLIENT_SECRET")
and not key.startswith(PREFIX)}
def secret_for(config):
# One source on every platform; never fall back to inherited credentials.
secret = config.get("CLIENT_SECRET", "")
if not secret:
raise NoticeError("未配置机器人凭据,请部署负责人在本地 config.env 填写 DINGTALK_PROGRESS_CLIENT_SECRET;不要在会话中提供密钥。")
return secret
def dws_path(config):
supplied = config.get("DWS_PATH")
path = shutil.which(supplied or "dws")
if not path:
raise NoticeError("未找到 dws,请部署负责人安装原生可执行文件并加入 Hub 的 PATH,或配置 DWS_PATH。")
if os.name == "nt" and Path(path).suffix.lower() != ".exe":
raise NoticeError("Windows 请使用原生 dws.exe(可配置 DWS_PATH),不通过 cmd/bat 转发 Markdown。")
return path
def json_object(output):
try:
value = json.loads(output)
return value if isinstance(value, dict) else {}
except (ValueError, TypeError):
return {}
def accepted_key(data):
value = data.get("processQueryKey")
return value.strip() if isinstance(value, str) and value.strip() else None
def invoke(command, env, sending=False, run=subprocess.run, sleep=time.sleep, stdin_data=None):
for attempt in range(1, ATTEMPTS + 1):
try:
result = run(command, env=env, input=stdin_data, capture_output=True, text=True,
encoding="utf-8", errors="replace", timeout=COMMAND_TIMEOUT)
except subprocess.TimeoutExpired:
raise NoticeError("钉钉调用超时,结果未知;本次不自动重发,请负责人核对。") from None
except OSError:
raise NoticeError("钉钉命令无法启动,请部署负责人检查安装和进程权限。") from None
data = json_object(result.stdout)
if sending and accepted_key(data):
return data
if result.returncode == 0:
if sending:
raise NoticeError("未收到有效发送回执,结果未知;本次不自动重发,请负责人核对。")
return data
if DNS_ERROR.search(result.stdout + "\n" + result.stderr):
if attempt < ATTEMPTS:
print(f"钉钉域名解析失败,10 秒后进行第 {attempt + 1}/3 次尝试。", file=sys.stderr)
sleep(RETRY_SECONDS)
continue
raise NoticeError("钉钉域名解析连续失败 3 次,本次通知已停止;主任务继续。")
raise NoticeError("钉钉调用未成功,未自动重发;请部署负责人检查认证、权限或网络,主任务继续。")
raise AssertionError("unreachable")
def mentions(args, config, cli, run=subprocess.run, sleep=time.sleep):
people = {}
for item in args.at_id:
name, sep, user_id = item.partition("=")
if not sep or not name.strip() or not user_id.strip() or re.search(r"\s", user_id):
raise NoticeError("已核实人员映射的格式应为 姓名=userId;不要自行编造编号。")
people[user_id.strip()] = name.strip()
names = list(dict.fromkeys(name.strip() for group in args.at
for name in group.replace(",", ",").split(",") if name.strip()))
if args.at and not names:
raise NoticeError("提醒对象不能为空。")
if names and not config.get("PROFILE"):
raise NoticeError("按姓名提醒需要部署负责人配置同组织的 PROFILE;不要临时切换或猜测账号。")
for name in names:
data = invoke([cli, "aisearch", "person", "--query", name, "--dimension", "name",
"--profile", config["PROFILE"], "--format", "json"],
clean_env(), run=run, sleep=sleep)
candidates = {}
rows = data.get("result", [])
if not isinstance(rows, list):
rows = []
for row in rows:
if not isinstance(row, dict):
continue
meta = row.get("meta") if isinstance(row.get("meta"), dict) else {}
actual_name = meta.get("name") or row.get("title") or row.get("author")
user_id = row.get("userId")
if actual_name == name and isinstance(user_id, str) and user_id.strip():
candidates[user_id] = name
if len(candidates) != 1:
raise NoticeError("提醒对象未唯一匹配,本条未发送;请负责人确认完整姓名或已有的有效人员映射。")
people.update(candidates)
return people
def main(argv=None, root=ROOT, run=subprocess.run, sleep=time.sleep):
args = parse_args(argv)
try:
config = read_config(root)
preview = args.dry_run or config.get("DRY_RUN") == "1"
if not preview and config.get("ENABLED") != "1":
emit("skipped", "钉钉通知未配置或未启用,已跳过。")
return 0
title, text = render(args, config, body_text(args, root))
if preview:
emit("preview", "仅离线预览,未验证群配置或提醒对象;未查人员、认证或发送,不输出配置中的密钥。",
title=title, text=text,
mentions_requested=args.command == "message" and bool(args.at or args.at_id))
return 0
if config.get("MODE", "app-bot") != "app-bot":
raise NoticeError("当前封装仅支持 app-bot,请部署负责人检查配置。")
if any(not config.get(key) for key in ("CLIENT_ID", "TARGET", "ROBOT_CODE")):
raise NoticeError("机器人配置不完整,请部署负责人检查,不要在会话中粘贴配置。")
cli = dws_path(config)
secret = secret_for(config)
people = mentions(args, config, cli, run, sleep) if args.command == "message" else {}
if people:
text += "\n\n" + " ".join("@" + user_id for user_id in people)
message = {"title": title, "text": text}
if people:
message["atUserIds"] = list(people)
payload = {
"msgParam": json.dumps(message, ensure_ascii=False), "msgKey": "sampleMarkdown",
"openConversationId": (args.group if args.command == "message" else None) or config["TARGET"],
"robotCode": config["ROBOT_CODE"],
}
# DWS accepts --data -: avoid shell quoting, argv length limits and
# exposing message/target contents in the process command line.
command = [cli, "api", "POST", "/v1.0/robot/groupMessages/send",
"--data", "-", "--yes", "--format", "json"]
if config.get("PROFILE"):
command += ["--profile", config["PROFILE"]]
env = clean_env()
env.update(DWS_CLIENT_ID=config["CLIENT_ID"], DWS_CLIENT_SECRET=secret)
data = invoke(command, env, sending=True, run=run, sleep=sleep,
stdin_data=json.dumps(payload, ensure_ascii=False))
emit("accepted", "钉钉服务端已接受通知,不代表群成员已读。", processQueryKey=accepted_key(data))
return 0
except NoticeError as error:
emit("error", str(error))
return 1
except (OSError, UnicodeError, subprocess.SubprocessError, ValueError):
emit("error", "通知配置、正文文件或本机调用异常;请部署负责人检查,本次停止且不影响主任务。")
return 1
if __name__ == "__main__":
for stream in (sys.stdout, sys.stderr):
if hasattr(stream, "reconfigure"):
stream.reconfigure(encoding="utf-8")
sys.exit(main())
+323
View File
@@ -0,0 +1,323 @@
"""Offline tests: fake configs, fake credentials, fake DWS. Never use live config."""
import contextlib
import io
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import unittest
from unittest import mock
import dingtalk_notify as notify
DNS = 'lookup api.dingtalk.com on 127.0.0.1:53: i/o timeout'
OK = '{"processQueryKey":"test-receipt"}'
def response(code=0, stdout=OK, stderr=''):
return subprocess.CompletedProcess(['fake-dws'], code, stdout, stderr)
class NotifyTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory(prefix='通知测试 with spaces ')
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
(self.root / '.dingtalk').mkdir()
self.config = self.root / '.dingtalk/config.env'
self.configure()
self.environment = mock.patch.dict(os.environ, {'DING_SEC': 'ignored-env-secret',
'DWS_CLIENT_SECRET': 'ignored-dws-secret'}, clear=True)
self.environment.start()
self.addCleanup(self.environment.stop)
self.locator = mock.patch.object(notify, 'dws_path', return_value='fake-dws')
self.locator.start()
self.addCleanup(self.locator.stop)
def configure(self, **updates):
values = dict(ENABLED='1', DRY_RUN='0', MODE='app-bot', CLIENT_ID='fake-client',
TARGET='fake-group', ROBOT_CODE='fake-robot', PROJECT='离线测试',
PROFILE='fake-corp:fake-user', CLIENT_SECRET='fake-secret')
values.update(updates)
self.config.write_text('\n'.join(notify.PREFIX + k + '=' + v for k, v in values.items()),
encoding='utf-8')
self.config.chmod(0o600)
def call(self, args, responses=None):
run = mock.Mock(side_effect=responses or [response()])
sleep = mock.Mock()
output, errors = io.StringIO(), io.StringIO()
with contextlib.redirect_stdout(output), contextlib.redirect_stderr(errors):
code = notify.main(args, root=self.root, run=run, sleep=sleep)
return code, json.loads(output.getvalue()), run, sleep, errors.getvalue()
def entries(self):
return [['progress', 'milestone', '**完成**\n\n- 内容'],
['message', '--title', '负责人提醒', '--text', '**请确认**\n\n- 内容']]
def test_events(self):
for event, label in notify.EVENTS.items():
with self.subTest(event=event):
code, data, run, _, _ = self.call(['progress', event, '中文摘要'])
self.assertEqual((code, data['status']), (0, 'accepted'))
payload = json.loads(run.call_args.kwargs['input'])
self.assertIn(label, json.loads(payload['msgParam'])['title'])
def test_payload_and_credentials_not_on_argv(self):
text = '**结果**\n\n- "中文" 😀 `代码` $变量 & <内容> \\路径'
code, data, run, _, _ = self.call(['message', '--title', '摘要', '--text', text])
self.assertEqual(code, 0)
command = run.call_args.args[0]
kwargs = run.call_args.kwargs
payload = json.loads(kwargs['input'])
self.assertEqual(json.loads(payload['msgParam'])['text'], text)
self.assertEqual(payload['openConversationId'], 'fake-group')
self.assertEqual(payload['msgKey'], 'sampleMarkdown')
self.assertEqual(command[command.index('--data') + 1], '-')
self.assertNotIn('fake-secret', str(command))
self.assertNotIn('fake-group', str(command))
self.assertEqual(kwargs['env']['DWS_CLIENT_SECRET'], 'fake-secret')
self.assertNotIn('DING_SEC', kwargs['env'])
self.assertNotIn('fake-secret', json.dumps(data))
def test_dns_retries_both_entries(self):
for args in self.entries():
with self.subTest(args=args):
code, data, run, sleep, _ = self.call(args, [response(1, '', DNS)] * 3)
self.assertEqual((code, data['status'], run.call_count), (1, 'error', 3))
self.assertEqual(sleep.call_args_list, [mock.call(10), mock.call(10)])
def test_transient_dns_success_stops(self):
for args in self.entries():
code, data, run, sleep, _ = self.call(args, [response(1, '', DNS), response()])
self.assertEqual((code, data['status'], run.call_count, sleep.call_count), (0, 'accepted', 2, 1))
def test_ambiguous_and_auth_errors_never_replayed_or_echoed(self):
for args in self.entries():
for error in ['i/o timeout', 'EOF', 'HTTP 503', 'permission denied', 'fake-secret App Token']:
with self.subTest(error=error):
code, data, run, sleep, stderr = self.call(args, [response(1, '', error)])
self.assertEqual((code, run.call_count, sleep.call_count), (1, 1, 0))
self.assertNotIn(error, json.dumps(data, ensure_ascii=False) + stderr)
def test_receipt_wins_even_nonzero(self):
for args in self.entries():
code, data, run, sleep, _ = self.call(args, [response(1, OK, DNS)])
self.assertEqual((code, data['status'], run.call_count, sleep.call_count), (0, 'accepted', 1, 0))
def test_bad_receipts_do_not_replay(self):
for raw in ['{}', '{"processQueryKey":" "}', '{"processQueryKey":123}', 'not json', '[]']:
code, _, run, sleep, _ = self.call(self.entries()[0], [response(0, raw)])
self.assertEqual((code, run.call_count, sleep.call_count), (1, 1, 0))
def test_timeout_no_replay(self):
code, data, run, sleep, _ = self.call(self.entries()[0], [subprocess.TimeoutExpired('fake', 60)])
self.assertEqual((code, run.call_count, sleep.call_count), (1, 1, 0))
self.assertIn('结果未知', data['message'])
self.assertEqual(run.call_args.kwargs['timeout'], 60)
def test_startup_failure_no_replay(self):
code, _, run, sleep, _ = self.call(self.entries()[0], [OSError('private machine detail')])
self.assertEqual((code, run.call_count, sleep.call_count), (1, 1, 0))
def test_missing_credentials_no_commands(self):
self.configure(CLIENT_SECRET='')
code, data, run, _, _ = self.call(self.entries()[0])
self.assertEqual((code, run.call_count), (1, 0))
self.assertIn('凭据', data['message'])
def test_skipped_no_tools(self):
self.configure(ENABLED='0')
for args in self.entries():
code, data, run, _, _ = self.call(args)
self.assertEqual((code, data['status'], run.call_count), (0, 'skipped', 0))
self.config.unlink()
self.assertEqual(self.call(self.entries()[0])[1]['status'], 'skipped')
def test_preview_never_uses_or_prints_secret_or_resolves_people(self):
for configured in [False, True]:
self.configure(DRY_RUN='1' if configured else '0')
for args in self.entries():
if args[0] == 'message':
args += ['--at', '完整姓名']
if not configured:
args += ['--dry-run']
with mock.patch.object(notify, 'secret_for', side_effect=AssertionError('secret accessed')):
code, data, run, _, _ = self.call(args)
self.assertEqual((code, data['status'], run.call_count), (0, 'preview', 0))
self.assertNotIn('fake-group', str(data))
self.assertNotIn('fake-secret', str(data))
def test_preview_without_secret(self):
self.configure(CLIENT_SECRET='')
code, data, run, _, _ = self.call(self.entries()[0] + ['--dry-run'])
self.assertEqual((code, data['status'], run.call_count), (0, 'preview', 0))
def test_config_cannot_be_used_as_body_even_in_preview(self):
for args in [['progress', 'milestone'], ['message', '--title', '摘要']]:
for flags in [[], ['--dry-run']]:
code, data, run, _, _ = self.call(args + ['--file', str(self.config)] + flags)
self.assertEqual((code, run.call_count), (1, 0))
self.assertNotIn('fake-secret', str(data))
def test_preview_without_config(self):
self.config.unlink()
self.assertEqual(self.call(self.entries()[0] + ['--dry-run'])[1]['status'], 'preview')
def test_utf8_bom_crlf_literal_config(self):
raw = self.config.read_text(encoding='utf-8') + '\nDINGTALK_PROGRESS_TITLE_PREFIX=$(not-executed)'
self.config.write_bytes(('\ufeff' + raw.replace('\n', '\r\n')).encode('utf-8'))
self.assertEqual(notify.read_config(self.root)['TITLE_PREFIX'], '$(not-executed)')
def test_invalid_config_safe_error(self):
for raw in ['DING_SEC=fake-secret', 'invalid fake-secret',
'DINGTALK_PROGRESS_ENABLED=1\nDINGTALK_PROGRESS_ENABLED=0',
'DINGTALK_PROGRESS_ENABLED=yes']:
self.config.write_text(raw, encoding='utf-8')
code, data, run, _, _ = self.call(self.entries()[0])
self.assertEqual((code, run.call_count), (1, 0))
self.assertNotIn('fake-secret', str(data))
def test_body_files_both_entries(self):
body = self.root / '中文 summary.md'
body.write_bytes('\ufeff**结果**\r\n\r\n- 已确认 😀'.encode('utf-8'))
for args in [['progress', 'milestone'], ['message', '--title', '摘要']]:
code, _, run, _, _ = self.call(args + ['--file', str(body)])
self.assertEqual(code, 0)
text = json.loads(json.loads(run.call_args.kwargs['input'])['msgParam'])['text']
self.assertIn('**结果**\n\n- 已确认 😀', text)
def test_empty_or_large_body_fails_before_tools(self):
for text in ['', ' ', '字' * 5000]:
code, _, run, _, _ = self.call(['message', '--title', '摘要', '--text', text])
self.assertEqual((code, run.call_count), (1, 0))
def test_exact_name_and_dedup_same_profile(self):
found = json.dumps({'result': [{'meta': {'name': '完整姓名'}, 'userId': 'fake-person'}]})
args = self.entries()[1] + ['--at', '完整姓名,完整姓名', '--at-id', '完整姓名=fake-person']
code, _, run, _, _ = self.call(args, [response(0, found), response()])
self.assertEqual((code, run.call_count), (0, 2))
for call in run.call_args_list:
command = call.args[0]
self.assertEqual(command[command.index('--profile') + 1], 'fake-corp:fake-user')
self.assertNotIn('DWS_CLIENT_SECRET', run.call_args_list[0].kwargs['env'])
message = json.loads(json.loads(run.call_args.kwargs['input'])['msgParam'])
self.assertEqual(message['atUserIds'], ['fake-person'])
self.assertTrue(message['text'].endswith('@fake-person'))
def test_names_zero_multiple_malformed_stop_before_send(self):
for rows in [[], [{'title': '完整姓名', 'userId': 'a'}, {'title': '完整姓名', 'userId': 'b'}],
[{'title': '别的姓名', 'userId': 'a'}], {}, [None]]:
code, _, run, sleep, _ = self.call(self.entries()[1] + ['--at', '完整姓名'],
[response(0, json.dumps({'result': rows}))])
self.assertEqual((code, run.call_count, sleep.call_count), (1, 1, 0))
def test_name_search_requires_explicit_profile(self):
self.configure(PROFILE='')
code, _, run, _, _ = self.call(self.entries()[1] + ['--at', '完整姓名'])
self.assertEqual((code, run.call_count), (1, 0))
def test_known_mapping_and_group_compatibility(self):
code, _, run, _, _ = self.call(self.entries()[1] + ['--at-id', '姓名=fake-id', '--group', 'fake-other'])
self.assertEqual(code, 0)
self.assertEqual(json.loads(run.call_args.kwargs['input'])['openConversationId'], 'fake-other')
def test_config_secret_is_only_source_on_all_platforms(self):
for platform in ['darwin', 'win32', 'linux']:
with self.subTest(platform=platform), mock.patch.object(sys, 'platform', platform):
self.assertEqual(notify.secret_for({'CLIENT_SECRET': 'fake-secret'}), 'fake-secret')
with self.assertRaises(notify.NoticeError):
notify.secret_for({})
def test_secret_special_characters_are_literal(self):
secret = 'fake=secret#literal$not-expanded'
self.configure(CLIENT_SECRET=secret)
code, data, run, _, _ = self.call(self.entries()[0])
self.assertEqual(code, 0)
self.assertEqual(run.call_args.kwargs['env']['DWS_CLIENT_SECRET'], secret)
self.assertNotIn(secret, str(data))
self.assertNotIn(secret, str(run.call_args.args))
self.assertNotIn(secret, run.call_args.kwargs['input'])
class LauncherTests(unittest.TestCase):
def test_real_subprocess_stdin_without_live_dws(self):
with tempfile.TemporaryDirectory(prefix='通知 fake transport ') as folder:
root = Path(folder)
(root / '.dingtalk').mkdir()
config = root / '.dingtalk/config.env'
config.write_text('DINGTALK_PROGRESS_ENABLED=1\nDINGTALK_PROGRESS_DRY_RUN=0\n'
'DINGTALK_PROGRESS_CLIENT_ID=fake-client\n'
'DINGTALK_PROGRESS_CLIENT_SECRET=fake-secret\n'
'DINGTALK_PROGRESS_TARGET=fake-group\n'
'DINGTALK_PROGRESS_ROBOT_CODE=fake-robot\n', encoding='utf-8')
config.chmod(0o600)
fake = root / 'fake dws.py'
fake.write_text(
'import json, os, sys\n'
'assert sys.argv[1:] == ["api", "POST", "/v1.0/robot/groupMessages/send", '
'"--data", "-", "--yes", "--format", "json"]\n'
'payload = json.loads(sys.stdin.buffer.read().decode("utf-8"))\n'
'assert payload["openConversationId"] == "fake-group"\n'
'assert json.loads(payload["msgParam"])["text"] == "中文\\n\\n- \\\"引号\\\" 😀"\n'
'assert os.environ["DWS_CLIENT_SECRET"] == "fake-secret"\n'
'assert "DING_SEC" not in os.environ\n'
'print(json.dumps({"processQueryKey": "fake-transport-receipt"}))\n', encoding='utf-8')
def transport(command, **kwargs):
return subprocess.run([sys.executable, str(fake), *command[1:]], **kwargs)
out = io.StringIO()
with mock.patch.object(notify, 'dws_path', return_value='fake-dws'), \
mock.patch.dict(os.environ, {'DING_SEC': 'ignored-env-secret'}), \
contextlib.redirect_stdout(out):
code = notify.main(['message', '--title', '摘要', '--text', '中文\n\n- "引号" 😀'],
root=root, run=transport)
self.assertEqual(code, 0, out.getvalue())
self.assertEqual(json.loads(out.getvalue())['status'], 'accepted')
def test_real_launchers_preview_in_isolated_copy(self):
"""Exercise OS processes/quoting, but copy no real config and never run dws."""
with tempfile.TemporaryDirectory(prefix='通知 launch with spaces ') as folder:
scripts = Path(folder) / 'scripts'
scripts.mkdir()
for name in ['dingtalk_notify.py', 'dingtalk-progress', 'dingtalk-bot-message',
'dingtalk-progress.ps1', 'dingtalk-bot-message.ps1']:
shutil.copy2(Path(__file__).parent / name, scripts / name)
text = '**结果**\n\n- "中文" 😀 `代码` $变量 & 内容'
body = Path(folder) / '中文正文.md'
body.write_text(text, encoding='utf-8')
env = {k: v for k, v in os.environ.items()
if not k.startswith(('DING', 'DWS_'))}
# Run from outside the script project: --file is cwd-relative.
commands = [[sys.executable, str(scripts / 'dingtalk_notify.py'), 'progress', 'milestone',
'--file', body.name, '--dry-run']]
pwsh = shutil.which('pwsh')
if os.name == 'nt':
self.assertIsNotNone(pwsh, 'Windows launcher test needs PowerShell 7.3+')
if pwsh:
commands += [[pwsh, '-NoProfile', '-File', str(scripts / 'dingtalk-progress.ps1'),
'milestone', text, '--dry-run'],
[pwsh, '-NoProfile', '-File', str(scripts / 'dingtalk-bot-message.ps1'),
'--title', '中文摘要', '--text', text, '--dry-run']]
bash = Path('C:/Program Files/Git/bin/bash.exe') if os.name == 'nt' else shutil.which('bash')
if bash and Path(bash).exists():
for name, args in [('dingtalk-progress', ['milestone']),
('dingtalk-bot-message', ['--title', '中文摘要'])]:
commands.append([str(bash), str(scripts / name), *args,
'--file', body.name, '--dry-run'])
for command in commands:
with self.subTest(entry=command[:3]):
result = subprocess.run(command, cwd=folder, env=env, capture_output=True,
text=True, encoding='utf-8', timeout=30)
self.assertEqual(result.returncode, 0, result.stderr)
data = json.loads(result.stdout)
self.assertEqual(data['status'], 'preview')
self.assertIn(text, data['text'])
if __name__ == '__main__':
unittest.main()