diff --git a/.dingtalk/README.md b/.dingtalk/README.md index 0584343..a2a6b0e 100644 --- a/.dingtalk/README.md +++ b/.dingtalk/README.md @@ -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='' +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 '' \ - -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= -DINGTALK_PROGRESS_TARGET= -DINGTALK_PROGRESS_ROBOT_CODE= -``` - -先保持 `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 入口,不访问真实项目配置。覆盖配置密钥为唯一来源、密钥不回显、不通过命令参数传递以及预览不认证,不等于完成真实钉钉发送验收。 diff --git a/.dingtalk/config.env.example b/.dingtalk/config.env.example index d48262b..f413a85 100644 --- a/.dingtalk/config.env.example +++ b/.dingtalk/config.env.example @@ -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=[项目进度] diff --git a/.gitattributes b/.gitattributes index d7677d6..6e2127f 100644 --- a/.gitattributes +++ b/.gitattributes @@ -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 diff --git a/.gitignore b/.gitignore index 217c9e5..a6005ad 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ # Local credentials and machine-specific configuration .dingtalk/config.env +.dingtalk/config.env.* +!.dingtalk/config.env.example **/.env **/.env.* !**/.env.example diff --git a/AGENTS.md b/AGENTS.md index 0b6a95e..f23c21e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 或其他凭据和内部标识。 @@ -89,8 +89,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 配置,插件缓存更新及在途项目迁移仍须明确确认;不要求角色每轮核对发布号,不将仓库发版规则塞进运行时 Skill。 @@ -101,7 +102,9 @@ ```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 ``` @@ -111,7 +114,7 @@ Skill Creator 的验证脚本位置取决于协作者的 Codex 安装。若默 ## Definition of Done - 修改 Skill 时已通过 Skill Creator 的结构校验。 -- 仅在修改 `scripts/dingtalk-progress` 时执行 Shell 语法检查。 +- 修改钉钉封装时运行离线测试;修改 Shell 入口时检查语法,修改 PowerShell 入口时核验解析和实际参数传递。只使用隔离假配置,不触发真实通知。 - 所有新增或改动的 Markdown 相对链接均能解析。 - 文件中没有待办标记、占位内容、真实凭据、群标识或误导性的缺失文件引用。 - 根 Skill、公共 reference、角色 reference 和对应 Hook 在受影响规则上保持一致。 diff --git a/README.md b/README.md index c31c8b1..a5f95cf 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ 本仓库为 Etunel 多 Agent 项目提供一套中文版、渐进式披露的角色职责与协作流程:项目Hub负责跨角色中继和全局推进,七个成员角色在各自真人负责人参与下完成专业任务,并以可追踪成果交回 Hub。 -> **钉钉定位说明:** 仓库中的 `.agents/skills/dingtalk-*`、`.dingtalk/` 和 `scripts/dingtalk-progress` 只作为参考与演示,用来告诉 Hub 如何调用钉钉发送项目进度或异常通知。钉钉不是 Etunel 的角色通信链路、任务队列、审批、门禁或项目事实源,也不会改变任何角色边界。 +> **钉钉定位说明:** 仓库中的 `.agents/skills/dingtalk-*`、`.dingtalk/` 和 `scripts/` 下的钉钉封装只作为接入参考,用来告诉 Hub 如何发送项目进度、异常摘要或负责人提醒。钉钉不是 Etunel 的角色通信链路、任务队列、审批、门禁或项目事实源,也不会改变任何角色边界。 ## 先了解这三件事 @@ -14,18 +14,27 @@ ## 版本与升级 -本版为 `v1.0.1`,流程设计基线仍为 **V1.0**。发布版本表示一套可领取的仓库文件,流程基线表示其依据的已确认流程,两者不必同步递增。正式发布以 Git 标签和对应发布包为准,主分支可能包含尚未发布的修改。 +本版为 `v1.0.2`,流程设计基线仍为 **V1.0**。版本采用 `vX.Y.Z`:前两位 `X.Y` 跟随 HTML 主流程中明确标注且已确认的版本,第三位 `Z` 表示该流程版本下的仓库发布迭代。正式发布以 Git 标签和对应发布包为准,主分支可能包含尚未发布的修改。 | 变更 | 版本规则 | | --- | --- | -| 不改变既定角色边界、必需成果或阶段条件的纠错、澄清与执行优化 | 升修订号,例如 `1.0.0 → 1.0.1` | -| 增加可选能力或场景,原有项目仍能按原约定执行 | 升次版本号,例如 `1.0.1 → 1.1.0` | -| 改变既有职责、成果或阶段门禁,旧项目需重新确认执行方式 | 升主版本号,例如 `1.x.x → 2.0.0` | +| HTML 流程版本不变,更新 Skill、Hook、脚本、配置或说明等任意内容 | 只递增第三位,例如流程保持 V1.0,`v1.0.1 → v1.0.2` | +| HTML 流程正式更新到新的 X.Y | 前两位同步,第三位从 0 开始,例如流程 V1.1 对应 `v1.1.0` | +| 日常提交,尚未安排发布 | 不必为每次提交创建标签;下一次正式发版按上述规则编号 | -全仓库只维护这一套发布序列,不给 Skill、Hook 分别编号。流程设计变更先确认,再同步受影响文件;只修正 Skill 执行说明时,不必改流程图版本。日常修改正常提交,需要交给团队使用时才统一校验、确定版本、创建 Git 标签及发布说明;已发布标签和内容不覆盖,修正发新版本。 +全仓库只维护这一套发布序列,不给 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 更新范围 - 根据一轮项目复盘,补清旧任务收尾、开工输入与本次产出的区分、资料异常交专业角色核实、查询无新线索时转交负责人决定,以及模拟不默认跳过交接。 @@ -100,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 源文件 @@ -108,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 在目标项目中维护统一的 `项目资料/`,而不是把项目成果写回本约束仓库: @@ -159,15 +175,19 @@ Hook 文件需保持短小,并与完整 Skill 职责一致。它们由 Etunel 钉钉集成是 Hub 的辅助通知参考,不是 Etunel 协作系统的一部分。仓库保留它,是为了让 Hub 在满足事件条件时有一个清晰、安全、可替换的调用方法。 -只有 Hub/主 Agent 可以使用下面的封装入口: +只有 Hub/主 Agent 可以使用下面的通用 Python 封装入口(Python 3.9+): ```sh -./scripts/dingtalk-progress "<简短中文 Markdown 摘要>" +python scripts/dingtalk_notify.py progress "<简短中文 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 或群名。仓库维护时也不要用真实发送作为普通测试。 @@ -190,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 ``` 还应人工确认: diff --git a/etunel-role-collaboration/SKILL.md b/etunel-role-collaboration/SKILL.md index c10bf97..567a090 100644 --- a/etunel-role-collaboration/SKILL.md +++ b/etunel-role-collaboration/SKILL.md @@ -59,7 +59,7 @@ Etunel 负责当前角色和会话绑定。不要向负责人询问或复述 rol - 缺信息、资料冲突、查询无新线索、旧任务收尾、错投、阻塞、超时升级、会议决定、返工、变更、流程跳转或模拟:读 [异常与协调](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 的渐进式发现树。 diff --git a/etunel-role-collaboration/references/dingtalk-progress-reporting.md b/etunel-role-collaboration/references/dingtalk-progress-reporting.md index a5d7f7f..b72f082 100644 --- a/etunel-role-collaboration/references/dingtalk-progress-reporting.md +++ b/etunel-role-collaboration/references/dingtalk-progress-reporting.md @@ -1,17 +1,15 @@ # Hub 钉钉项目进度汇报 -仅当当前会话是 Hub 且发生本文件定义的汇报事件时读取。钉钉是面向项目群组的单向辅助可见性与异常提醒,不是任务派发、跨角色沟通、审批、门禁、人类确认或项目记录的事实源。 +仅当当前会话是 Hub 且发生汇报事件或需提醒负责人处理具体问题时读取。钉钉是面向项目群组的单向辅助可见性与异常提醒,不是任务派发、跨角色沟通、审批、门禁、人类确认或项目记录的事实源。 -## 权限与唯一入口 +## 权限与封装入口 - 只有 Hub/主 Agent 可以发送;成员 Agent 只把成果、状态或阻塞交给 Hub。 - 项目配置为启用时视为持续发送授权,不需每条消息再次询问。 -- 只能调用项目封装入口: - - `./scripts/dingtalk-progress "<中文 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 不在脚本外追加重试循环。 diff --git a/etunel-role-collaboration/references/dingtalk-script-usage.md b/etunel-role-collaboration/references/dingtalk-script-usage.md new file mode 100644 index 0000000..eb7539d --- /dev/null +++ b/etunel-role-collaboration/references/dingtalk-script-usage.md @@ -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 主流程。 diff --git a/etunel-role-collaboration/references/hub-workflow.md b/etunel-role-collaboration/references/hub-workflow.md index e8fd012..031d323 100644 --- a/etunel-role-collaboration/references/hub-workflow.md +++ b/etunel-role-collaboration/references/hub-workflow.md @@ -159,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 禁止事项 diff --git a/etunel-role-hook-contexts/中心角色精简职责与每轮提醒.md b/etunel-role-hook-contexts/中心角色精简职责与每轮提醒.md index d91bf46..63e917d 100644 --- a/etunel-role-hook-contexts/中心角色精简职责与每轮提醒.md +++ b/etunel-role-hook-contexts/中心角色精简职责与每轮提醒.md @@ -27,14 +27,15 @@ "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" ] } ``` @@ -49,5 +50,5 @@ 用 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,标识从入站消息取。 只检查当前必需条件,复用成果,阶段通过后推进,后续资料按需索取。决定变化要说明旧任务停续和新输入;资料冲突交专业角色核实。按阶段留档并更新进度、成果索引和记录;直交测试文件登记索引、版本及产品收讫。阻塞请相应负责人协调;跳转、回退或暂缓如实记录,模拟默认不跳过交接、不关闭真实问题。 -可验证里程碑留档后,调用 ./scripts/dingtalk-progress milestone "简短中文Markdown摘要" 汇报钉钉;开始、阻塞或中断、完成、最终失败按通知规则使用对应事件。用短段或列表说清结果、问题和下一步,不堆日志或中英混杂;失败不阻塞主流程,钉钉不能代替Etunel回复或结算。 +可验证里程碑留档后,用 python scripts/dingtalk_notify.py progress milestone "中文Markdown摘要" 报钉钉;其他事件按通知规则。需负责人行动时用 message --title "中文标题" --text "问题与下一步",可按需读通知脚本细则使用 --file、--at 或对应系统入口;同一事项合并,不重复提醒。结果回各自角色会话;配置和密钥由封装读取,不打开真实config.env、不索要编号或凭据,不在脚本外重试,预览不算发送,通知失败不阻塞主流程、不代替Etunel回复或结算。 ``` diff --git a/scripts/dingtalk-bot-message b/scripts/dingtalk-bot-message new file mode 100644 index 0000000..5c485fb --- /dev/null +++ b/scripts/dingtalk-bot-message @@ -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 "$@" diff --git a/scripts/dingtalk-bot-message.ps1 b/scripts/dingtalk-bot-message.ps1 new file mode 100644 index 0000000..b506cfe --- /dev/null +++ b/scripts/dingtalk-bot-message.ps1 @@ -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 diff --git a/scripts/dingtalk-progress b/scripts/dingtalk-progress index df50197..9844cc6 100644 --- a/scripts/dingtalk-progress +++ b/scripts/dingtalk-progress @@ -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 <中文 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 "$@" diff --git a/scripts/dingtalk-progress.ps1 b/scripts/dingtalk-progress.ps1 new file mode 100644 index 0000000..1d30041 --- /dev/null +++ b/scripts/dingtalk-progress.ps1 @@ -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 diff --git a/scripts/dingtalk_notify.py b/scripts/dingtalk_notify.py new file mode 100644 index 0000000..d3cd37f --- /dev/null +++ b/scripts/dingtalk_notify.py @@ -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()) diff --git a/scripts/test_dingtalk_notify.py b/scripts/test_dingtalk_notify.py new file mode 100644 index 0000000..bb55852 --- /dev/null +++ b/scripts/test_dingtalk_notify.py @@ -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()