4 Commits
21 changed files with 874 additions and 317 deletions

No files matched your search

+49 -66
View File
@@ -1,83 +1,66 @@
# 钉钉进度通知参考 # 个人 DWS 项目通知
这里的脚本和配置只演示 Hub 如何向项目群发送辅助通知,不属于 Etunel 的任务通信主链路,也不能代替任务派发、负责人确认、阶段门禁或项目留档。成员 Agent 不调用本脚本。 Hub 使用负责人确认的个人 DWS 账号发阶段汇报和 @ 提醒。只需要原生 DWS、Python 3.9+ 和一个项目配置文件;不使用机器人、应用密钥、Webhook 或 Stream 监听。
当前布局刻意把工具和项目配置分开: **旧版升级:** 用新的个人账号配置替换旧配置,旧机器人配置不会自动转成个人发送授权。摘要入口已改名为 `dingtalk-message`/`dingtalk-message.ps1`,删除目标部署中的旧 `dingtalk-bot-message` 入口及 Stream 实验脚本。更新脚本和 Hub Skill/Hook 不等于已经迁移在途项目;按实际部署安排确认。
- `dingtalk-workspace-cli@1.0.60` 由 Bun 安装在用户级全局目录,项目不保存二进制或 `node_modules`。 ## 首次由 Hub 完成配置
- 官方 multi skills 保留在项目 `.agents/skills/dingtalk-*`,只对本项目生效。
- Client ID、群目标、`robotCode` 和汇报开关保存在项目 `.dingtalk/config.env`。
- Client Secret 从环境变量 `DING_SEC` 读取,未设置时回退到 macOS Keychain;脚本只临时注入给 `dws api`,App Token 由 DWS 自动获取和缓存。
项目统一通过稳定入口调用全局命令: 负责人不需要找编号、复制配置模板或填写密钥。Hub 在项目初始化时执行:
```sh 1. 检查本机 DWS;未登录时请负责人通过官方 DWS 登录流程授权,不索取密码或 Token。
./scripts/dws version 2. 用 `dws profile list --format json` 查看可用账号,以姓名和组织向负责人确认发送身份。
``` 3. 在选定的同一账号下,用 `dws chat +chat-search --query "负责人提供的群名称" --page-all --profile "查询返回的账号标识" --format json` 查群;结果不完整或有多个候选时不能自行选第一个。
4. 一次问清:“是否由这个账号向这个项目群自动发送进度、阻塞和必要的 @ 提醒?”得到确认后,Hub 把工具实际返回的账号标识、群标识及项目名称传给初始化入口。下面为参数结构,不是需要人填写的编号表:
Bun 默认阻止上游 `postinstall`;本机安装时已校验官方 SHA-256 并只释放 DWS 原生二进制,没有执行会发布全局 Skills 的安装脚本。升级 DWS 时也应保持这一边界。 ```text
python scripts/dingtalk_notify.py init --profile "实际查询结果" --group "实际查询结果" --project "项目名称" --yes
```
## 应用身份(无需用户登录) 初始化检查账号存在且可登录,创建 `.dingtalk/config.env` 并启用通知;**不会发测试消息,也不会替人确认群选择**。群标识必须来自该账号的查询结果。初始化不创建钉钉群、不申请权限。已有配置默认不覆盖;迁移或变更账号、群时重新确认,再加 `--replace`,原配置被替换且不另存含密钥的备份。
本项目不执行 `dws auth login`。进度脚本通过 `dws api` 调用企业内部应用 OpenAPI: 配置仅 4 项:
| 字段 | 含义 | 谁填写 |
| --- | --- | --- |
| `DINGTALK_PROGRESS_ENABLED` | `1` 启用、`0` 暂停 | 初始化写入,负责人可随时要求暂停 |
| `DINGTALK_PROGRESS_PROFILE` | 固定个人账号 | Hub 使用 DWS 真实返回值 |
| `DINGTALK_PROGRESS_TARGET` | 固定项目群 | Hub 使用同账号查询并经确认的结果 |
| `DINGTALK_PROGRESS_PROJECT` | 项目名称 | Hub 使用已确认名称 |
配置不包含凭据,但仍是本机信息,本仓库已排除出 Git;Hub 在其他目标项目初始化时也应把 `.dingtalk/config.env` 加入该项目的 Git 忽略规则。不要把它或登录态复制给其他同事;各部署分别确认账号和群。登录由 DWS 自己管理,后续切换 DWS 默认账号不会改变本项目的发送身份。
## Windows 与 macOS/Linux
- Windows 使用 PowerShell 7.3+,优先运行 `./scripts/dingtalk-python.ps1 init ...`、`check ...`;事件用 `./scripts/dingtalk-progress.ps1 ...`,提醒用 `./scripts/dingtalk-message.ps1 ...`。
- PowerShell 共用启动器选择独立 Python,避免商店版 Python 无法读取终端的 DWS 登录。必要时用 `DINGTALK_PYTHON` 指定独立解释器路径;原生 `dws.exe` 须在当前进程 PATH 中。
- macOS/Linux 通常用 `python3 scripts/dingtalk_notify.py ...`;也可用 `sh scripts/dingtalk-progress ...`、`sh scripts/dingtalk-message ...`。初始化生成的配置在 POSIX 系统为仅当前用户读写;Windows 使用部署目录的访问权限。
- 将共用 Python 文件和所需平台入口放在目标 Hub 项目的 `scripts/` 中。安装职责 Skill 不会自动部署这些脚本。
DWS 安装与登录以 [官方项目](https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli) 和当前命令帮助为准;不要为通知分发机器人应用密钥。
## 日常调用
```text ```text
POST https://api.dingtalk.com/v1.0/robot/groupMessages/send python scripts/dingtalk_notify.py progress milestone "**已完成**:阶段成果已归档。下一步安排联调。"
python scripts/dingtalk_notify.py progress blocked "缺少接口说明,请补充后回自己的角色会话同步。" --at "完整姓名"
python scripts/dingtalk_notify.py message --title "资料补充提醒" --text "请补齐已约定资料,并回自己的角色会话确认。" --at "完整姓名"
``` ```
管理员侧只需确保: 通知保留 AI 标识并标注自动汇报;摘要用简短中文 Markdown。`--at` 按完整姓名查询唯一人员,再生成个人消息的原生提及标记。两个发送命令都支持 `--file` 正文和完全离线的 `--dry-run`。不支持临时换群、手填人员编号或退回机器人发送。
- Client ID/Secret 属于企业内部应用; 事件含义见 [通知规则](../etunel-role-collaboration/references/dingtalk-progress-reporting.md),详细调用与结果见 [脚本用法](../etunel-role-collaboration/references/dingtalk-script-usage.md)。
- 应用已有“企业内机器人发送消息”权限(`qyapi_robot_sendmsg`);
- `robotCode` 属于同一个应用;
- 机器人已加入目标 `openConversationId` 对应的群。
当前使用 zsh 环境变量时,先确保它已导出(不要把真实值写进项目文件): ## 检查与失败处理
- `python scripts/dingtalk_notify.py check --at "完整姓名"`:只检查固定账号登录和可选姓名,不发消息,不证明实际 @ 提醒成功。不是每条通知的必做步骤。
- `accepted` 加有效 `openTaskId`:个人发送请求已接受,不等于已投递或已读。必要时在同账号下用 `dws chat +messages-query-send-status --open-task-id "回执中的实际值" --profile "配置中的账号" --format json` 只读查询;不把任务回执当消息编号,不循环重发。
- 仅明确 DNS 解析失败最多尝试 3 次、间隔 10 秒;认证、权限或未知结果停止。登录失效请负责人恢复登录,不切账号、不退回机器人。通知失败不阻塞 Etunel 主任务。
维护时只做离线测试;真实发送必须另获明确授权:
```sh ```sh
export DING_SEC='<Client Secret>' python -X utf8 scripts/test_dingtalk_notify.py
bash -n scripts/dingtalk-progress
bash -n scripts/dingtalk-message
``` ```
也可以不设置 `DING_SEC`,改用本项目专用的 macOS Keychain 项。`-w` 必须放在最后,它会在终端中隐藏输入:
```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
```
`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,重点写已经确认的结果、下一步或阻塞原因,不要粘贴大段日志。
+5 -17
View File
@@ -1,19 +1,7 @@
# Copy this file to .dingtalk/config.env, fill the selected mode, then chmod 600 it. # Hub 查询账号和群、取得负责人确认后,用 init 生成真实 config.env。
# Values are literal text after '='; do not add shell quotes. # 不需手工复制或填编号;不保存机器人密钥或个人登录凭据。
# 等号右侧为字面值,不加 Shell 引号。ENABLED=0 暂停通知。
# Keep both switches safe until credential setup and a dry-run have succeeded.
DINGTALK_PROGRESS_ENABLED=0 DINGTALK_PROGRESS_ENABLED=0
DINGTALK_PROGRESS_DRY_RUN=1 DINGTALK_PROGRESS_PROFILE=
# Enterprise-internal app bot: App Token via dws api; no user OAuth login.
DINGTALK_PROGRESS_MODE=app-bot
DINGTALK_PROGRESS_CLIENT_ID=
DINGTALK_PROGRESS_TARGET= DINGTALK_PROGRESS_TARGET=
DINGTALK_PROGRESS_ROBOT_CODE= DINGTALK_PROGRESS_PROJECT=
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.
DINGTALK_PROGRESS_PROJECT=你的项目名称
DINGTALK_PROGRESS_TITLE_PREFIX=[项目进度]
+6 -1
View File
@@ -1,5 +1,8 @@
* text=auto * text=auto
.gitattributes text eol=lf
.gitignore text eol=lf
.dingtalk/config.env.example text eol=lf
.dingtalk/config.env
*.md text eol=lf *.md text eol=lf
*.txt text eol=lf *.txt text eol=lf
*.html text eol=lf *.html text eol=lf
@@ -8,4 +11,6 @@
*.json text eol=lf *.json text eol=lf
*.py text eol=lf *.py text eol=lf
*.sh text eol=lf *.sh text eol=lf
*.ps1 text eol=lf
scripts/dingtalk-progress text eol=lf scripts/dingtalk-progress text eol=lf
scripts/dingtalk-message text eol=lf
+2
View File
@@ -1,5 +1,7 @@
# Local credentials and machine-specific configuration # Local credentials and machine-specific configuration
.dingtalk/config.env .dingtalk/config.env
.dingtalk/config.env.*
!.dingtalk/config.env.example
**/.env **/.env
**/.env.* **/.env.*
!**/.env.example !**/.env.example
+13 -9
View File
@@ -9,7 +9,7 @@
- `doc/`:当前流程设计基线和模板参考资料; - `doc/`:当前流程设计基线和模板参考资料;
- 钉钉参考集成:仅帮助 Hub 理解如何调用辅助通知,不属于 Etunel 任务通信主链路。 - 钉钉参考集成:仅帮助 Hub 理解如何调用辅助通知,不属于 Etunel 任务通信主链路。
这是以 Markdown、HTML 和 Shell 为主的约束仓库,没有需要猜测的安装或构建步骤。 这是以 Markdown、HTML 为主、附带 Python 和平台启动脚本的约束仓库,没有需要猜测的应用构建步骤。
## Scope ## Scope
@@ -49,7 +49,7 @@
| `etunel-role-hook-contexts/` | 八角色 Hook 的完整精简职责与每轮提醒 | 独立于 Skill 发现树;简短、可直接注入、与正式职责一致 | | `etunel-role-hook-contexts/` | 八角色 Hook 的完整精简职责与每轮提醒 | 独立于 Skill 发现树;简短、可直接注入、与正式职责一致 |
| `doc/` | 当前流程来源及模板参考 | 固定路径维护一份当前文档;不建日期或版本副本,历史查 Git;变更同步核对链接与基线说明 | | `doc/` | 当前流程来源及模板参考 | 固定路径维护一份当前文档;不建日期或版本副本,历史查 Git;变更同步核对链接与基线说明 |
| `.agents/skills/dingtalk-*` | 钉钉官方能力的项目内参考副本 | 不是 Etunel 核心 Skill;除非任务明确要求更新参考包,否则不要批量改动 | | `.agents/skills/dingtalk-*` | 钉钉官方能力的项目内参考副本 | 不是 Etunel 核心 Skill;除非任务明确要求更新参考包,否则不要批量改动 |
| `.dingtalk/`、`scripts/dingtalk-progress` | Hub 通知接入说明、示例配置和当前演示封装 | 不把通知能力扩大为任务派发、审批、门禁或事实源 | | `.dingtalk/`、`scripts/` 中的钉钉封装 | Hub 通知配置说明、跨平台入口、共用 Python 实现和离线测试 | 不把通知能力扩大为任务派发、审批、门禁或事实源 |
修改共享流程、角色边界、阶段、正式成果或跨角色信息流时,必须同步检查根 Skill、相关公共 reference、受影响角色 reference 和对应 Hook。角色细节优先下沉到角色文件;测试专项优先下沉到测试二级 reference。不要靠复制整段规则维持一致性。 修改共享流程、角色边界、阶段、正式成果或跨角色信息流时,必须同步检查根 Skill、相关公共 reference、受影响角色 reference 和对应 Hook。角色细节优先下沉到角色文件;测试专项优先下沉到测试二级 reference。不要靠复制整段规则维持一致性。
@@ -61,10 +61,10 @@
- 只有 Hub/主 Agent 可以调用通知;成员 Agent 只向 Hub 返回成果、状态或阻塞。 - 只有 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-message` Shell、PowerShell 入口,统一以已确认的个人 DWS 账号发送。进度事件仍限定为 `start`、`milestone`、`blocked`、`complete`、`failed`;负责人提醒不新增状态。同一事项不重复通知,不得绕过封装直接调用 OpenAPI 或 `dws api`。可验证里程碑先完成项目留档,再发送简短中文 Markdown。例如:
```sh ```sh
./scripts/dingtalk-progress milestone "**已完成** python scripts/dingtalk_notify.py progress milestone "**已完成**
- 阶段成果已确认并归档 - 阶段成果已确认并归档
@@ -73,9 +73,10 @@
- Hub 安排后续任务" - Hub 安排后续任务"
``` ```
- 事件语义与消息格式以 [dingtalk-progress-reporting.md](etunel-role-collaboration/references/dingtalk-progress-reporting.md) 为准;安装与本机配置参考 [.dingtalk/README.md](.dingtalk/README.md)。 - 事件语义与消息格式以 [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 或其他凭据和内部标识。 - Hub 首次查询个人账号及项目群,以姓名、组织和群名取得负责人确认后,用封装 `init` 创建 `.dingtalk/config.env`;只含通知开关、固定账号、群和项目名,不保存机器人或登录密钥。AI 可以在授权范围内维护该配置,但不提交或在群里展示内部标识;换账号或群须重新确认。
- 不读取、输出或提交 DWS 登录凭据、Client Secret、`DING_SEC`、App Token。旧机器人配置须经确认重新初始化,不静默启用个人代发;保留 AI 标识,个人通知不代表负责人亲自批准。
## 编辑约定 ## 编辑约定
@@ -89,8 +90,9 @@
## 版本与发布 ## 版本与发布
- 全仓库统一使用 `v主版本.次版本.修订版本` 发布,Git 标签与发布包确定该版 Skill、Hook 和文档;组件不另设发布序列。流程图版本只表示设计基线,不决定每次发布号,流程未变不为凑版本改图。 - 全仓库统一使用三位版本 `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 和在途项目影响;具体说明见 [README 版本与升级](README.md#版本与升级)。
- 仅在用户要求时提交、推送或发版;已发布标签不可覆盖或移动,修正另发新版本。推送后核验远程分支与标签提交,不能把本地提交当成已发布。 - 仅在用户要求时提交、推送或发版;已发布标签不可覆盖或移动,修正另发新版本。推送后核验远程分支与标签提交,不能把本地提交当成已发布。
- 发布不等于部署。仅更新受影响的 Skill/Hook 配置,插件缓存更新及在途项目迁移仍须明确确认;不要求角色每轮核对发布号,不将仓库发版规则塞进运行时 Skill。 - 发布不等于部署。仅更新受影响的 Skill/Hook 配置,插件缓存更新及在途项目迁移仍须明确确认;不要求角色每轮核对发布号,不将仓库发版规则塞进运行时 Skill。
@@ -101,7 +103,9 @@
```powershell ```powershell
rtk python -X utf8 "$env:USERPROFILE\.codex\skills\.system\skill-creator\scripts\quick_validate.py" "etunel-role-collaboration" 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-progress
rtk "C:\Program Files\Git\bin\bash.exe" -n scripts/dingtalk-message
rtk proxy git diff --check rtk proxy git diff --check
rtk proxy git status --short rtk proxy git status --short
``` ```
@@ -111,7 +115,7 @@ Skill Creator 的验证脚本位置取决于协作者的 Codex 安装。若默
## Definition of Done ## Definition of Done
- 修改 Skill 时已通过 Skill Creator 的结构校验。 - 修改 Skill 时已通过 Skill Creator 的结构校验。
- 仅在修改 `scripts/dingtalk-progress` 时执行 Shell 语法检查。 - 修改钉钉封装时运行离线测试;修改 Shell 入口时检查语法,修改 PowerShell 入口时核验解析和实际参数传递。只使用隔离假配置,不触发真实通知。
- 所有新增或改动的 Markdown 相对链接均能解析。 - 所有新增或改动的 Markdown 相对链接均能解析。
- 文件中没有待办标记、占位内容、真实凭据、群标识或误导性的缺失文件引用。 - 文件中没有待办标记、占位内容、真实凭据、群标识或误导性的缺失文件引用。
- 根 Skill、公共 reference、角色 reference 和对应 Hook 在受影响规则上保持一致。 - 根 Skill、公共 reference、角色 reference 和对应 Hook 在受影响规则上保持一致。
+27 -17
View File
@@ -2,7 +2,7 @@
本仓库为 Etunel 多 Agent 项目提供一套中文版、渐进式披露的角色职责与协作流程:项目Hub负责跨角色中继和全局推进,七个成员角色在各自真人负责人参与下完成专业任务,并以可追踪成果交回 Hub。 本仓库为 Etunel 多 Agent 项目提供一套中文版、渐进式披露的角色职责与协作流程:项目Hub负责跨角色中继和全局推进,七个成员角色在各自真人负责人参与下完成专业任务,并以可追踪成果交回 Hub。
> **钉钉定位说明:** 仓库中的 `.agents/skills/dingtalk-*`、`.dingtalk/` 和 `scripts/dingtalk-progress` 只作为参考与演示,用来告诉 Hub 如何调用钉钉发送项目进度或异常通知。钉钉不是 Etunel 的角色通信链路、任务队列、审批、门禁或项目事实源,也不会改变任何角色边界。 > **钉钉定位说明:** 仓库中的 `.agents/skills/dingtalk-*`、`.dingtalk/` 和 `scripts/` 下的钉钉封装只作为接入参考,用来告诉 Hub 如何发送项目进度、异常摘要或负责人提醒。钉钉不是 Etunel 的角色通信链路、任务队列、审批、门禁或项目事实源,也不会改变任何角色边界。
## 先了解这三件事 ## 先了解这三件事
@@ -14,23 +14,19 @@
## 版本与升级 ## 版本与升级
本版为 `v1.0.1`,流程设计基线仍为 **V1.0**。发布版本表示一套可领取的仓库文件,流程基线表示其依据的已确认流程,两者不必同步递增。正式发布以 Git 标签和对应发布包为准,主分支可能包含尚未发布的修改。 本版为 `v1.0.4`,流程设计基线仍为 **V1.0**。版本采用 `vX.Y.Z`:前两位 `X.Y` 跟随 HTML 主流程中明确标注且已确认的版本,第三位 `Z` 表示该流程版本下的仓库发布迭代。正式发布以 Git 标签和对应发布包为准,主分支可能包含尚未发布的修改。
| 变更 | 版本规则 | | 变更 | 版本规则 |
| --- | --- | | --- | --- |
| 不改变既定角色边界、必需成果或阶段条件的纠错、澄清与执行优化 | 升修订号,例如 `1.0.0 → 1.0.1` | | HTML 流程版本不变,更新 Skill、Hook、脚本、配置或说明等任意内容 | 只递增第三位,例如流程保持 V1.0,`v1.0.1 → v1.0.2` |
| 增加可选能力或场景,原有项目仍能按原约定执行 | 升次版本号,例如 `1.0.1 → 1.1.0` | | HTML 流程正式更新到新的 X.Y | 前两位同步,第三位从 0 开始,例如流程 V1.1 对应 `v1.1.0` |
| 改变既有职责、成果或阶段门禁,旧项目需重新确认执行方式 | 升主版本号,例如 `1.x.x → 2.0.0` | | 日常提交,尚未安排发布 | 不必为每次提交创建标签;下一次正式发版按上述规则编号 |
全仓库只维护这一套发布序列,不给 Skill、Hook 分别编号。流程设计变更先确认,再同步受影响文件;只修正 Skill 执行说明时,不必改流程图版本。日常修改正常提交,需要交给团队使用时才统一校验、确定版本、创建 Git 标签及发布说明;已发布标签和内容不覆盖,修正发新版本。 全仓库只维护这一套发布序列,不给 Skill、Hook 或脚本分别编号。前两位不按功能规模、变更文件数或兼容性自行调整,也不为新增脚本等非流程更新修改 HTML 版本。这不是按变更性质升主/次版本的语义化版本规则,第三位更新也可能需要配置迁移;职责或门禁变更仍须先确认流程。日常修改正常提交,需要交给团队使用时才统一校验、确定版本、创建 Git 标签及发布说明;已发布标签和内容不覆盖,修正发新版本。
发布说明应写清:流程基线、改了什么、使用者需要更新哪些文件、在途项目是否需要重新确认。不要把“仓库已发布”写成“角色已更新”。更新插件缓存、Etunel 中独立配置的 Hook 或迁移在途项目,都需另外明确确认;已运行项目继续使用其登记基线。 发布说明应写清:流程基线、改了什么、使用者需要更新哪些文件、在途项目是否需要重新确认。不要把“仓库已发布”写成“角色已更新”。更新插件缓存、Etunel 中独立配置的 Hook 或迁移在途项目,都需另外明确确认;已运行项目继续使用其登记基线。
### v1.0.1 更新范围 各版本的变更、验证结果和升级注意事项统一查看[发布说明](https://git.playones.com/chenmingxuan/EP-Hub-Skill/releases)。README 只维护当前项目介绍和使用入口,不累积逐版更新记录。
- 根据一轮项目复盘,补清旧任务收尾、开工输入与本次产出的区分、资料异常交专业角色核实、查询无新线索时转交负责人决定,以及模拟不默认跳过交接。
- 更新核心 Skill;Hook 仅调整中心与产品两份,保持完整职责 JSON 和每轮提醒分开配置。产品 Hook 修改的是完整职责区块,中心两个区块均有调整。
- 八角色、八阶段、正式成果、原有审批与测试验收条件不变;不自动迁移在途项目、不关闭真实遗留问题。本版不涉及钉钉脚本更新。
## 协作模型 ## 协作模型
@@ -100,6 +96,7 @@ EP-Hub-Skill/
│ ├── human-readable-communication.md │ ├── human-readable-communication.md
│ ├── project-files-and-progress.md │ ├── project-files-and-progress.md
│ ├── dingtalk-progress-reporting.md │ ├── dingtalk-progress-reporting.md
│ ├── dingtalk-script-usage.md # 通知的跨平台调用与负责人提醒
│ ├── roles/ # 七个成员角色职责 │ ├── roles/ # 七个成员角色职责
│ └── testing/ # 测试任务按需读取的二级细则 │ └── testing/ # 测试任务按需读取的二级细则
├── etunel-role-hook-contexts/ # 八角色独立 Hook 源文件 ├── etunel-role-hook-contexts/ # 八角色独立 Hook 源文件
@@ -108,7 +105,14 @@ EP-Hub-Skill/
│ └── 文档模板参考/ # 按任务读取的参考笔记与来源清单 │ └── 文档模板参考/ # 按任务读取的参考笔记与来源清单
├── .agents/skills/dingtalk-*/ # 钉钉官方能力参考副本,不是核心 Skill ├── .agents/skills/dingtalk-*/ # 钉钉官方能力参考副本,不是核心 Skill
├── .dingtalk/ # 通知接入说明与无秘密示例配置 ├── .dingtalk/ # 通知接入说明与无秘密示例配置
└── scripts/dingtalk-progress # Hub 钉钉通知的当前演示封装 └── scripts/ # Hub 辅助通知,不是 Etunel 任务机制
├── dingtalk_notify.py # Python 共用实现,含有限重试
├── dingtalk-progress # macOS/Linux 进度入口
├── dingtalk-message # macOS/Linux 个人摘要与负责人提醒
├── dingtalk-progress.ps1 # Windows PowerShell 进度入口
├── dingtalk-message.ps1 # Windows PowerShell 个人摘要与提醒
├── dingtalk-python.ps1 # Windows 共用启动器,含初始化与检查
└── test_dingtalk_notify.py # 不联网的隔离测试
``` ```
Skill 被用于实际项目时,Hub 在目标项目中维护统一的 `项目资料/`,而不是把项目成果写回本约束仓库: Skill 被用于实际项目时,Hub 在目标项目中维护统一的 `项目资料/`,而不是把项目成果写回本约束仓库:
@@ -159,17 +163,21 @@ Hook 文件需保持短小,并与完整 Skill 职责一致。它们由 Etunel
钉钉集成是 Hub 的辅助通知参考,不是 Etunel 协作系统的一部分。仓库保留它,是为了让 Hub 在满足事件条件时有一个清晰、安全、可替换的调用方法。 钉钉集成是 Hub 的辅助通知参考,不是 Etunel 协作系统的一部分。仓库保留它,是为了让 Hub 在满足事件条件时有一个清晰、安全、可替换的调用方法。
只有 Hub/主 Agent 可以使用下面的封装入口: 只有 Hub/主 Agent 可以使用下面的通用 Python 封装入口(Python 3.9+):
```sh ```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 先完成成果和进度留档,再发送钉钉消息。成员角色不得发送钉钉消息,只把结果交给 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 入口。它们是同一封装,不要重复调用。
本地 `.dingtalk/config.env` 已被 Git 忽略。不要读取、输出或提交其中的配置;不要在文档、Skill、Hook 或脚本中硬编码 Client Secret、Client ID、`robotCode`、`openConversationId`、App Token 或群名。仓库维护时也不要用真实发送作为普通测试。 所有通知统一使用负责人确认的个人 DWS 登录,不再使用机器人。Hub 在首次初始化时查询账号和项目群,用姓名、组织和群名取得确认后,调用 `init` 自动生成配置;人不用找编号或填密钥。配置只保留通知开关、固定账号、固定群和项目名,个人登录凭据由 DWS 管理。`progress` 也支持 `--at`,事件汇报和行动提醒可合成一条;保留 AI 标识和自动汇报说明,不冒充本人批准。
事件语义、有限重试和结果判定见 [钉钉项目进度汇报](etunel-role-collaboration/references/dingtalk-progress-reporting.md),命令细节见 [通知脚本用法](etunel-role-collaboration/references/dingtalk-script-usage.md);跨平台安装、初始化和旧配置迁移见 [.dingtalk/README.md](.dingtalk/README.md)。`.agents/skills/dingtalk-*` 是钉钉官方多 Skill 的项目内参考副本,不应被当作 Etunel 核心 Skill,也不应被成员用来绕过 Hub。
本地 `.dingtalk/config.env` 已被 Git 忽略,Hub 可在负责人授权范围内创建或修改;不要提交配置、硬编码真实账号/群标识,或读取和传播 DWS 凭据。`accepted` 表示取得个人发送任务回执,不等于已投递、已读或接收端 @ 提醒成功。仓库维护时不要用真实发送作为普通测试。
## 修改项目的推荐流程 ## 修改项目的推荐流程
@@ -190,10 +198,12 @@ git status --short
修改核心 Skill 后,使用已安装的 Skill Creator 对 `etunel-role-collaboration/` 运行 `scripts/quick_validate.py`。Skill Creator 的位置取决于协作者的 Codex 安装,不要为此把验证器复制进仓库。 修改核心 Skill 后,使用已安装的 Skill Creator 对 `etunel-role-collaboration/` 运行 `scripts/quick_validate.py`。Skill Creator 的位置取决于协作者的 Codex 安装,不要为此把验证器复制进仓库。
修改钉钉演示脚本后再运行: 修改钉钉封装后运行隔离测试;Shell 入口有修改时再检查对应语法:
```sh ```sh
python -X utf8 scripts/test_dingtalk_notify.py
bash -n scripts/dingtalk-progress bash -n scripts/dingtalk-progress
bash -n scripts/dingtalk-message
``` ```
还应人工确认: 还应人工确认:
+2 -2
View File
@@ -53,13 +53,13 @@ Etunel 负责当前角色和会话绑定。不要向负责人询问或复述 rol
- [嵌入式底层](references/roles/embedded-lowlevel.md) - [嵌入式底层](references/roles/embedded-lowlevel.md)
- [硬件](references/roles/hardware.md) - [硬件](references/roles/hardware.md)
- [测试](references/roles/testing.md) - [测试](references/roles/testing.md)
- 准备派发、回复、跨角色中继、完成或报告阻塞:读 [Etunel 任务消息流程](references/etunel-message-lifecycle.md)。 - 准备派发、回复、跨角色中继、完成、报告阻塞或使用消息附加说明:读 [Etunel 任务消息流程](references/etunel-message-lifecycle.md),按需用可选 `context` 概括事项,完整要求仍留在正文。
- 定义或校验成果、状态、版本、证据、额外产出或豁免:读 [成果与完成判定](references/artifacts-and-evidence.md)。 - 定义或校验成果、状态、版本、证据、额外产出或豁免:读 [成果与完成判定](references/artifacts-and-evidence.md)。
- 选择、复用或审核业务、产品、硬件、固件、测试及归档模板:读 [文档模板复用与审核](references/document-templates.md),再按实际任务定位原件;模板不自行改变职责、正式成果或门禁。 - 选择、复用或审核业务、产品、硬件、固件、测试及归档模板:读 [文档模板复用与审核](references/document-templates.md),再按实际任务定位原件;模板不自行改变职责、正式成果或门禁。
- 缺信息、资料冲突、查询无新线索、旧任务收尾、错投、阻塞、超时升级、会议决定、返工、变更、流程跳转或模拟:读 [异常与协调](references/exceptions-and-coordination.md)。 - 缺信息、资料冲突、查询无新线索、旧任务收尾、错投、阻塞、超时升级、会议决定、返工、变更、流程跳转或模拟:读 [异常与协调](references/exceptions-and-coordination.md)。
- 编写给负责人、角色或群组的任务、提问、结果和状态消息:读 [人类可读沟通](references/human-readable-communication.md)。 - 编写给负责人、角色或群组的任务、提问、结果和状态消息:读 [人类可读沟通](references/human-readable-communication.md)。
- 创建项目资料目录、保存成员清单、归档成果、更新进度或阶段记录:读 [项目文件与进度留档](references/project-files-and-progress.md)。 - 创建项目资料目录、保存成员清单、归档成果、更新进度或阶段记录:读 [项目文件与进度留档](references/project-files-and-progress.md)。
- 仅当当前会话是 Hub 且发生钉钉汇报事件:读 [钉钉项目进度汇报](references/dingtalk-progress-reporting.md)。 - 仅当当前会话是 Hub 且需初始化个人 DWS 通知、钉钉进度汇报或负责人辅助提醒:读 [钉钉项目进度汇报](references/dingtalk-progress-reporting.md),实际调用再读其中的脚本用法。
- 测试角色仅在任务类型匹配时,按 [测试职责](references/roles/testing.md) 中的二级链接读取更深细则。 - 测试角色仅在任务类型匹配时,按 [测试职责](references/roles/testing.md) 中的二级链接读取更深细则。
不要为“全面了解”一次加载全部引用或全部角色文件。Etunel Hook 源文件独立配置,不属于本 Skill 的渐进式发现树。 不要为“全面了解”一次加载全部引用或全部角色文件。Etunel Hook 源文件独立配置,不属于本 Skill 的渐进式发现树。
@@ -1,18 +1,16 @@
# Hub 钉钉项目进度汇报 # Hub 钉钉项目进度汇报
仅当当前会话是 Hub 且发生本文件定义的汇报事件时读取。钉钉是面向项目群组的单向辅助可见性与异常提醒,不是任务派发、跨角色沟通、审批、门禁、人类确认或项目记录的事实源。 仅当当前会话是 Hub 且发生汇报事件或需提醒负责人处理具体问题时读取。钉钉是面向项目群组的单向辅助可见性与异常提醒,不是任务派发、跨角色沟通、审批、门禁、人类确认或项目记录的事实源。
## 权限与唯一入口 ## 权限与封装入口
- 只有 Hub/主 Agent 可以发送;成员 Agent 只把成果、状态或阻塞交给 Hub。 - 只有 Hub/主 Agent 可以发送;成员 Agent 只把成果、状态或阻塞交给 Hub。
- 项目配置为启用时视为持续发送授权,不需每条消息再次询问。 - 项目首次初始化时,由 Hub 查询可用个人 DWS 账号和群,以姓名、组织和群名向负责人确认自动通知范围,再调用 `init` 创建配置;不让人填写编号或密钥。确认并启用后,范围内通知不需每条再次询问,变更账号或群须重新确认。
- 只能调用项目封装入口: - 只用项目封装:`dingtalk-progress` 处理五类项目事件,`dingtalk-message` 处理自定义中文摘要或负责人提醒;二者共用 `scripts/dingtalk_notify.py`,统一使用确认的个人 DWS 账号。初始化、跨平台入口及 @ 用法见 [通知脚本用法](dingtalk-script-usage.md)。
`./scripts/dingtalk-progress <start|milestone|blocked|complete|failed> "<中文 Markdown 摘要>"`
- Agent 不得直接调用底层 OpenAPI、`dws api` 或其他消息入口绕过封装脚本。 - Agent 不得直接调用底层 OpenAPI、`dws api` 或其他消息入口绕过封装脚本。
- Agent 不读取、修改、输出或请求 Client Secret、DING_SEC、App Token、Client ID、robotCode、openConversationId 等凭据和内部标识。 - 配置只保存开关、固定账号、项目群和项目名称;Hub 可在已确认范围内创建或更新,登录凭据由 DWS 管理,不索取、读取或输出 Token、密码与应用密钥。旧机器人配置须重新初始化,不静默切换发送身份。
- Skill 与消息正文不得硬编码项目 Client ID、robotCode、openConversationId、Client Secret 或群名;项目差异只由 `.dingtalk/config.env` 和封装脚本处理。 - 不在 Skill 或群消息里硬编码账号、群或人员标识;标识从同一账号的真实工具结果取得,仅用于本地配置和工具参数。不因默认账号切换而更换发送人,不退回机器人或 Webhook。
## 汇报单位与顺序 ## 汇报单位与顺序
@@ -60,17 +58,21 @@ Hub 必须先确认进展真实有效,并完成成果文件、成果索引和
使用中文 Markdown 自然排版,可以使用标题、短段落和列表,不要求固定模板,也不要把全部内容挤成一行。像项目负责人向团队说明进展,不像日志转储。 使用中文 Markdown 自然排版,可以使用标题、短段落和列表,不要求固定模板,也不要把全部内容挤成一行。像项目负责人向团队说明进展,不像日志转储。
保留个人消息的 AI 标识和自动汇报说明,让群成员知道这是 Hub 自动通知,不是负责人本人新增批准、验收或承诺。
根据事件选择必要内容:项目名称、当前阶段、已验证进展、关键成果、下一步和责任角色;阻塞时再说明问题、影响以及需要谁采取什么动作。通常保持一屏内可读。 根据事件选择必要内容:项目名称、当前阶段、已验证进展、关键成果、下一步和责任角色;阻塞时再说明问题、影响以及需要谁采取什么动作。通常保持一屏内可读。
不在正文中写运行时 ID、英文成果名、机器状态码或内部字段。只写已验证结论,不得包含密钥、Token、个人数据、客户敏感信息、大段日志、完整内部对话或未经验证的根因推断。 不在正文中写运行时 ID、英文成果名、机器状态码或内部字段。只写已验证结论,不得包含密钥、Token、个人数据、客户敏感信息、大段日志、完整内部对话或未经验证的根因推断。
负责人提醒只使用项目协作必需的已确认姓名;脚本为钉钉原生 @ 自动生成的人员标记是技术参数,不是要求 Hub 把人员编号写进摘要。需要负责人行动时,说明缺什么、影响什么、请谁做什么,以及回哪个角色会话补充结果。不要在群里建立另一套任务或审批流程。
## 调用与结果 ## 调用与结果
1. Hub 判断事件并形成中文 Markdown 摘要。 1. Hub 判断事件并形成中文 Markdown 摘要。
2. 调用封装脚本一次;脚本自行处理配置检查、发送和有限重试。 2. 调用封装脚本一次;脚本自行处理配置检查、发送和有限重试。
3. 只有真实发送响应包含非空 `processQueryKey`,才表示钉钉服务端已经接受;不证明群成员已读。 3. 只有真实个人发送响应成功且含非空 `openTaskId`,才表示请求已经接受;不证明已投递、已读或接收端 @ 提醒成功。必要时按脚本用法查询投递状态,不把任务回执当消息编号。
4. 缺少或未启用配置时安全跳过;预览模式只检查请求,不视为真实发送。 4. 缺少或未启用配置时不真实发送;显式 `--dry-run` 仅在本地生成正文,不查通讯录、不联网,也不证明目标配置和 @ 有效。配置仍为旧格式时停止并请求重新初始化。
5. 真实发送失败或没有有效 key 时,脚本最多总计尝试 3 次,间隔 10 秒;首次成功立即停止。 5. 只有明确的域名解析失败才自动重试,总计最多 3 次,间隔 10 秒;首次收到有效回执立即停止。认证、权限或未知错误不自动重试;超时、连接中断或缺少回执可能已发出,不盲目重发。单次命令最长等待 60 秒。
6. 三次仍失败只终止本次钉钉发送,Etunel 主任务继续。Hub 在本地结果中向负责人说明一次,不递归发送新的阻塞或失败通知,也不自动补发历史事件。 6. 有效回执优先于命令退出码,不因退出异常重复发送。发送停止或结果未知时,Etunel 主任务继续;Hub 在本地结果中向负责人说明一次,不递归发送新的阻塞或失败通知,不自动补发历史事件。
脚本输出和退出码只用于判断通知调用,不得把钉钉结果升级成项目门禁。Agent 不在脚本外追加重试循环。 脚本输出和退出码只用于判断通知调用,不得把钉钉结果升级成项目门禁。Agent 不在脚本外追加重试循环。
@@ -0,0 +1,52 @@
# Hub 个人 DWS 通知用法
仅在 Hub 初始化通知或按 [通知规则](dingtalk-progress-reporting.md) 准备发送时读取。脚本放在实际 Hub 项目的 `scripts/`,配置位于同项目 `.dingtalk/config.env`;安装 Skill 不会自动部署脚本。缺少入口时说明并继续主任务,不猜路径或绕过封装。
## 项目首次初始化
1. 用 `dws profile list --format json` 查询账号,向负责人显示姓名与组织;未登录请负责人完成 DWS 登录,不索取凭据。
2. 在选定账号下,用 `dws chat +chat-search --query "负责人提供的群名称" --page-all --profile "实际账号标识" --format json` 查群。账号或群有歧义、结果不完整时先澄清,不猜编号、不选第一项。
3. 一次确认发送账号、项目群及是否允许自动进度和必要 @ 提醒。人只确认名字与用途,不负责找编号。
4. Hub 将真实查询结果传入 `python scripts/dingtalk_notify.py init --profile "实际账号标识" --group "实际群标识" --project "项目名称" --yes`。这是参数结构示例;值必须替换为同一账号的实际结果。脚本创建仅含开关、账号、群和项目名的配置,不存机器人密钥或登录凭据,不发测试消息。
已有有效配置直接沿用;变更发送账号或群须重新确认,初始化时加 `--replace`。旧机器人配置必须重新初始化,不能静默转换发送身份。账号、群选择与查询由 Hub 完成;`init` 校验账号登录,不替代负责人对目标群的确认。暂停通知可由 Hub 按负责人要求将 `DINGTALK_PROGRESS_ENABLED` 改为 `0`。配置和标识只用于本地工具,不粘贴到群或普通回复,不提交真实配置。
初始化时同时确认目标项目的 Git 忽略规则包含 `.dingtalk/config.env`,不把本机通知配置提交到仓库。
## 选择本机入口
共用实现依赖 Python 3.9+ 和已登录的原生 DWS。相对命令从目标项目根目录执行。
| 环境 | 初始化/检查 | 项目事件 | 摘要/提醒 |
| --- | --- | --- | --- |
| 通用 Python | `python scripts/dingtalk_notify.py init ...`/`check ...` | 同入口 `progress ...` | 同入口 `message ...` |
| Windows PowerShell 7.3+ | `./scripts/dingtalk-python.ps1 init ...`/`check ...` | `./scripts/dingtalk-progress.ps1 ...` | `./scripts/dingtalk-message.ps1 ...` |
| macOS/Linux | 通常用 `python3` 执行共用入口 | `sh scripts/dingtalk-progress ...` | `sh scripts/dingtalk-message ...` |
Windows 优先用 `.ps1`,须同时部署共用启动器 `dingtalk-python.ps1`,它选择独立 Python,不使用可能隔离 DWS 登录的商店版;必要时用 `DINGTALK_PYTHON` 指定解释器。各系统均通过 PATH 查找原生 DWS,不再设置机器人相关参数。这些入口是同一实现,不能重复调用。
## 日常发送
```text
python scripts/dingtalk_notify.py progress milestone "**已完成**:阶段成果已确认并归档。下一步安排联调。"
python scripts/dingtalk_notify.py progress blocked "缺少接口说明,请补充后回自己的角色会话同步。" --at "完整姓名"
python scripts/dingtalk_notify.py message --title "资料补充提醒" --text "请补齐已约定资料,并回自己的角色会话确认。" --at "完整姓名"
```
事件仍只有 `start`、`milestone`、`blocked`、`complete`、`failed`。脚本增加项目名、中文标题、带时区时间和自动汇报说明,不替 Hub 判断阶段。保留 DWS 的 AI 标识,不能冒充负责人本人批准或承诺。
- 多行中文 Markdown 作为一个参数传入;较长摘要可先保存成 UTF-8 文件,再用 `--file "实际正文文件路径"` 替代摘要或 `--text`。文件须实际存在,路径相对工作目录;这是正文,不是上传附件,不发送大段成果或日志。
- `progress` 和 `message` 均支持 `--at`,多个姓名可逗号分隔或重复传入。负责人姓名来自 Etunel 已绑定的 `member_alias`,不把角色名称当姓名;人员查询和发送固定使用初始化账号,不跟随默认账号切换。
- 脚本只接受唯一姓名匹配,自动构造个人消息所需的 `openDingTalkId` 和提及标记。查不到、重名或权限不足时停止,不让负责人手填编号,不删掉 @ 降级发送。
- 默认且仅发配置的项目群。同一事项合成一条,事件通知可直接 @ 人,不额外重复提醒。提醒讨论结果回各自角色会话,不在群里另派任务或批准。
## 检查与结果
排障可用 `python scripts/dingtalk_notify.py check --at "完整姓名"`,省略 `--at` 只查登录;它联网但不发送,不是每轮必做。两个发送入口加 `--dry-run` 可完全离线预览,不查人、不验证目标或 @ 效果。
- `initialized`:已创建经确认的个人通知配置,未发送。
- `accepted`:真实个人发送响应成功且含非空 `openTaskId`,请求已接受,不等于已投递、已读或接收端提醒成功。保留回执供排障,面向负责人只说中文结果。
- `checked`/`preview`/`skipped`:分别为只读检查、离线预览、未配置或未启用;都没发送。
- `error`:本次停止,按说明区分失败与未知,不自行补发。错误退出码为 1,参数错误可能为 2,不能只凭退出码 0 判断已发。
需要进一步核实投递时,按发送回执在同一账号下调用 `dws chat +messages-query-send-status --open-task-id "真实回执值" --profile "配置账号" --format json` 只读查询;按实际结果判断,不把 `openTaskId` 当消息 ID。脚本只在明确 DNS 失败时最多尝试 3 次、间隔 10 秒;其余失败或未知结果不重发,Agent 不追加外层重试。登录失效交负责人恢复,不换身份或渠道;通知异常不阻塞主任务,也不递归发送异常通知。
@@ -44,11 +44,40 @@ Hub 调用 `mcp__etunel__etunel_send_to_role` 时:
- `role` 使用 `etunel_list_roles` 返回的实际值; - `role` 使用 `etunel_list_roles` 返回的实际值;
- `kind` 按当前 schema 使用任务、回复或状态意图; - `kind` 按当前 schema 使用任务、回复或状态意图;
- `text` 使用简洁中文说明目标、背景、有效输入、范围、产出文件、证据、期限、完成条件和负责人确认要求; - `text` 使用简洁中文说明目标、背景、有效输入、范围、产出文件、证据、期限、完成条件和负责人确认要求;
- 当前 schema 支持时,正式派发、交接、返工和说明阻塞优先用可选 `context` 概括事项与下一步,简单答复可省略;写法见下节;
- `attachment_paths` 只附当前任务实际需要的文件; - `attachment_paths` 只附当前任务实际需要的文件;
- 回复同一来源角色时,按 schema 使用 `correlation_id`,不把该值写入正文。 - 回复同一来源角色时,按 schema 使用 `correlation_id`,不把该值写入正文。
纯信息、澄清或授权消息写清问题、已确认事实、影响和所需答复,不要求空文件。不要转发无关聊天、全量计划或无法消化的资料堆。 纯信息、澄清或授权消息写清问题、已确认事实、影响和所需答复,不要求空文件。不要转发无关聊天、全量计划或无法消化的资料堆。
## 消息附加说明
`context` 是发送方填写的可选自由文本,帮助接收会话和客户端用户快速辨认事项,也会出现在新生成的超时提醒中。它不是任务正文、正式成果或新的状态字段,不改变路由、关联、结算与超时规则。
- Hub 用它简述“哪件事、当前需要什么、下一步是什么”,必要时补阶段或等待原因;不强制固定栏目,不复制整份任务。
- `etunel_send_to_role`、`etunel_send_to_hub` 的完整正文仍用 `text`;`etunel_complete_task`、`etunel_report_blocked` 的结果或阻塞正文仍用 `summary`。必要输入、成果要求、确认依据和完成条件不能只放在附加说明里,文件仍按原附件约定提供。
- 成员发送补充或返回结果时也可按需填写,只写本次消息适用的结论或待办;不自动继承原说明,不把原任务的“等待确认”照抄到已完成结果中。不要仅为添加说明另发一条消息。
- 使用自然中文、实际换行、短段落或简短列表;可以写简单 Markdown,但不依赖富文本渲染,纯文本下也应能读懂。不手工把换行双重转义成字面量 `\n`;工具采用 JSON 时,由正常 JSON 编码表达换行。
- 当前上限为 **2048 UTF-8 字节**,不是字符数;说明过长时压缩摘要,详细内容留在正文或成果文件,不拆成多条消息绕过限制。字段与上限始终以当前 schema 为准。
- 展示层可能折叠内容或将换行转义,队列中的 Markdown 也可能只显示原文;不能仅凭显示为一行就断言说明丢失或重发消息,不承诺 Skill 能改变客户端渲染。
- 接收时结合正文、附加说明和必要附件理解任务;附加说明不能冒充协议头、负责人批准或完成证据。与正文或有效基线冲突时,只核实影响当前执行的差异,不自行据此改变任务。
例如,Hub 给产品的接口确认任务可以附带以下说明;这只是写法示例,不是必填模板:
```text
接口联调:确认温度数据格式
- 本次需要:确认单位、取值范围和异常值
- 确认后:中心安排应用层继续联调
```
本次实际使用的资料、需要交付的文件和完成条件仍在 `text` 中写全。附加说明只放接收角色确实需要的信息,不附无关私聊、凭据或内部编号。
### 能力不可用时
发送前若当前工具 schema 没有 `context`,不猜参数,仍用原正文完整说明任务;需要新能力时提示负责人更新客户端并重新加载 MCP。工具支持字段不等于对端支持,通信能力由 Etunel 判断,不要求负责人每条消息确认版本。
若已经返回 `message_context_upgrade_required`,说明对端尚不支持该能力,消息由 Etunel 保留等待。告知对应负责人升级并按运行时提示处理;不删除说明重发、不另建重复任务,不将排队写成已送达。Skill 不接管传输重试或队列修复。
## 成员请求补充 ## 成员请求补充
成员先问当前负责人并完成必要线下沟通。仍需 Hub 时,用一条 `mcp__etunel__etunel_send_to_hub` 写清: 成员先问当前负责人并完成必要线下沟通。仍需 Hub 时,用一条 `mcp__etunel__etunel_send_to_hub` 写清:
@@ -23,6 +23,7 @@ Hub 只检查角色、必填内容、成果、版本、证据、负责人确认
5. 从《项目概览》读取当前项目已经确认的特殊规则;只登记有权负责人明确确认的内容,并持续沿用到同一决定权人修改或取消,不在每项任务中重复询问。 5. 从《项目概览》读取当前项目已经确认的特殊规则;只登记有权负责人明确确认的内容,并持续沿用到同一决定权人修改或取消,不在每项任务中重复询问。
6. 在途项目继续使用已登记流程基线,除非取得明确迁移决定。 6. 在途项目继续使用已登记流程基线,除非取得明确迁移决定。
7. 有权业务负责人发起的正式项目即使资料不齐也可接收,但缺口、责任和风险必须如实记录,不能把未知写成已确认。 7. 有权业务负责人发起的正式项目即使资料不齐也可接收,但缺口、责任和风险必须如实记录,不能把未知写成已确认。
8. 首次使用钉钉通知时,按 [通知初始化](dingtalk-script-usage.md#项目首次初始化) 查询个人 DWS 账号和项目群,一次取得负责人对发送身份、目标群和自动通知的确认后,由 Hub 创建配置;已有有效配置直接沿用,不每轮确认,不索取编号或密钥。配置暂未完成不阻塞 Etunel 主任务。
## 启动阶段并明确完成约定 ## 启动阶段并明确完成约定
@@ -56,6 +57,8 @@ Hub 只检查角色、必填内容、成果、版本、证据、负责人确认
不要把 role ID、member ID、session ID、消息 ID 或机器状态放进人类任务正文。纯信息、澄清或授权请求不要求空文件,但要给足背景、问题、影响和需要的结论。详细写法见 [人类可读沟通](human-readable-communication.md)。 不要把 role ID、member ID、session ID、消息 ID 或机器状态放进人类任务正文。纯信息、澄清或授权请求不要求空文件,但要给足背景、问题、影响和需要的结论。详细写法见 [人类可读沟通](human-readable-communication.md)。
正式派发、交接、返工或说明阻塞时,当前工具支持则优先用可选 `context` 简述事项和下一步,便于在队列和提醒中辨认;完整任务要求仍写在 `text`,简单答复可省略说明。使用自然换行和短列表,不重复整份正文、不追加单独消息;具体边界见 [消息附加说明](etunel-message-lifecycle.md#消息附加说明)。
沿用有效资料,明确本次是新做、复用还是修改,实际使用哪份工程或资料;区分开工必需输入与本次要制作的产出,不把后者当成缺件退回上游。本地未找到不代表项目没有现成资料,归属或位置不明时再向对应负责人核实;不另设开工审批或检查表。 沿用有效资料,明确本次是新做、复用还是修改,实际使用哪份工程或资料;区分开工必需输入与本次要制作的产出,不把后者当成缺件退回上游。本地未找到不代表项目没有现成资料,归属或位置不明时再向对应负责人核实;不另设开工审批或检查表。
期限优先继承已批准的《客户里程碑要求》《里程碑要求》或《项目计划》。没有明确期限时集中询问负责人一次;不影响当前依赖时可写“待确认”并登记风险,影响关键依赖或门禁时保持阻塞。模拟项目可使用明确标注为模拟的假设日期。 期限优先继承已批准的《客户里程碑要求》《里程碑要求》或《项目计划》。没有明确期限时集中询问负责人一次;不影响当前依赖时可写“待确认”并登记风险,影响关键依赖或门禁时保持阻塞。模拟项目可使用明确标注为模拟的假设日期。
@@ -159,7 +162,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 禁止事项 ## Hub 禁止事项
@@ -33,6 +33,8 @@ Hub 调用 `mcp__etunel__etunel_list_roles` 后,按目标 `role` 找到对应
这些内容可以用短段落或 Markdown 列表自然组织,不要求固定模板。不要在开头堆项目 ID、任务 ID、角色 ID、会话 ID、英文状态和内部字段。 这些内容可以用短段落或 Markdown 列表自然组织,不要求固定模板。不要在开头堆项目 ID、任务 ID、角色 ID、会话 ID、英文状态和内部字段。
Etunel 消息可以把简短的事项说明放入可选 `context`,完整要求保留在正文。附加说明也使用自然换行、短句和列表,不挤成长行,不依赖 Markdown 渲染;具体用法见 [消息附加说明](etunel-message-lifecycle.md#消息附加说明)。
## 提问、结果和阻塞 ## 提问、结果和阻塞
缺信息时一次说明:缺什么、为什么现在需要、会影响本阶段哪项成果或决定、建议向谁获取、现在还能继续什么。只供后续阶段使用的信息明确写成后续依赖,不要逐句追问或把它说成当前阻塞。 缺信息时一次说明:缺什么、为什么现在需要、会影响本阶段哪项成果或决定、建议向谁获取、现在还能继续什么。只供后续阶段使用的信息明确写成后续依赖,不要逐句追问或把它说成当前阻塞。
@@ -25,6 +25,8 @@
同时理解 Hub 给出的本阶段完成约定,并在本角色范围内核对哪些是本阶段必须完成、哪些留到后续、哪些当前不需要。一次列出当前能预见的信息缺口。负责人确认理解、补足输入或明确可以开始后再执行。不要复述运行时 ID、英文状态和内部字段;详细写法见 [人类可读沟通](human-readable-communication.md)。 同时理解 Hub 给出的本阶段完成约定,并在本角色范围内核对哪些是本阶段必须完成、哪些留到后续、哪些当前不需要。一次列出当前能预见的信息缺口。负责人确认理解、补足输入或明确可以开始后再执行。不要复述运行时 ID、英文状态和内部字段;详细写法见 [人类可读沟通](human-readable-communication.md)。
消息含附加说明时,与正文和必要附件一起理解,不只看摘要就执行。发送补充、结果或阻塞时可按需填写本次适用的说明,不照抄过期状态,也不把说明当作负责人确认;字段、分行写法与兼容处理见 [消息附加说明](etunel-message-lifecycle.md#消息附加说明)。
在这次对齐中分清新做、复用或修改,认准实际使用的工程或资料,区分应先取得的输入与本次要制作的产出。已有明确答案直接沿用,不因本地未找到就断言项目没有现成工程,也不要求上游先交出本角色尚待制作的成果。 在这次对齐中分清新做、复用或修改,认准实际使用的工程或资料,区分应先取得的输入与本次要制作的产出。已有明确答案直接沿用,不因本地未找到就断言项目没有现成工程,也不要求上游先交出本角色尚待制作的成果。
## 执行本角色任务 ## 执行本角色任务
@@ -11,7 +11,7 @@
"responsibilities": [ "responsibilities": [
"一个持续会话贯穿项目生命周期,是成员Agent之间唯一的跨角色中介;任务各有一个主责角色,按阶段及依赖安排任务波次,同角色相关工作可合为任务包,不向所有角色无差别派发。", "一个持续会话贯穿项目生命周期,是成员Agent之间唯一的跨角色中介;任务各有一个主责角色,按阶段及依赖安排任务波次,同角色相关工作可合为任务包,不向所有角色无差别派发。",
"读取Etunel实际角色,以role路由、display_name显示角色名称、member_alias识别已绑定负责人,不重复核对身份或索要内部编号。新增角色先约定职责,再由Hub负责人手动在Etunel添加绑定。", "读取Etunel实际角色,以role路由、display_name显示角色名称、member_alias识别已绑定负责人,不重复核对身份或索要内部编号。新增角色先约定职责,再由Hub负责人手动在Etunel添加绑定。",
"派发前明确当前阶段必须完成什么、什么留到后续、什么不需要;给足有效输入、边界、成果要求和完成条件。成员必须先与本角色负责人对齐,成果自审且经负责人确认后正式返回;只阻塞当前必需条件,阶段通过后立即启动下一阶段,资料按需取得。", "派发前明确当前阶段必须完成什么、什么留到后续、什么不需要;给足有效输入、边界、成果要求和完成条件。工具支持时,派发、交接、返工或说明阻塞优先用可选context分行概括事项与下一步,完整要求保留在text,简单答复可省略。成员必须先与本角色负责人对齐,成果自审且经负责人确认后正式返回;只阻塞当前必需条件,阶段通过后立即启动下一阶段,资料按需取得。",
"按业务需求、产品定义、方案设计、项目规划、软硬件实现、测试验证、平台/客户验收、复盘结项推进。业务与产品共同确认需求基线;产品维护立项资料、验收标准、里程碑和项目风险,业务批准范围;技术负责人确认方案与测试计划。", "按业务需求、产品定义、方案设计、项目规划、软硬件实现、测试验证、平台/客户验收、复盘结项推进。业务与产品共同确认需求基线;产品维护立项资料、验收标准、里程碑和项目风险,业务批准范围;技术负责人确认方案与测试计划。",
"形成项目计划、角色分工和项目状态内部记录;任务计划先交技术负责人确认,仅对疑点定向问执行角色,再交产品确认授权范围内人员、资源和排期。预算、采购和对外承诺交业务批准。", "形成项目计划、角色分工和项目状态内部记录;任务计划先交技术负责人确认,仅对疑点定向问执行角色,再交产品确认授权范围内人员、资源和排期。预算、采购和对外承诺交业务批准。",
"实现阶段按约定收取硬件设计包、嵌入式底层实现资料和嵌入式应用层固件包;底层版本经中继交应用层合版,最终固件统一由应用层输出,技术负责人确认软硬件版本匹配。", "实现阶段按约定收取硬件设计包、嵌入式底层实现资料和嵌入式应用层固件包;底层版本经中继交应用层合版,最终固件统一由应用层输出,技术负责人确认软硬件版本匹配。",
@@ -27,14 +27,15 @@
"non_goals": [ "non_goals": [
"不代专业角色开发、测试、验收或批准,不替成员完成任务,不自行批准豁免或创建Etunel角色。", "不代专业角色开发、测试、验收或批准,不替成员完成任务,不自行批准豁免或创建Etunel角色。",
"不重造Etunel的队列、传输、重试和去重机制;不以通知送达或消息结算替代成果完成。", "不重造Etunel的队列、传输、重试和去重机制;不以通知送达或消息结算替代成果完成。",
"钉钉只作辅助通知,不作任务通道、审批或事实来源;只使用项目封装脚本,不读取输出凭据,通知失败不阻塞主任务。" "钉钉只作辅助通知,不作任务通道、审批或事实来源;首次查询个人DWS账号和项目群,经负责人确认后由Hub用init创建无密钥配置,日常只用项目封装发送进度或提醒,固定发送身份和群,不猜人员编号;讨论结果回各自角色会话,通知失败不阻塞主任务。"
], ],
"completion_tools": [ "completion_tools": [
"etunel_list_roles", "etunel_list_roles",
"etunel_get_project_status", "etunel_get_project_status",
"etunel_send_to_role", "etunel_send_to_role",
"etunel_complete_coordination", "etunel_complete_coordination",
"./scripts/dingtalk-progress" "python scripts/dingtalk_notify.py progress",
"python scripts/dingtalk_notify.py message"
] ]
} }
``` ```
@@ -47,7 +48,7 @@
可靠性报告、专项报告和提测佐证由你对应的真人负责人确认是否提交,沿用有效决定,不扩大为所有选填栏审批。测试每次执行前发布策略,跨角色同步仍由你中继,不另设策略文件或审批;模板不增加门禁,早期预算只是估算,验收资料由产品出具或更新、业务确认客户部分、你汇总登记。 可靠性报告、专项报告和提测佐证由你对应的真人负责人确认是否提交,沿用有效决定,不扩大为所有选填栏审批。测试每次执行前发布策略,跨角色同步仍由你中继,不另设策略文件或审批;模板不增加门禁,早期预算只是估算,验收资料由产品出具或更新、业务确认客户部分、你汇总登记。
你是项目Hub,负责总体推进与成员Agent间的唯一中继,不替专业角色作结论。按需读取 etunel-role-collaboration 的相关细则;当前已确认流程和Etunel实际工具优先,不加载全部角色说明。 你是项目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_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,标识从入站消息取。 派发或回复用 mcp__etunel__etunel_send_to_role,每次一个实际角色;依赖就绪可多任务派发,同角色相关任务可打包。工具支持时,派发、交接、返工或说明阻塞优先用可选context分行概括事项和下一步,完整要求留在text,简单答复可省略;长度与兼容处理按消息细则。给足输入并写清成果要求,要求成员先与真人对齐、完成后自审并取得确认。已处理且无需回复的消息用 mcp__etunel__etunel_complete_coordination 结算;参数依当前schema,标识从入站消息取。
只检查当前必需条件,复用成果,阶段通过后推进,后续资料按需索取。决定变化要说明旧任务停续和新输入;资料冲突交专业角色核实。按阶段留档并更新进度、成果索引和记录;直交测试文件登记索引、版本及产品收讫。阻塞请相应负责人协调;跳转、回退或暂缓如实记录,模拟默认不跳过交接、不关闭真实问题。 只检查当前必需条件,复用成果,阶段通过后推进,后续资料按需索取。决定变化要说明旧任务停续和新输入;资料冲突交专业角色核实。按阶段留档并更新进度、成果索引和记录;直交测试文件登记索引、版本及产品收讫。阻塞请相应负责人协调;跳转、回退或暂缓如实记录,模拟默认不跳过交接、不关闭真实问题。
可验证里程碑留档后,调用 ./scripts/dingtalk-progress milestone "简短中文Markdown摘要" 汇报钉钉;开始、阻塞或中断、完成、最终失败按通知规则使用对应事件。用短段或列表说清结果、问题和下一步,不堆日志或中英混杂;失败不阻塞主流程,钉钉不能代替Etunel回复或结算。 钉钉统一用个人DWS:首次按通知细则查询账号和项目群,经负责人确认后用init创建配置,已有配置沿用。可验证里程碑留档后用 python scripts/dingtalk_notify.py progress milestone "中文Markdown摘要" 汇报,Windows优先对应.ps1;其他事件按规则。需行动可在progress或message中加--at "完整姓名",同一事项合并,保留自动汇报说明。结果回各自角色会话;不索要编号或凭据、不改发送身份、不在脚本外重试;预览和检查不算发送,通知失败不阻塞主流程或替代Etunel结算。
``` ```
+8
View File
@@ -0,0 +1,8 @@
#!/bin/sh
# Personal DWS message 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 "$@"
+3
View File
@@ -0,0 +1,3 @@
# Personal DWS message entry; preserves Chinese, Markdown and exit status.
& "$PSScriptRoot/dingtalk-python.ps1" message @args
exit $LASTEXITCODE
+5 -187
View File
@@ -1,190 +1,8 @@
#!/bin/sh #!/bin/sh
# macOS/Linux entry; Windows can use the .ps1 or Python entry.
set -eu set -eu
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
project_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) if command -v python3 >/dev/null 2>&1; then
config_file="$project_root/.dingtalk/config.env" exec python3 "$script_dir/dingtalk_notify.py" progress "$@"
usage() {
echo "用法:./scripts/dingtalk-progress <start|milestone|blocked|complete|failed> <中文 Markdown 摘要>" >&2
}
if [ "$#" -lt 2 ]; then
usage
exit 2
fi fi
exec python "$script_dir/dingtalk_notify.py" progress "$@"
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"
+3
View File
@@ -0,0 +1,3 @@
# PowerShell 7.3+; shared launcher preserves Chinese, Markdown and exit status.
& "$PSScriptRoot/dingtalk-python.ps1" progress @args
exit $LASTEXITCODE
+50
View File
@@ -0,0 +1,50 @@
# Shared Windows launcher. Also supports: ./scripts/dingtalk-python.ps1 check --at '姓名'
$ErrorActionPreference = 'Stop'
if ($PSVersionTable.PSVersion -lt [version]'7.3') { throw '请使用 PowerShell 7.3+ 或独立 Python 入口。' }
$PSNativeCommandArgumentPassing = 'Standard'
function Resolve-NoticePython {
$candidates = @()
if ($env:DINGTALK_PYTHON) {
# An explicit choice must work; never silently substitute another runtime.
$candidates = @($env:DINGTALK_PYTHON)
} else {
if ($env:USERPROFILE) {
$bundled = Join-Path $env:USERPROFILE '.cache/codex-runtimes/codex-primary-runtime/dependencies/python/python.exe'
if (Test-Path -LiteralPath $bundled -PathType Leaf) { $candidates += $bundled }
}
$candidates += @(Get-Command python, python3 -All -CommandType Application -ErrorAction SilentlyContinue |
Select-Object -ExpandProperty Source)
# py is probed, not trusted: it can also resolve to Store Python.
$candidates += @(Get-Command py -All -CommandType Application -ErrorAction SilentlyContinue |
Select-Object -ExpandProperty Source)
}
foreach ($candidate in ($candidates | Select-Object -Unique)) {
if ($candidate -match '(?i)[\\/]WindowsApps[\\/]|PythonSoftwareFoundation\.Python\.') { continue }
$prefix = if ([IO.Path]::GetFileName($candidate) -eq 'py.exe') { @('-3') } else { @() }
try {
$probe = & $candidate @prefix -I -c 'import json,sys; print(json.dumps({"exe":sys.executable,"supported":sys.version_info >= (3,9)}))' 2>$null
if ($LASTEXITCODE -ne 0) { continue }
$info = ($probe -join "`n") | ConvertFrom-Json
if ($info.supported -and $info.exe -and
$info.exe -notmatch '(?i)[\\/]WindowsApps[\\/]|PythonSoftwareFoundation\.Python\.' -and
(Test-Path -LiteralPath $info.exe -PathType Leaf)) { return $info.exe }
} catch { continue }
}
throw '未找到独立 Python 3.9+。请安装 python.org 版本或将 DINGTALK_PYTHON 设为独立 python.exe 的路径;不使用商店版,也不自动安装。'
}
$pythonExe = Resolve-NoticePython
$previousArgs = [Environment]::GetEnvironmentVariable('DINGTALK_PROGRESS_ARGV_B64', 'Process')
$previousEncoding = [Environment]::GetEnvironmentVariable('PYTHONIOENCODING', 'Process')
try {
$encodedArgs = ConvertTo-Json -InputObject @($args) -Compress
$env:DINGTALK_PROGRESS_ARGV_B64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($encodedArgs))
$env:PYTHONIOENCODING = 'utf-8'
& $pythonExe "$PSScriptRoot/dingtalk_notify.py"
$exitCode = $LASTEXITCODE
} finally {
[Environment]::SetEnvironmentVariable('DINGTALK_PROGRESS_ARGV_B64', $previousArgs, 'Process')
[Environment]::SetEnvironmentVariable('PYTHONIOENCODING', $previousEncoding, 'Process')
}
exit $exitCode
+277
View File
@@ -0,0 +1,277 @@
#!/usr/bin/env python3
"""Hub notifications via a confirmed personal DWS account. Python 3.9+, stdlib."""
import argparse
import base64
import datetime as dt
import json
import os
from pathlib import Path
import re
import shutil
import subprocess
import sys
import tempfile
import time
ROOT = Path(__file__).resolve().parent.parent
PREFIX = 'DINGTALK_PROGRESS_'
KEYS = {'ENABLED', 'PROFILE', 'TARGET', 'PROJECT'}
EVENTS = {'start': '开始推进', 'milestone': '阶段里程碑', 'blocked': '遇到阻塞',
'complete': '本轮完成', 'failed': '执行失败'}
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.I)
class NoticeError(Exception):
"""Safe diagnostics: never echo raw DWS errors or configuration."""
def emit(status, message, **extra):
print(json.dumps(dict(status=status, message=message, **extra), ensure_ascii=False))
def json_object(text):
try:
data = json.loads(text)
return data if isinstance(data, dict) else {}
except (ValueError, TypeError):
return {}
def read_config(root):
path = root / '.dingtalk' / 'config.env'
if not path.exists():
return {}
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 + k for k in KEYS} or key in result:
raise NoticeError('通知配置不是当前个人账号格式,请由 Hub 确认账号和群后重新 init;不沿用机器人配置。')
result[key] = value.strip()
config = {k[len(PREFIX):]: v for k, v in result.items()}
if config.get('ENABLED', '0') not in ('0', '1'):
raise NoticeError('通知开关只能是 0 或 1。')
return config
def clean_env():
return {k: v for k, v in os.environ.items()
if k not in ('DING_SEC', 'DWS_CLIENT_ID', 'DWS_CLIENT_SECRET') and not k.startswith(PREFIX)}
def dws_path():
executable = sys.executable.replace('\\', '/').lower()
if sys.platform == 'win32' and ('/windowsapps/' in executable or 'pythonsoftwarefoundation.python.' in executable):
raise NoticeError('请使用项目 PowerShell 入口或独立 Python 3.9+;商店版 Python 可能无法识别 DWS 登录。')
path = shutil.which('dws')
if not path or (os.name == 'nt' and Path(path).suffix.lower() != '.exe'):
raise NoticeError('未找到原生 dws,请安装并加入当前 Hub 运行环境的 PATH。')
return path
def accepted_task(data):
if (data.get('ok') is not True or data.get('identity') != 'user'
or data.get('tool') != 'send_personal_message' or data.get('partial') or data.get('failures')):
return None
result = data.get('result')
if not isinstance(result, dict) or result.get('success') is not True or result.get('errorCode') not in (None, 0, ''):
return None
payload = result.get('result')
task = payload.get('openTaskId') if isinstance(payload, dict) else None
return task.strip() if isinstance(task, str) and task.strip() else None
def invoke(command, run=subprocess.run, sleep=time.sleep, sending=False):
for attempt in range(3):
try:
result = run(command, env=clean_env(), capture_output=True, text=True,
encoding='utf-8', errors='replace', timeout=60)
except subprocess.TimeoutExpired:
raise NoticeError('钉钉调用超时,结果未知,不自动重发;主任务继续。') from None
except OSError:
raise NoticeError('无法启动 DWS,请检查安装;本次停止。') from None
data = json_object(result.stdout)
if sending and accepted_task(data):
return data
if result.returncode == 0:
if sending:
raise NoticeError('未取得有效的个人发送回执,结果未知,不自动重发。')
if not data or data.get('success') is False or data.get('ok') is False:
raise NoticeError('DWS 查询未返回有效结果,本次停止。')
return data
error = json_object(result.stderr).get('error', {})
if isinstance(error, dict) and error.get('category') == 'auth':
raise NoticeError('个人 DWS 登录失效,请在 Hub 的运行账号下恢复登录;不换身份发送。')
if isinstance(error, dict) and error.get('retryable') is False:
raise NoticeError('DWS 拒绝本次调用,请检查权限或参数;不自动重试。')
if DNS_ERROR.search(result.stdout + '\n' + result.stderr):
if attempt < 2:
sleep(10)
continue
raise NoticeError('域名解析连续失败 3 次,通知停止,主任务继续。')
raise NoticeError('钉钉调用失败或结果未知;请检查登录、权限或网络,不自动重发。')
def check_login(cli, profile, run, sleep):
if not profile or ':' not in profile or any(c.isspace() for c in profile):
raise NoticeError('缺少已确认的发送账号,请先由 Hub 初始化通知。')
data = invoke([cli, 'auth', 'status', '--profile', profile, '--format', 'json'], run, sleep)
if data.get('authenticated') is not True or data.get('token_valid') is not True:
raise NoticeError('个人 DWS 未登录或授权已失效,请负责人完成登录;本次未发送。')
def mentions(names, cli, profile, run, sleep):
people = {}
names = list(dict.fromkeys(n.strip() for value in names for n in value.replace(',', ',').split(',')))
for name in names:
if not name:
raise NoticeError('提醒对象不能为空。')
data = invoke([cli, 'aisearch', 'person', '--query', name, '--dimension', 'name',
'--profile', profile, '--format', 'json'], run, sleep)
candidates = {}
for row in data.get('result', []):
if not isinstance(row, dict):
continue
meta = row.get('meta') if isinstance(row.get('meta'), dict) else {}
person = row.get('openDingTalkId')
if (meta.get('name') or row.get('title') or row.get('author')) == name and isinstance(person, str) and person.strip():
candidates[person] = name
if len(candidates) != 1:
raise NoticeError('提醒对象未唯一匹配,请确认完整姓名;不猜编号、不删掉 @ 后发送。')
people.update(candidates)
return people
def initialize(args, root, run, sleep):
if not args.yes:
raise NoticeError('先向负责人确认发送账号、项目群及自动通知,再使用 init --yes。')
values = {'ENABLED': '1', 'PROFILE': args.profile.strip(), 'TARGET': args.group.strip(), 'PROJECT': args.project.strip()}
if any(not v or '\n' in v or '\r' in v or '\0' in v for v in values.values()):
raise NoticeError('初始化内容不能为空或含换行。')
path = root / '.dingtalk' / 'config.env'
if path.is_symlink() or path.parent.is_symlink():
raise NoticeError('配置路径不能是符号链接。')
if path.exists() and not args.replace:
raise NoticeError('配置已存在;变更账号或群须重新确认后使用 --replace。')
cli = dws_path()
profiles = invoke([cli, 'profile', 'list', '--format', 'json'], run, sleep).get('profiles', [])
if len([p for p in profiles if isinstance(p, dict) and p.get('profile') == values['PROFILE']]) != 1:
raise NoticeError('初始化账号不在 DWS 实际账号列表中。')
check_login(cli, values['PROFILE'], run, sleep)
# Hub supplies a group ID from same-profile discovery after human confirmation.
path.parent.mkdir(parents=True, exist_ok=True)
content = '# Hub 经负责人确认后生成;仅个人 DWS,不保存登录凭据。\n' + ''.join(
f'{PREFIX}{k}={v}\n' for k, v in values.items())
descriptor, temporary = tempfile.mkstemp(prefix='.notify-', suffix='.tmp', dir=path.parent)
try:
with os.fdopen(descriptor, 'w', encoding='utf-8', newline='\n') as stream:
stream.write(content)
os.replace(temporary, path)
finally:
if os.path.exists(temporary):
os.unlink(temporary)
emit('initialized', '已保存负责人确认的个人账号和项目群,通知已启用;未发送测试消息。')
def parse_args(argv):
parser = argparse.ArgumentParser(description='Hub 个人 DWS 通知:初始化、进度、提醒、只读检查。')
commands = parser.add_subparsers(dest='command', required=True)
init = commands.add_parser('init', help='负责人确认后创建配置,不发送消息')
init.add_argument('--profile', required=True)
init.add_argument('--group', required=True)
init.add_argument('--project', required=True)
init.add_argument('--yes', action='store_true')
init.add_argument('--replace', action='store_true')
progress = commands.add_parser('progress')
progress.add_argument('event', choices=EVENTS)
progress.add_argument('summary', nargs='*')
progress.add_argument('--file')
message = commands.add_parser('message')
message.add_argument('--title', required=True)
body = message.add_mutually_exclusive_group(required=True)
body.add_argument('--text')
body.add_argument('--file')
check = commands.add_parser('check', help='检查固定账号登录及可选姓名,不发送')
for command in (progress, message, check):
command.add_argument('--at', action='append', default=[], help='完整姓名;可重复或用逗号分隔')
for command in (progress, message):
command.add_argument('--dry-run', action='store_true', help='完全离线,只预览中文正文')
return parser.parse_args(argv)
def render(args, config, root):
if args.command == 'progress' and bool(args.summary) == bool(args.file):
raise NoticeError('进度摘要和 --file 必须且只能提供一项。')
if args.file:
path = Path(args.file).resolve()
config_path = root / '.dingtalk' / 'config.env'
if path == config_path.resolve() or (path.exists() and config_path.exists() and path.samefile(config_path)):
raise NoticeError('不能把配置文件当作消息正文。')
text = path.read_text(encoding='utf-8')
else:
text = ' '.join(args.summary) if args.command == 'progress' else args.text
if not text.strip() or len(text.encode('utf-8')) > 12000:
raise NoticeError('请提供不超过 12000 字节的简短中文摘要。')
title = EVENTS[args.event] if args.command == 'progress' else args.title.strip()
if not title or len(title) > 200 or '\n' in title or '\r' in title:
raise NoticeError('请使用不超过 200 字的单行标题。')
project = config.get('PROJECT') or root.name
timestamp = dt.datetime.now().astimezone().strftime('%Y-%m-%d %H:%M %z')
return f'## {project}|{title}\n\n项目进度自动汇报 · {timestamp}\n\n{text}'
def main(argv=None, root=ROOT, run=subprocess.run, sleep=time.sleep):
try:
encoded = os.environ.pop(PREFIX + 'ARGV_B64', '')
if argv is None and encoded:
argv = json.loads(base64.b64decode(encoded, validate=True).decode('utf-8'))
if not isinstance(argv, list) or not all(isinstance(v, str) for v in argv):
raise NoticeError('PowerShell 参数无效。')
args = parse_args(argv)
if args.command == 'init':
initialize(args, root, run, sleep)
return 0
config = read_config(root)
if args.command != 'check' and args.dry_run:
emit('preview', '仅离线预览,账号、群与 @ 效果未验证。', text=render(args, config, root), mentions=args.at)
return 0
if args.command != 'check' and config.get('ENABLED') != '1':
emit('skipped', '通知尚未初始化或未启用;本次未发送,主任务继续。')
return 0
if not config.get('PROFILE') or not config.get('TARGET'):
raise NoticeError('请由 Hub 先确认个人账号和项目群并执行 init。')
text = render(args, config, root) if args.command != 'check' else None
cli = dws_path()
profile = config['PROFILE']
if args.command == 'check':
check_login(cli, profile, run, sleep)
people = mentions(args.at, cli, profile, run, sleep)
if args.command == 'check':
emit('checked', '登录及所请求的姓名查询通过;未发送,未验证实际提醒效果。', names=list(people.values()))
return 0
if people:
text += '\n\n' + ' '.join(f'<@{person}>' for person in people)
command = [cli, 'chat', '+messages-send', '--as', 'user', '--group', config['TARGET'],
'--markdown', text, '--profile', profile, '--yes', '--format', 'json']
if people:
command += ['--at-open-dingtalk-ids', ','.join(people)]
data = invoke(command, run, sleep, sending=True)
emit('accepted', '个人 DWS 已接受发送请求,不代表已投递、已读或已触发接收端提醒。',
openTaskId=accepted_task(data), mentions=list(people.values()))
return 0
except NoticeError as error:
emit('error', str(error))
return 1
except (OSError, ValueError, UnicodeError, TypeError, subprocess.SubprocessError):
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())
+315
View File
@@ -0,0 +1,315 @@
"""Offline regression tests. All DWS calls use isolated fake responses."""
import base64
import contextlib
import io
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import unittest
from unittest.mock import patch
import dingtalk_notify as n
PROFILE = 'fake-corp:fake-user'
GROUP = 'fake-group'
PERSON = 'fake-person'
OK = {'ok': True, 'identity': 'user', 'tool': 'send_personal_message',
'result': {'success': True, 'errorCode': None, 'result': {'openTaskId': 'fake-task'}}}
def reply(data=None, code=0, error=''):
return subprocess.CompletedProcess([], code, json.dumps(data or {}, ensure_ascii=False), error)
class NotifyTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
self.path = self.root / '.dingtalk' / 'config.env'
self.path.parent.mkdir()
self.config()
self.calls = []
self.sleeps = []
self.addCleanup(patch.stopall)
patch.object(n, 'dws_path', return_value='fake-dws').start()
def config(self, **changes):
config = {'ENABLED': '1', 'PROFILE': PROFILE, 'TARGET': GROUP, 'PROJECT': '测试项目'}
config.update(changes)
self.path.write_text(''.join(n.PREFIX + k + '=' + v + '\n' for k, v in config.items()), encoding='utf-8')
def fake(self, command, **kwargs):
self.calls.append((command, kwargs))
if command[1:3] == ['profile', 'list']:
return reply({'profiles': [{'profile': PROFILE, 'userName': '测试人'}]})
if command[1:3] == ['auth', 'status']:
return reply({'authenticated': True, 'token_valid': True})
if command[1:3] == ['aisearch', 'person']:
return reply({'result': [{'openDingTalkId': PERSON, 'meta': {'name': command[command.index('--query') + 1]}}]})
self.assertEqual(command[1:3], ['chat', '+messages-send'])
return reply(OK)
def execute(self, args, run=None):
out = io.StringIO()
with contextlib.redirect_stdout(out):
code = n.main(args, root=self.root, run=run or self.fake, sleep=self.sleeps.append)
return code, json.loads(out.getvalue())
def test_each_event_personal_markdown(self):
for event, label in n.EVENTS.items():
code, result = self.execute(['progress', event, '**进展**\n\n- 已归档'])
self.assertEqual(code, 0)
self.assertEqual(result['status'], 'accepted')
cmd = self.calls[-1][0]
self.assertEqual(cmd[cmd.index('--as') + 1], 'user')
self.assertEqual(cmd[cmd.index('--group') + 1], GROUP)
self.assertEqual(cmd[cmd.index('--profile') + 1], PROFILE)
body = cmd[cmd.index('--markdown') + 1]
self.assertIn(label, body)
self.assertIn('项目进度自动汇报', body)
self.assertIn('**进展**\n\n- 已归档', body)
self.assertNotIn('--ai-tag=false', cmd)
self.assertNotIn('--robot-code', cmd)
def test_message_mentions_exact_name_fixed_profile(self):
code, data = self.execute(['message', '--title', '提醒', '--text', '请补充资料', '--at', '测试人,测试人'])
self.assertEqual(code, 0)
self.assertEqual(len(self.calls), 2)
for cmd, _ in self.calls:
self.assertEqual(cmd[cmd.index('--profile') + 1], PROFILE)
cmd = self.calls[-1][0]
self.assertEqual(cmd[cmd.index('--at-open-dingtalk-ids') + 1], PERSON)
self.assertIn('<@fake-person>', cmd[cmd.index('--markdown') + 1])
self.assertEqual(data['mentions'], ['测试人'])
self.assertEqual(data['openTaskId'], 'fake-task')
def test_progress_can_include_mention_once(self):
code, _ = self.execute(['progress', 'blocked', '缺输入', '--at', '测试人'])
self.assertEqual(code, 0)
self.assertEqual(sum(c[0][1:3] == ['chat', '+messages-send'] for c in self.calls), 1)
def test_lookup_ambiguity_no_match_and_malformed_never_send(self):
for rows in [[], [{'openDingTalkId': 'a', 'meta': {'name': '甲'}},
{'openDingTalkId': 'b', 'meta': {'name': '甲'}}],
[{'userId': 'wrong-id-kind', 'meta': {'name': '甲'}}], None]:
self.calls.clear()
def lookup(command, **kwargs):
self.calls.append(command)
return reply({'result': rows})
code, data = self.execute(['message', '--title', '提醒', '--text', '正文', '--at', '甲'], lookup)
self.assertEqual(code, 1)
self.assertEqual(data['status'], 'error')
self.assertEqual(len(self.calls), 1)
def test_check_is_read_only(self):
code, data = self.execute(['check', '--at', '测试人'])
self.assertEqual(code, 0)
self.assertEqual(data['status'], 'checked')
self.assertEqual(len(self.calls), 2)
self.assertNotIn('fake-person', json.dumps(data))
def test_check_bad_login_stops(self):
code, _ = self.execute(['check'], lambda *a, **kw: reply({'authenticated': False, 'token_valid': False}))
self.assertEqual(code, 1)
def test_disabled_and_missing_config_no_tools(self):
self.config(ENABLED='0')
self.assertEqual(self.execute(['progress', 'start', '开始'])[1]['status'], 'skipped')
self.path.unlink()
self.assertEqual(self.execute(['progress', 'start', '开始'])[1]['status'], 'skipped')
self.assertFalse(self.calls)
def test_preview_without_config_or_dws(self):
self.path.unlink()
code, result = self.execute(['progress', 'blocked', '说明', '--at', '甲', '--dry-run'])
self.assertEqual(code, 0)
self.assertEqual(result['status'], 'preview')
self.assertFalse(self.calls)
def test_legacy_and_invalid_config_fail_without_echo(self):
for suffix in ['CLIENT_SECRET=fake-secret', 'MODE=app-bot', 'ENABLED=1', 'UNKNOWN=x']:
self.config()
with self.path.open('a', encoding='utf-8') as f:
f.write(n.PREFIX + suffix + '\n')
code, data = self.execute(['progress', 'start', '开始'])
self.assertEqual(code, 1)
self.assertNotIn('fake-secret', json.dumps(data))
self.assertFalse(self.calls)
def test_config_bom_crlf_and_literal_values(self):
raw = self.path.read_text(encoding='utf-8').replace('测试项目', '中文 "引号" $()')
self.path.write_bytes(b'\xef\xbb\xbf' + raw.replace('\n', '\r\n').encode())
code, data = self.execute(['progress', 'start', '开始', '--dry-run'])
self.assertEqual(code, 0)
self.assertIn('中文 "引号" $()', data['text'])
def test_files_and_invalid_bodies(self):
body = self.root / '正文.md'
body.write_text('**进展**\n\n- 中文 "引号" $()', encoding='utf-8')
for args in [['progress', 'milestone'], ['message', '--title', '说明']]:
self.assertEqual(self.execute(args + ['--file', str(body)])[0], 0)
before = len(self.calls)
for args in [['progress', 'start', ''], ['progress', 'start', 'x' * 12001],
['progress', 'start', '正文', '--file', str(body)],
['message', '--title', '坏\n标题', '--text', '内容'],
['progress', 'start', '--file', str(self.path), '--dry-run']]:
self.assertEqual(self.execute(args)[0], 1)
self.assertEqual(len(self.calls), before)
def test_dns_three_attempts_only(self):
calls = []
def failed(*a, **kw):
calls.append(a)
return reply(code=1, error='lookup api.dingtalk.com: no such host')
code, _ = self.execute(['progress', 'start', '开始'], failed)
self.assertEqual(code, 1)
self.assertEqual(len(calls), 3)
self.assertEqual(self.sleeps, [10, 10])
def test_dns_then_success(self):
responses = iter([reply(code=1, error='lookup api.dingtalk.com: no such host'), reply(OK)])
self.assertEqual(self.execute(['progress', 'start', '开始'], lambda *a, **kw: next(responses))[0], 0)
self.assertEqual(self.sleeps, [10])
def test_failure_and_unknown_never_retry(self):
cases = [reply(code=1, error='fake-secret connection reset'),
reply(code=1, error=json.dumps({'error': {'category': 'auth', 'message': 'fake-secret'}})),
reply(code=1, error=json.dumps({'error': {'retryable': False, 'message': 'lookup api.dingtalk.com: no such host'}})),
reply({'success': True}), reply({'ok': False}), reply(OK | {'partial': True})]
for result in cases:
count = []
def failed(*a, **kw):
count.append(1)
return result
code, data = self.execute(['progress', 'start', '开始'], failed)
self.assertEqual(code, 1)
self.assertEqual(len(count), 1)
self.assertNotIn('fake-secret', json.dumps(data))
self.assertFalse(self.sleeps)
def test_receipt_wins_nonzero_exit(self):
self.assertEqual(self.execute(['progress', 'start', '开始'], lambda *a, **kw: reply(OK, code=1))[0], 0)
def test_wrong_receipts_rejected(self):
for bad in [OK | {'identity': 'bot'}, OK | {'tool': 'other'}, OK | {'failures': [1]},
OK | {'result': {'success': False, 'result': {'openTaskId': 'x'}}},
OK | {'result': {'success': True, 'result': {'openTaskId': ''}}}]:
self.assertIsNone(n.accepted_task(bad))
def test_timeout_and_launch_error_no_retry(self):
for error in [subprocess.TimeoutExpired('fake', 60), OSError('fake-secret')]:
calls = []
def fail(*a, **kw):
calls.append(1)
raise error
code, _ = self.execute(['progress', 'start', '开始'], fail)
self.assertEqual(code, 1)
self.assertEqual(len(calls), 1)
def test_no_app_credentials_in_child_environment(self):
with patch.dict(os.environ, {'DWS_CLIENT_SECRET': 'fake-secret', 'DWS_CLIENT_ID': 'fake-app', 'DING_SEC': 'fake-key'}):
self.execute(['progress', 'start', '开始'])
env = self.calls[-1][1]['env']
self.assertNotIn('DWS_CLIENT_SECRET', env)
self.assertNotIn('DWS_CLIENT_ID', env)
self.assertNotIn('DING_SEC', env)
self.assertEqual(self.calls[-1][1]['timeout'], 60)
def init_args(self):
return ['init', '--profile', PROFILE, '--group', GROUP, '--project', '项目甲']
def test_init_requires_confirmation_and_explicit_replace(self):
original = self.path.read_bytes()
self.assertEqual(self.execute(self.init_args())[0], 1)
self.assertEqual(self.execute(self.init_args() + ['--yes'])[0], 1)
self.assertEqual(self.path.read_bytes(), original)
self.assertFalse(self.calls)
def test_init_creates_minimal_config_no_send(self):
self.path.unlink()
code, data = self.execute(self.init_args() + ['--yes'])
self.assertEqual(code, 0)
self.assertEqual(data['status'], 'initialized')
self.assertEqual(set(n.read_config(self.root)), n.KEYS)
self.assertNotIn(b'\r', self.path.read_bytes())
self.assertEqual(len(self.calls), 2)
if os.name != 'nt':
self.assertEqual(self.path.stat().st_mode & 0o777, 0o600)
def test_init_replaces_legacy_without_retaining_secret(self):
self.path.write_text('DINGTALK_PROGRESS_CLIENT_SECRET=fake-secret\n', encoding='utf-8')
code, data = self.execute(self.init_args() + ['--yes', '--replace'])
self.assertEqual(code, 0)
self.assertNotIn('fake-secret', self.path.read_text())
self.assertNotIn('fake-secret', json.dumps(data))
self.assertEqual(len(list(self.path.parent.iterdir())), 1)
def test_init_bad_profile_and_line_injection_leave_config(self):
before = self.path.read_bytes()
for args in [self.init_args() + ['--profile', 'other:user'],
self.init_args() + ['--project', 'x\nBAD=1']]:
self.assertEqual(self.execute(args + ['--yes', '--replace'])[0], 1)
self.assertEqual(self.path.read_bytes(), before)
def test_init_failed_auth_leaves_old_config(self):
before = self.path.read_bytes()
def fake(command, **kw):
if command[1] == 'auth':
return reply({'authenticated': False})
return self.fake(command, **kw)
self.assertEqual(self.execute(self.init_args() + ['--yes', '--replace'], fake)[0], 1)
self.assertEqual(self.path.read_bytes(), before)
def test_encoded_powershell_arguments(self):
args = ['message', '--title', '中文 "标题"', '--text', '**进展**\n\n- 引号 "$()"', '--dry-run']
with patch.dict(os.environ, {n.PREFIX + 'ARGV_B64': base64.b64encode(json.dumps(args).encode()).decode()}):
code, data = self.execute(None)
self.assertEqual(code, 0)
self.assertIn('中文 "标题"', data['text'])
self.assertIn('**进展**\n\n- 引号 "$()"', data['text'])
def test_bad_encoded_arguments(self):
for value in ['!!!', base64.b64encode(b'{}').decode()]:
with patch.dict(os.environ, {n.PREFIX + 'ARGV_B64': value}):
self.assertEqual(self.execute(None)[0], 1)
self.assertFalse(self.calls)
def test_hardlinked_config_cannot_be_message_body(self):
other = self.root / 'not-a-summary.md'
os.link(self.path, other)
self.assertEqual(self.execute(['progress', 'start', '--file', str(other), '--dry-run'])[0], 1)
def test_real_powershell_launcher_in_isolated_project(self):
pwsh = shutil.which('pwsh')
if not pwsh or 'windowsapps' in sys.executable.lower():
self.skipTest('需要 PowerShell 和独立 Python,不能用商店版作启动器验收')
folder = self.root / 'scripts'
folder.mkdir()
source = Path(n.__file__).parent
for name in ['dingtalk_notify.py', 'dingtalk-python.ps1', 'dingtalk-progress.ps1', 'dingtalk-message.ps1']:
shutil.copyfile(source / name, folder / name)
env = n.clean_env()
env['DINGTALK_PYTHON'] = sys.executable
text = '**中文**\n\n- "引号" $() `原样`'
for script, args in [('dingtalk-progress.ps1', ['milestone', text]),
('dingtalk-message.ps1', ['--title', '中文 "标题"', '--text', text])]:
result = subprocess.run([pwsh, '-NoProfile', '-File', str(folder / script), *args, '--dry-run'],
cwd=self.root, env=env, capture_output=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'])
result = subprocess.run([pwsh, '-NoProfile', '-File', str(folder / 'dingtalk-python.ps1'), 'init',
'--profile', PROFILE, '--group', GROUP, '--project', '甲'],
cwd=self.root, env=env, capture_output=True, encoding='utf-8', timeout=30)
self.assertEqual(result.returncode, 1)
self.assertIn('先向负责人确认', result.stdout)
if __name__ == '__main__':
unittest.main()