Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a08cfd6a3b | ||
|
|
4f9efd175d |
No files matched your search
+84
-62
@@ -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 入口,不访问真实项目配置。覆盖配置密钥为唯一来源、密钥不回显、不通过命令参数传递以及预览不认证,不等于完成真实钉钉发送验收。
|
||||
@@ -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
@@ -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
|
||||
@@ -1,5 +1,7 @@
|
||||
# Local credentials and machine-specific configuration
|
||||
.dingtalk/config.env
|
||||
.dingtalk/config.env.*
|
||||
!.dingtalk/config.env.example
|
||||
**/.env
|
||||
**/.env.*
|
||||
!**/.env.example
|
||||
|
||||
@@ -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 在受影响规则上保持一致。
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
还应人工确认:
|
||||
|
||||
@@ -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返回索引、版本和接收结论;两条验收线针对同一对象及版本均通过才完成验收。",
|
||||
"接到任务先与本角色真人负责人对齐目标、输入和成果要求;信息足够后执行,成果自审并经负责人实际确认后才正式返回。",
|
||||
|
||||
@@ -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 "$@"
|
||||
@@ -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
@@ -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 "$@"
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user