feat: 统一阶段通知模板并支持逐项提醒负责人

This commit is contained in:
liyun committed 2026-09-29 20:05:30 +08:00
1 parent 1d85de4c7c
commit 3fb9c0658d
10 files changed
+301 -58

No files matched your search

+29 -4
View File
@@ -42,12 +42,37 @@ DWS 安装与登录以 [官方项目](https://github.com/DingTalk-Real-AI/dingta
## 日常调用 ## 日常调用
```text ```text
python scripts/dingtalk_notify.py progress milestone "**已完成**:阶段成果已归档。下一步安排联调。" python scripts/dingtalk_notify.py progress milestone --stage "阶段八:复盘结项" "**已确认的进展**
python scripts/dingtalk_notify.py progress blocked "缺少接口说明,请补充后回自己的角色会话同步。" --at "完整姓名"
python scripts/dingtalk_notify.py message --title "资料补充提醒" --text "请补齐已约定资料,并回自己的角色会话确认。" --at "完整姓名" - 相关负责人已确认:产品验收材料已在线下完成,业务已确认客户验收通过。
- 项目已进入资料归档与复盘结项阶段,目前开始整理结项资料,项目尚未关闭。
- 已通知相关负责人进行阶段交接,不新增审批。
**待办事项**
- 结项资料整理:由 Hub 整理四类结项工作稿。
- 资料补充:由产品 Agent、业务 Agent 补充现有线下成果及确认索引,不重做验收。
- 旧事项停止:由 Hub 停止此前的验收准备催办。
- 结项确认:四类成果具备后,由 Hub 请 @{示例负责人} 确认结项,确认后再锁定成果。
- 本轮工作范围:不新增应用修复、设备测试或发布验证。
**遗留**
- 原测试不通过的结果如实保留,不因进入结项阶段改为通过。
- 已接受延期的问题如实保留,后续沿用 Bug 系统中的记录和已有处理安排继续跟进。" --at "示例负责人"
python scripts/dingtalk_notify.py progress blocked --stage "阶段三:方案设计" "**待办事项**
- 方案设计:请 @{示例甲} 补充接口说明,并回自己的角色会话同步;接口说明缺失影响方案确认。" --at "示例甲"
python scripts/dingtalk_notify.py message --title "阶段三:方案设计|资料补充提醒" --text "**待办事项**
- 方案设计:请 @{示例甲} 补齐已约定的接口资料,并回自己的角色会话确认。" --at "示例甲"
``` ```
通知保留 AI 标识并标注自动汇报;摘要用简短中文 Markdown。`--at` 按完整姓名查询唯一人员,再生成个人消息的原生提及标记。两个发送命令都支持 `--file` 正文和完全离线的 `--dry-run`。不支持临时换群、手填人员编号或退回机器人发送。 以上结项场景、事项和姓名仅作格式演示;验收结论、成果数量、延期安排和确认人不作为其他项目的默认事实或要求。真实发送须使用当前项目的有效记录和已绑定负责人姓名。
`progress` 现在必须传入 `--stage`,每次使用当前项目记录里的阶段编号和名称;未登记编号时仅写已知名称。标题显示“项目名称|阶段编号:阶段名称”,不再显示固定的“阶段里程碑”。旧调用需补齐参数,并同步部署脚本和相关 Hub Skill/Hook;本地改动不等于在途项目已更新。
进度通知使用加粗标题和“已确认的进展”“待办事项”“遗留”三个栏目:待办按“事项名称:由谁做什么”逐条写清,当前阶段尚未解决的问题列入遗留。保留已定前提、停止事项和工作范围,阶段启动不写成项目关闭。确实没有待办或遗留时写“无”,未核实不能当作没有;沿用已定责任和期限,不能为了通知新增工作或重填清单。具体写法见[通知规则](../etunel-role-collaboration/references/dingtalk-progress-reporting.md#消息写法)。通知保留 AI 标识并标注自动汇报;摘要用简短中文 Markdown。在对应事项中写 `@{完整姓名}`,并通过 `--at` 传入相同姓名,脚本唯一匹配后原位生成原生 @;多人分别对应各自事项,同一接收人去重。仅传 `--at` 的旧写法仍在末尾追加提醒。离线预览只显示“@姓名”,不证明实际提醒成功。两个发送命令都支持 `--file` 正文和完全离线的 `--dry-run`。不支持临时换群、手填人员编号或退回机器人发送。
事件含义见 [通知规则](../etunel-role-collaboration/references/dingtalk-progress-reporting.md),详细调用与结果见 [脚本用法](../etunel-role-collaboration/references/dingtalk-script-usage.md)。 事件含义见 [通知规则](../etunel-role-collaboration/references/dingtalk-progress-reporting.md),详细调用与结果见 [脚本用法](../etunel-role-collaboration/references/dingtalk-script-usage.md)。
+8 -4
View File
@@ -64,13 +64,17 @@
- Agent 只用 `scripts/dingtalk_notify.py` 的 `progress`/`message`,或等价的 `dingtalk-progress`/`dingtalk-message` Shell、PowerShell 入口,统一以已确认的个人 DWS 账号发送。进度事件仍限定为 `start`、`milestone`、`blocked`、`complete`、`failed`;负责人提醒不新增状态。同一事项不重复通知,不得绕过封装直接调用 OpenAPI 或 `dws api`。可验证里程碑先完成项目留档,再发送简短中文 Markdown。例如: - Agent 只用 `scripts/dingtalk_notify.py` 的 `progress`/`message`,或等价的 `dingtalk-progress`/`dingtalk-message` Shell、PowerShell 入口,统一以已确认的个人 DWS 账号发送。进度事件仍限定为 `start`、`milestone`、`blocked`、`complete`、`failed`;负责人提醒不新增状态。同一事项不重复通知,不得绕过封装直接调用 OpenAPI 或 `dws api`。可验证里程碑先完成项目留档,再发送简短中文 Markdown。例如:
```sh ```sh
python scripts/dingtalk_notify.py progress milestone "**已完成** python scripts/dingtalk_notify.py progress milestone --stage "阶段六:测试验证" "**已确认的进展**
- 阶段成果已确认并归档 - 本轮测试报告已归档。
**下一步** **待办事项**
- Hub 安排后续任务" - 结果确认:由 Hub 请 @{示例负责人} 确认本轮测试结果。
**遗留**
- 无。" --at "示例负责人"
``` ```
- 事件语义与消息格式以 [dingtalk-progress-reporting.md](etunel-role-collaboration/references/dingtalk-progress-reporting.md) 为准;安装与本机配置参考 [.dingtalk/README.md](.dingtalk/README.md)。 - 事件语义与消息格式以 [dingtalk-progress-reporting.md](etunel-role-collaboration/references/dingtalk-progress-reporting.md) 为准;安装与本机配置参考 [.dingtalk/README.md](.dingtalk/README.md)。
- 通知封装是辅助接入参考,不得据此反推或改变 Etunel 主流程;不创建通知队列或外层重试。仅明确 DNS 失败最多 3 次、间隔 10 秒,结果未知不盲目重发。 - 通知封装是辅助接入参考,不得据此反推或改变 Etunel 主流程;不创建通知队列或外层重试。仅明确 DNS 失败最多 3 次、间隔 10 秒,结果未知不盲目重发。
+1 -1
View File
@@ -59,7 +59,7 @@ Etunel 负责当前角色和会话绑定。不要向负责人询问或复述 rol
- 缺信息、资料冲突、查询无新线索、旧任务收尾、错投、阻塞、超时升级、会议决定、返工、变更、流程跳转或模拟:读 [异常与协调](references/exceptions-and-coordination.md)。 - 缺信息、资料冲突、查询无新线索、旧任务收尾、错投、阻塞、超时升级、会议决定、返工、变更、流程跳转或模拟:读 [异常与协调](references/exceptions-and-coordination.md)。
- 编写给负责人、角色或群组的任务、提问、结果和状态消息:读 [人类可读沟通](references/human-readable-communication.md)。 - 编写给负责人、角色或群组的任务、提问、结果和状态消息:读 [人类可读沟通](references/human-readable-communication.md)。
- 创建项目资料目录、保存成员清单、归档成果、更新进度或阶段记录:读 [项目文件与进度留档](references/project-files-and-progress.md)。 - 创建项目资料目录、保存成员清单、归档成果、更新进度或阶段记录:读 [项目文件与进度留档](references/project-files-and-progress.md)。
- 仅当当前会话是 Hub 且需初始化个人 DWS 通知、钉钉进度汇报或负责人辅助提醒:读 [钉钉项目进度汇报](references/dingtalk-progress-reporting.md),实际调用再读其中的脚本用法。 - 仅当当前会话是 Hub 且需初始化个人 DWS 通知、钉钉进度汇报或负责人辅助提醒:读 [钉钉项目进度汇报](references/dingtalk-progress-reporting.md),标题使用当前阶段,按“已确认的进展、待办事项、遗留”三个栏目逐条表述,并 @ 已明确的真人处理人;实际调用再读其中的脚本用法。
- 测试角色仅在任务类型匹配时,按 [测试职责](references/roles/testing.md) 中的二级链接读取更深细则。 - 测试角色仅在任务类型匹配时,按 [测试职责](references/roles/testing.md) 中的二级链接读取更深细则。
不要为“全面了解”一次加载全部引用或全部角色文件。Etunel Hook 源文件独立配置,不属于本 Skill 的渐进式发现树。 不要为“全面了解”一次加载全部引用或全部角色文件。Etunel Hook 源文件独立配置,不属于本 Skill 的渐进式发现树。
@@ -56,19 +56,56 @@ Hub 必须先确认进展真实有效,并完成成果文件、成果索引和
## 消息写法 ## 消息写法
使用中文 Markdown 自然排版,可以使用标题、短段落和列表,不要求固定模板,也不要把全部内容挤成一行。像项目负责人向团队说明进展,不像日志转储。 标题使用“项目名称|阶段编号:阶段名称”,例如“示例项目|阶段六:测试验证”。Hub 每次从有效项目记录取得当前阶段编号和名称,通过 `progress --stage` 整体传入;编号未登记时只写已知阶段名称,不自行编号或因通知示例改变阶段顺序。阶段变化后使用新名称,不把“阶段里程碑”等事件标签当成阶段,也不根据正文猜测阶段。提醒入口的自定义标题同样先写当前阶段,再写提醒事项。跨阶段事项在对应条目中注明所属阶段,通知标题仍写项目当前阶段。
进度通知采用已确认的格式:加粗项目与阶段标题、自动汇报时间、“已确认的进展”“待办事项”“遗留”三个加粗栏目,栏目间留空行。每件事单独一条,用日常说法写清谁做什么。确实没有待办或遗留时写“无”;资料不足时说明尚未核实,不把未知写成没有。本轮没有新增已确认进展时如实说明。
- 已确认的进展:写清完成了什么、谁已作出什么决定以及适用范围。区分“已进入阶段/开始整理”和“已完成/已关闭”;已通知交接不等于对方已经完成交接。已决定的事项直接说明结果,不再请人重复批准。
- 待办事项:合并列出本轮需要决定、确认或处理的全部事项,用“事项名称:由谁做什么”逐条表述,例如资料整理、资料补充、旧事项停止、结果确认和本轮工作范围;按实际情况命名,不要求凑齐示例条目。标题已说明当前阶段,不必每条重复阶段名,涉及其他阶段时注明。需人工决定时写“条件具备后,由 Hub 请某负责人确认具体事项,确认后再执行后续动作”,条件只取已有约定;有可选方案时简短列明。需执行时写清已明确的处理人和动作,有已约定期限时沿用,必要时说明结果回哪个角色会话。已明确停止的催办、需要复用的成果和不新增的工作须保留,不改写成新任务。
- 遗留:逐条列出当前阶段尚未解决、需要继续跟进的事项,说明现状、已确认的后续安排及对应处理人;有已定期限、后续阶段或对阶段推进的影响时一并说明。列为遗留不等于获准延期、豁免、关闭问题或通过阶段;同一问题在待办里写本轮动作,在遗留里交代未解决状态,避免重复整段内容。
- 待办和需跟进的遗留,在已明确真人处理人或决策人时,于对应条目中 @ 该人;Hub 代为请求确认时 @ 被请求确认的真人负责人,不把“Hub”或角色名称当成可查询姓名。纯 Agent 工作可直接写 Hub 或实际角色 Agent;真人姓名缺失时沿用已知角色,不猜测或编造 @ 对象,确需提醒再按脚本用法取得已绑定姓名。多人分工写清各自动作,同一人可在对应条目中重复出现,发送接收人去重。已完成且无需行动的进展不额外 @ 人。未确定的责任、期限或事实不编造,也不为填写通知重新要求分配或填表。
- 用具体动作替代“收口”“闭环”“拉齐”等笼统表述。例如“处理方式已收口”可写成“负责人已确定处理方式”,但不得把处理方式确定写成问题已解决。原决定中的“暂不修复”“无需新增清单”等范围和限制必须保留。
以下用结项整理场景演示已确认的通知格式,项目名与人员称呼均已泛化。示例中的验收结论、成果数量、确认人及延期安排只属于该场景,不成为其他项目的默认事实、职责或放行条件;各项目仍依据自己的有效记录和流程基线生成内容。
```markdown
**示例项目|阶段八:复盘结项**
项目进度自动汇报 · 发送时自动生成带时区的时间
**已确认的进展**
- 相关负责人已确认:产品验收材料已在线下完成,业务已确认客户验收通过。
- 项目已进入资料归档与复盘结项阶段,目前开始整理结项资料,项目尚未关闭。
- 已通知相关负责人进行阶段交接,不新增审批。
**待办事项**
- 结项资料整理:由 Hub 整理四类结项工作稿。
- 资料补充:由产品 Agent、业务 Agent 补充现有线下成果及确认索引,不重做验收。
- 旧事项停止:由 Hub 停止此前的验收准备催办。
- 结项确认:四类成果具备后,由 Hub 请 @示例负责人 确认结项,确认后再锁定成果。
- 本轮工作范围:不新增应用修复、设备测试或发布验证。
**遗留**
- 原测试不通过的结果如实保留,不因进入结项阶段改为通过。
- 已接受延期的问题如实保留,后续沿用 Bug 系统中的记录和已有处理安排继续跟进。
```
示例标题、时间和栏目只说明最终呈现方式;调用时正文文件仅含正文,项目名、阶段标题与时间由脚本生成,不重复粘贴整条通知。
保留个人消息的 AI 标识和自动汇报说明,让群成员知道这是 Hub 自动通知,不是负责人本人新增批准、验收或承诺。 保留个人消息的 AI 标识和自动汇报说明,让群成员知道这是 Hub 自动通知,不是负责人本人新增批准、验收或承诺。
根据事件选择必要内容:项目名称、当前阶段、已验证进展、关键成果、下一步和责任角色;阻塞时再说明问题、影响以及需要谁采取什么动作。通常保持一屏内可读。 根据事件选择必要内容:已验证进展、关键成果、下一步和责任角色;阻塞时再说明问题、影响以及需要谁采取什么动作。尽量简短,但不能为了控制篇幅省掉本轮需要决定或处理的事项。专业细节留在成果或问题系统中,通知不新建另一份问题台账。
不在正文中写运行时 ID、英文成果名、机器状态码或内部字段。只写已验证结论,不得包含密钥、Token、个人数据、客户敏感信息、大段日志、完整内部对话或未经验证的根因推断。 不在正文中写运行时 ID、英文成果名、机器状态码或内部字段。只写已验证结论,不得包含密钥、Token、个人数据、客户敏感信息、大段日志、完整内部对话或未经验证的根因推断。
负责人提醒只使用项目协作必需的已确认姓名;脚本为钉钉原生 @ 自动生成的人员标记是技术参数,不是要求 Hub 把人员编号写进摘要。需要负责人行动时,说明缺什么、影响什么、请谁做什么,以及回哪个角色会话补充结果。不要在群里建立另一套任务或审批流程。 负责人提醒只使用项目协作必需的已确认姓名。Hub 在原始正文中用 `@{完整姓名}` 指定条目内的位置,并用 `--at` 列出相同姓名;脚本查询唯一匹配后原位转换为钉钉原生 @。纯文字“@姓名”只用于人读预览,不能代替实际提及参数。脚本自动生成的人员标记是技术参数,不是要求 Hub 把人员编号写进摘要。需要负责人行动时,说明缺什么、影响什么、请谁做什么,以及回哪个角色会话补充结果。不要在群里建立另一套任务或审批流程。
## 调用与结果 ## 调用与结果
1. Hub 判断事件并形成中文 Markdown 摘要。 1. Hub 判断事件、取得当前阶段名称,按上面的栏目形成逐条中文摘要。用户要求先看内容时,先展示完整通知预览,得到本次发送确认后再调用真实发送;离线预览不代表已发送。
2. 调用封装脚本一次;脚本自行处理配置检查、发送和有限重试。 2. 调用封装脚本一次;脚本自行处理配置检查、发送和有限重试。
3. 只有真实个人发送响应成功且含非空 `openTaskId`,才表示请求已经接受;不证明已投递、已读或接收端 @ 提醒成功。必要时按脚本用法查询投递状态,不把任务回执当消息编号。 3. 只有真实个人发送响应成功且含非空 `openTaskId`,才表示请求已经接受;不证明已投递、已读或接收端 @ 提醒成功。必要时按脚本用法查询投递状态,不把任务回执当消息编号。
4. 缺少或未启用配置时不真实发送;显式 `--dry-run` 仅在本地生成正文,不查通讯录、不联网,也不证明目标配置和 @ 有效。配置仍为旧格式时停止并请求重新初始化。 4. 缺少或未启用配置时不真实发送;显式 `--dry-run` 仅在本地生成正文,不查通讯录、不联网,也不证明目标配置和 @ 有效。配置仍为旧格式时停止并请求重新初始化。
@@ -28,21 +28,47 @@ Windows 优先用 `.ps1`,须同时部署共用启动器 `dingtalk-python.ps1`
## 日常发送 ## 日常发送
```text ```text
python scripts/dingtalk_notify.py progress milestone "**已完成**:阶段成果已确认并归档。下一步安排联调。" python scripts/dingtalk_notify.py progress milestone --stage "阶段八:复盘结项" "**已确认的进展**
python scripts/dingtalk_notify.py progress blocked "缺少接口说明,请补充后回自己的角色会话同步。" --at "完整姓名"
python scripts/dingtalk_notify.py message --title "资料补充提醒" --text "请补齐已约定资料,并回自己的角色会话确认。" --at "完整姓名" - 相关负责人已确认:产品验收材料已在线下完成,业务已确认客户验收通过。
- 项目已进入资料归档与复盘结项阶段,目前开始整理结项资料,项目尚未关闭。
- 已通知相关负责人进行阶段交接,不新增审批。
**待办事项**
- 结项资料整理:由 Hub 整理四类结项工作稿。
- 资料补充:由产品 Agent、业务 Agent 补充现有线下成果及确认索引,不重做验收。
- 旧事项停止:由 Hub 停止此前的验收准备催办。
- 结项确认:四类成果具备后,由 Hub 请 @{示例负责人} 确认结项,确认后再锁定成果。
- 本轮工作范围:不新增应用修复、设备测试或发布验证。
**遗留**
- 原测试不通过的结果如实保留,不因进入结项阶段改为通过。
- 已接受延期的问题如实保留,后续沿用 Bug 系统中的记录和已有处理安排继续跟进。" --at "示例负责人"
python scripts/dingtalk_notify.py progress blocked --stage "阶段三:方案设计" "**待办事项**
- 方案设计:请 @{示例甲} 补充接口说明,并回自己的角色会话同步;接口说明缺失影响方案确认。" --at "示例甲"
python scripts/dingtalk_notify.py message --title "阶段三:方案设计|资料补充提醒" --text "**待办事项**
- 方案设计:请 @{示例甲} 补齐已约定的接口资料,并回自己的角色会话确认。" --at "示例甲"
``` ```
事件仍只有 `start`、`milestone`、`blocked`、`complete`、`failed`。脚本增加项目名、中文标题、带时区时间和自动汇报说明,不替 Hub 判断阶段。保留 DWS 的 AI 标识,不能冒充负责人本人批准或承诺。 以上结项场景、事项和姓名仅作格式演示;验收结论、成果数量、延期安排和确认人不作为其他项目的默认事实或要求。真实发送须使用当前项目的有效记录和已绑定负责人姓名。
事件仍只有 `start`、`milestone`、`blocked`、`complete`、`failed`。`progress` 必须传入 `--stage`,值为 Hub 从当前项目记录取得的阶段编号及中文名称;未登记编号时仅使用已知名称。脚本生成“项目名称|阶段编号:阶段名称”、带时区时间和自动汇报说明,不推断阶段、不把阶段写进固定配置。阶段缺失、空白、包含控制字符或超过 200 字时停止,不发送泛称“阶段里程碑”的通知。保留 DWS 的 AI 标识,不能冒充负责人本人批准或承诺。
旧调用需补充 `--stage`,并同步更新目标 Hub 项目的脚本、Skill 和 Hook;仓库修改不等于目标项目已经部署。正文由 Hub 按[通知写法](dingtalk-progress-reporting.md#消息写法)组织“已确认的进展”“待办事项”“遗留”三个栏目,待办用“事项名称:由谁做什么”逐条编写。脚本生成加粗标题、时间与自动汇报说明,原样保留正文的条件、范围、顺序和完成状态,仅转换指定的 @ 标记,不自动拆句、推断责任人或追加任务。较长通知优先使用 `--file`,文件只保存三个栏目的正文。
- 多行中文 Markdown 作为一个参数传入;较长摘要可先保存成 UTF-8 文件,再用 `--file "实际正文文件路径"` 替代摘要或 `--text`。文件须实际存在,路径相对工作目录;这是正文,不是上传附件,不发送大段成果或日志。 - 多行中文 Markdown 作为一个参数传入;较长摘要可先保存成 UTF-8 文件,再用 `--file "实际正文文件路径"` 替代摘要或 `--text`。文件须实际存在,路径相对工作目录;这是正文,不是上传附件,不发送大段成果或日志。
- `progress` 和 `message` 均支持 `--at`,多个姓名可逗号分隔或重复传入。负责人姓名来自 Etunel 已绑定的 `member_alias`,不把角色名称当姓名;人员查询和发送固定使用初始化账号,不跟随默认账号切换。 - `progress` 和 `message` 均支持 `--at`,多个姓名可逗号分隔或重复传入。负责人姓名来自 Etunel 已绑定的 `member_alias`,不把角色名称当姓名;人员查询和发送固定使用初始化账号,不跟随默认账号切换。
- 在待办或遗留条目中使用 `@{完整姓名}`,并在 `--at` 中列出同一姓名;脚本在该位置生成原生 @,不把已原位提及的人再追加到末尾。只传 `--at` 而未在正文指定位置的人仍追加到末尾。多人可分别放在各自事项中,同一人重复出现时只查询一次、发送接收人去重。缺少配对姓名或标记不完整时停止,不把普通文字 @ 当成原生提醒。
- 脚本只接受唯一姓名匹配,自动构造个人消息所需的 `openDingTalkId` 和提及标记。查不到、重名或权限不足时停止,不让负责人手填编号,不删掉 @ 降级发送。 - 脚本只接受唯一姓名匹配,自动构造个人消息所需的 `openDingTalkId` 和提及标记。查不到、重名或权限不足时停止,不让负责人手填编号,不删掉 @ 降级发送。
- 默认且仅发配置的项目群。同一事项合成一条,事件通知可直接 @ 人,不额外重复提醒。提醒讨论结果回各自角色会话,不在群里另派任务或批准。 - 默认且仅发配置的项目群。同一事项合成一条,事件通知可直接 @ 人,不额外重复提醒。提醒讨论结果回各自角色会话,不在群里另派任务或批准。
## 检查与结果 ## 检查与结果
排障可用 `python scripts/dingtalk_notify.py check --at "完整姓名"`,省略 `--at` 只查登录;它联网但不发送,不是每轮必做。两个发送入口加 `--dry-run` 可完全离线预览,不查人、不验证目标或 @ 效果。 排障可用 `python scripts/dingtalk_notify.py check --at "完整姓名"`,省略 `--at` 只查登录;它联网但不发送,不是每轮必做。两个发送入口加 `--dry-run` 可完全离线预览,条目中的标记显示为“@姓名”;不查人、不验证目标或 @ 效果。
- `initialized`:已创建经确认的个人通知配置,未发送。 - `initialized`:已创建经确认的个人通知配置,未发送。
- `accepted`:真实个人发送响应成功且含非空 `openTaskId`,请求已接受,不等于已投递、已读或接收端提醒成功。保留回执供排障,面向负责人只说中文结果。 - `accepted`:真实个人发送响应成功且含非空 `openTaskId`,请求已接受,不等于已投递、已读或接收端提醒成功。保留回执供排障,面向负责人只说中文结果。
@@ -162,7 +162,7 @@ Hub 不把 AI 自主生成当成人类确认。Hub 自己的负责人在以下
## 钉钉进度 ## 钉钉进度
只有 Hub 可以发送项目进度。Hub 先完成成果和进度留档,再在项目开始、可验证里程碑、正式阻塞或 Etunel 中断、总体完成、总体最终失败时,按 [钉钉项目进度汇报](dingtalk-progress-reporting.md) 调用进度封装。需要负责人处理具体问题或超过约定时间需提醒时,可按 [脚本用法](dingtalk-script-usage.md) 发送简短中文提醒;同一事项合并发送,不重复发进度和提醒。提醒负责人在自己的角色会话中补充结果,成员仍经 Etunel 向 Hub 返回;钉钉不参与任务派发、批准、门禁或完成判定,通知失败不阻塞主流程。 只有 Hub 可以发送项目进度。通知标题使用项目当前阶段编号和名称,正文按已确认的进展、待办事项、遗留逐条说明,待办写清“事项名称:由谁做什么”,在事项中 @ 已明确的真人处理人,保留条件、停止事项和工作范围,阶段启动不写成项目关闭;格式按通知细则。Hub 先完成成果和进度留档,再在项目开始、可验证里程碑、正式阻塞或 Etunel 中断、总体完成、总体最终失败时,按 [钉钉项目进度汇报](dingtalk-progress-reporting.md) 调用进度封装。需要负责人处理具体问题或超过约定时间需提醒时,可按 [脚本用法](dingtalk-script-usage.md) 发送简短中文提醒;同一事项合并发送,不重复发进度和提醒。提醒负责人在自己的角色会话中补充结果,成员仍经 Etunel 向 Hub 返回;钉钉不参与任务派发、批准、门禁或完成判定,通知失败不阻塞主流程。
## Hub 禁止事项 ## Hub 禁止事项
@@ -43,8 +43,10 @@ Etunel 消息可以把简短的事项说明放入可选 `context`,完整要求
报告阻塞时说明:当前问题、已经尝试了什么、影响范围、需要谁采取什么动作、恢复条件。机器标识由 Etunel 工具关联,不要求负责人手工抄写。 报告阻塞时说明:当前问题、已经尝试了什么、影响范围、需要谁采取什么动作、恢复条件。机器标识由 Etunel 工具关联,不要求负责人手工抄写。
涉及阶段决定或待处理事项时,一事一条:说明所属阶段、具体问题、需要谁决定什么或处理什么。列全本轮需要行动的事项;已确定的安排直接说明结果,保留原有范围、限制和完成状态,不用“收口”“闭环”等词代替具体动作,不把已决定写成已解决。
## 面向 Hub 与钉钉 ## 面向 Hub 与钉钉
成员交给 Hub 的消息也应简明,但可以附成果路径、版本和证据引用供校验。Hub 转发时忠实保留专业结论,不把内部机器字段一并转给下游。 成员交给 Hub 的消息也应简明,但可以附成果路径、版本和证据引用供校验。Hub 转发时忠实保留专业结论,不把内部机器字段一并转给下游。
钉钉消息可以使用 Markdown 标题、段落和列表。保持中文、简短、可公开,只写已验证进展、问题、影响和下一步;详细规则见 [钉钉项目进度汇报](dingtalk-progress-reporting.md)。 钉钉标题显示项目当前阶段编号和名称,正文按“已确认的进展”“待办事项”“遗留”组织;确认、决定和处理动作按“事项名称:由谁做什么”列入待办,未解决问题及跟进安排列入遗留,并在各事项中 @ 已明确的真人处理人。保留条件、停止事项和工作范围,阶段启动不写成阶段完成或项目关闭;纯 Agent 工作直接写对应角色。保持中文、简短、可公开,只写已验证进展、问题、影响和下一步;详细规则见 [钉钉项目进度汇报](dingtalk-progress-reporting.md)。
@@ -27,7 +27,7 @@
"non_goals": [ "non_goals": [
"不代专业角色开发、测试、验收或批准,不替成员完成任务,不自行批准豁免或创建Etunel角色。", "不代专业角色开发、测试、验收或批准,不替成员完成任务,不自行批准豁免或创建Etunel角色。",
"不重造Etunel的队列、传输、重试和去重机制;不以通知送达或消息结算替代成果完成。", "不重造Etunel的队列、传输、重试和去重机制;不以通知送达或消息结算替代成果完成。",
"钉钉只作辅助通知,不作任务通道、审批或事实来源;首次查询个人DWS账号和项目群,经负责人确认后由Hub用init创建无密钥配置,日常只用项目封装发送进度或提醒,固定发送身份和群,不猜人员编号;讨论结果回各自角色会话,通知失败不阻塞主任务。" "钉钉只作辅助通知,不作任务通道、审批或事实来源;首次查询个人DWS账号和项目群,经负责人确认后由Hub用init创建无密钥配置,日常只用项目封装发送进度或提醒,标题用项目当前阶段编号和名称,正文分已确认的进展、待办事项、遗留,待办用“事项:动作”写清责任与条件并@已明确的真人处理人,纯Agent工作写对应角色;已确认安排不重复要求批准,阶段启动不写成项目关闭;固定发送身份和群,不猜人员编号;讨论结果回各自角色会话,通知失败不阻塞主任务。"
], ],
"completion_tools": [ "completion_tools": [
"etunel_list_roles", "etunel_list_roles",
@@ -50,5 +50,5 @@
用 mcp__etunel__etunel_list_roles 获取实际角色;role用于路由,display_name用于显示,member_alias用于称呼已绑定负责人,不向人核对内部编号。按需用 mcp__etunel__etunel_get_project_status 查状态,但消息状态不等于成果完成。 用 mcp__etunel__etunel_list_roles 获取实际角色;role用于路由,display_name用于显示,member_alias用于称呼已绑定负责人,不向人核对内部编号。按需用 mcp__etunel__etunel_get_project_status 查状态,但消息状态不等于成果完成。
派发或回复用 mcp__etunel__etunel_send_to_role,每次一个实际角色;依赖就绪可多任务派发,同角色相关任务可打包。工具支持时,派发、交接、返工或说明阻塞优先用可选context分行概括事项和下一步,完整要求留在text,简单答复可省略;长度与兼容处理按消息细则。给足输入并写清成果要求,要求成员先与真人对齐、完成后自审并取得确认。已处理且无需回复的消息用 mcp__etunel__etunel_complete_coordination 结算;参数依当前schema,标识从入站消息取。 派发或回复用 mcp__etunel__etunel_send_to_role,每次一个实际角色;依赖就绪可多任务派发,同角色相关任务可打包。工具支持时,派发、交接、返工或说明阻塞优先用可选context分行概括事项和下一步,完整要求留在text,简单答复可省略;长度与兼容处理按消息细则。给足输入并写清成果要求,要求成员先与真人对齐、完成后自审并取得确认。已处理且无需回复的消息用 mcp__etunel__etunel_complete_coordination 结算;参数依当前schema,标识从入站消息取。
只检查当前必需条件,复用成果,阶段通过后推进,后续资料按需索取。决定变化要说明旧任务停续和新输入;资料冲突交专业角色核实。按阶段留档并更新进度、成果索引和记录;直交测试文件登记索引、版本及产品收讫。阻塞请相应负责人协调;跳转、回退或暂缓如实记录,模拟默认不跳过交接、不关闭真实问题。 只检查当前必需条件,复用成果,阶段通过后推进,后续资料按需索取。决定变化要说明旧任务停续和新输入;资料冲突交专业角色核实。按阶段留档并更新进度、成果索引和记录;直交测试文件登记索引、版本及产品收讫。阻塞请相应负责人协调;跳转、回退或暂缓如实记录,模拟默认不跳过交接、不关闭真实问题。
钉钉统一用个人DWS:首次按通知细则查询账号和项目群,经负责人确认后用init创建配置,已有配置沿用。可验证里程碑留档后用 python scripts/dingtalk_notify.py progress milestone "中文Markdown摘要" 汇报,Windows优先对应.ps1;其他事件按规则。需行动可在progress或message中加--at "完整姓名",同一事项合并,保留自动汇报说明。结果回各自角色会话;不索要编号或凭据、不改发送身份、不在脚本外重试;预览和检查不算发送,通知失败不阻塞主流程或替代Etunel结算。 钉钉统一用个人DWS:首次按通知细则查询账号和项目群,经负责人确认后用init创建配置,已有配置沿用。可验证里程碑留档后用 python scripts/dingtalk_notify.py progress milestone --stage "当前阶段编号及中文名称" "逐条中文Markdown摘要" 汇报,Windows优先对应.ps1;其他事件按规则。阶段编号和名称每次从有效项目记录取得,不自行编号;正文用已确认的进展、待办事项、遗留三个栏目;待办按“事项名称:由谁做什么”列清,保留前提、停止事项和已定范围,阶段启动不写成项目关闭。纯Agent工作写对应角色;需真人处理且姓名已明确时,在对应条目写@{完整姓名},并加--at "完整姓名",请求决定时@有权决定的真人;遗留不等于获准延期或通过门禁。同一事项合并,保留自动汇报说明。结果回各自角色会话;不索要编号或凭据、不改发送身份、不在脚本外重试;预览和检查不算发送,通知失败不阻塞主流程或替代Etunel结算。
``` ```
+40 -12
View File
@@ -16,8 +16,8 @@ import time
ROOT = Path(__file__).resolve().parent.parent ROOT = Path(__file__).resolve().parent.parent
PREFIX = 'DINGTALK_PROGRESS_' PREFIX = 'DINGTALK_PROGRESS_'
KEYS = {'ENABLED', 'PROFILE', 'TARGET', 'PROJECT'} KEYS = {'ENABLED', 'PROFILE', 'TARGET', 'PROJECT'}
EVENTS = {'start': '开始推进', 'milestone': '阶段里程碑', 'blocked': '遇到阻塞', EVENTS = ('start', 'milestone', 'blocked', 'complete', 'failed')
'complete': '本轮完成', 'failed': '执行失败'} INLINE_MENTION = re.compile(r'@\{([^{}\r\n]+)\}')
DNS_ERROR = re.compile(r'lookup [\w.-]+(?: on [^:\s]+(?::\d+)?)?: [^\n]*' DNS_ERROR = re.compile(r'lookup [\w.-]+(?: on [^:\s]+(?::\d+)?)?: [^\n]*'
r'(?:i/o timeout|no such host|server misbehaving|temporary failure in name resolution)', re.I) r'(?:i/o timeout|no such host|server misbehaving|temporary failure in name resolution)', re.I)
@@ -123,12 +123,36 @@ def check_login(cli, profile, run, sleep):
raise NoticeError('个人 DWS 未登录或授权已失效,请负责人完成登录;本次未发送。') raise NoticeError('个人 DWS 未登录或授权已失效,请负责人完成登录;本次未发送。')
def mention_names(names):
names = list(dict.fromkeys(n.strip() for value in names for n in value.replace(',', ',').split(',')))
if any(not name for name in names):
raise NoticeError('提醒对象不能为空。')
return names
def format_mentions(text, names, people=None):
names = mention_names(names)
placed = {match.group(1).strip() for match in INLINE_MENTION.finditer(text)}
if '@{' in INLINE_MENTION.sub('', text) or not placed.issubset(names):
raise NoticeError('正文中的 @{完整姓名} 必须完整填写,并在 --at 中列出同一姓名。')
by_name = {name: person for person, name in people.items()} if people is not None else None
if by_name is not None and not set(names).issubset(by_name):
raise NoticeError('提醒对象未全部唯一匹配,本次停止,不删掉 @ 后发送。')
def marker(name):
return f'<@{by_name[name]}>' if by_name is not None else f'@{name}'
text = INLINE_MENTION.sub(lambda match: marker(match.group(1).strip()), text)
remaining = [marker(name) for name in names if name not in placed]
if remaining:
text += '\n\n' + ' '.join(remaining)
return text
def mentions(names, cli, profile, run, sleep): def mentions(names, cli, profile, run, sleep):
people = {} people = {}
names = list(dict.fromkeys(n.strip() for value in names for n in value.replace(',', ',').split(','))) names = mention_names(names)
for name in names: for name in names:
if not name:
raise NoticeError('提醒对象不能为空。')
data = invoke([cli, 'aisearch', 'person', '--query', name, '--dimension', 'name', data = invoke([cli, 'aisearch', 'person', '--query', name, '--dimension', 'name',
'--profile', profile, '--format', 'json'], run, sleep) '--profile', profile, '--format', 'json'], run, sleep)
candidates = {} candidates = {}
@@ -187,6 +211,7 @@ def parse_args(argv):
init.add_argument('--replace', action='store_true') init.add_argument('--replace', action='store_true')
progress = commands.add_parser('progress') progress = commands.add_parser('progress')
progress.add_argument('event', choices=EVENTS) progress.add_argument('event', choices=EVENTS)
progress.add_argument('--stage', required=True, help='项目当前阶段编号及中文名称;每次从有效项目记录取得')
progress.add_argument('summary', nargs='*') progress.add_argument('summary', nargs='*')
progress.add_argument('--file') progress.add_argument('--file')
message = commands.add_parser('message') message = commands.add_parser('message')
@@ -196,7 +221,7 @@ def parse_args(argv):
body.add_argument('--file') body.add_argument('--file')
check = commands.add_parser('check', help='检查固定账号登录及可选姓名,不发送') check = commands.add_parser('check', help='检查固定账号登录及可选姓名,不发送')
for command in (progress, message, check): for command in (progress, message, check):
command.add_argument('--at', action='append', default=[], help='完整姓名;可重复或用逗号分隔') command.add_argument('--at', action='append', default=[], help='完整姓名;正文可用 @{完整姓名} 定位,可重复或用逗号分隔')
for command in (progress, message): for command in (progress, message):
command.add_argument('--dry-run', action='store_true', help='完全离线,只预览中文正文') command.add_argument('--dry-run', action='store_true', help='完全离线,只预览中文正文')
return parser.parse_args(argv) return parser.parse_args(argv)
@@ -215,12 +240,13 @@ def render(args, config, root):
text = ' '.join(args.summary) if args.command == 'progress' else args.text text = ' '.join(args.summary) if args.command == 'progress' else args.text
if not text.strip() or len(text.encode('utf-8')) > 12000: if not text.strip() or len(text.encode('utf-8')) > 12000:
raise NoticeError('请提供不超过 12000 字节的简短中文摘要。') raise NoticeError('请提供不超过 12000 字节的简短中文摘要。')
title = EVENTS[args.event] if args.command == 'progress' else args.title.strip() raw_title = args.stage if args.command == 'progress' else args.title
if not title or len(title) > 200 or '\n' in title or '\r' in title: title = raw_title.strip()
if not title or len(title) > 200 or any(ord(c) < 32 or ord(c) == 127 for c in raw_title):
raise NoticeError('请使用不超过 200 字的单行标题。') raise NoticeError('请使用不超过 200 字的单行标题。')
project = config.get('PROJECT') or root.name project = config.get('PROJECT') or root.name
timestamp = dt.datetime.now().astimezone().strftime('%Y-%m-%d %H:%M %z') timestamp = dt.datetime.now().astimezone().strftime('%Y-%m-%d %H:%M %z')
return f'## {project}|{title}\n\n项目进度自动汇报 · {timestamp}\n\n{text}' return f'**{project}|{title}**\n\n项目进度自动汇报 · {timestamp}\n\n{text}'
def main(argv=None, root=ROOT, run=subprocess.run, sleep=time.sleep): def main(argv=None, root=ROOT, run=subprocess.run, sleep=time.sleep):
@@ -236,7 +262,8 @@ def main(argv=None, root=ROOT, run=subprocess.run, sleep=time.sleep):
return 0 return 0
config = read_config(root) config = read_config(root)
if args.command != 'check' and args.dry_run: if args.command != 'check' and args.dry_run:
emit('preview', '仅离线预览,账号、群与 @ 效果未验证。', text=render(args, config, root), mentions=args.at) text = format_mentions(render(args, config, root), args.at)
emit('preview', '仅离线预览,账号、群与 @ 效果未验证。', text=text, mentions=mention_names(args.at))
return 0 return 0
if args.command != 'check' and config.get('ENABLED') != '1': if args.command != 'check' and config.get('ENABLED') != '1':
emit('skipped', '通知尚未初始化或未启用;本次未发送,主任务继续。') emit('skipped', '通知尚未初始化或未启用;本次未发送,主任务继续。')
@@ -244,6 +271,8 @@ def main(argv=None, root=ROOT, run=subprocess.run, sleep=time.sleep):
if not config.get('PROFILE') or not config.get('TARGET'): if not config.get('PROFILE') or not config.get('TARGET'):
raise NoticeError('请由 Hub 先确认个人账号和项目群并执行 init。') raise NoticeError('请由 Hub 先确认个人账号和项目群并执行 init。')
text = render(args, config, root) if args.command != 'check' else None text = render(args, config, root) if args.command != 'check' else None
if text is not None:
format_mentions(text, args.at) # Validate inline names before any DWS call.
cli = dws_path() cli = dws_path()
profile = config['PROFILE'] profile = config['PROFILE']
if args.command == 'check': if args.command == 'check':
@@ -252,8 +281,7 @@ def main(argv=None, root=ROOT, run=subprocess.run, sleep=time.sleep):
if args.command == 'check': if args.command == 'check':
emit('checked', '登录及所请求的姓名查询通过;未发送,未验证实际提醒效果。', names=list(people.values())) emit('checked', '登录及所请求的姓名查询通过;未发送,未验证实际提醒效果。', names=list(people.values()))
return 0 return 0
if people: text = format_mentions(text, args.at, people)
text += '\n\n' + ' '.join(f'<@{person}>' for person in people)
command = [cli, 'chat', '+messages-send', '--as', 'user', '--group', config['TARGET'], command = [cli, 'chat', '+messages-send', '--as', 'user', '--group', config['TARGET'],
'--markdown', text, '--profile', profile, '--yes', '--format', 'json'] '--markdown', text, '--profile', profile, '--yes', '--format', 'json']
if people: if people:
+145 -24
View File
@@ -61,8 +61,8 @@ class NotifyTests(unittest.TestCase):
return code, json.loads(out.getvalue()) return code, json.loads(out.getvalue())
def test_each_event_personal_markdown(self): def test_each_event_personal_markdown(self):
for event, label in n.EVENTS.items(): for event in n.EVENTS:
code, result = self.execute(['progress', event, '**进展**\n\n- 已归档']) code, result = self.execute(['progress', event, '--stage', '测试验证', '**进展**\n\n- 已归档'])
self.assertEqual(code, 0) self.assertEqual(code, 0)
self.assertEqual(result['status'], 'accepted') self.assertEqual(result['status'], 'accepted')
cmd = self.calls[-1][0] cmd = self.calls[-1][0]
@@ -70,12 +70,37 @@ class NotifyTests(unittest.TestCase):
self.assertEqual(cmd[cmd.index('--group') + 1], GROUP) self.assertEqual(cmd[cmd.index('--group') + 1], GROUP)
self.assertEqual(cmd[cmd.index('--profile') + 1], PROFILE) self.assertEqual(cmd[cmd.index('--profile') + 1], PROFILE)
body = cmd[cmd.index('--markdown') + 1] body = cmd[cmd.index('--markdown') + 1]
self.assertIn(label, body) self.assertEqual(body.splitlines()[0], '**测试项目|测试验证**')
self.assertIn('项目进度自动汇报', body) self.assertIn('项目进度自动汇报', body)
self.assertIn('**进展**\n\n- 已归档', body) self.assertIn('**进展**\n\n- 已归档', body)
self.assertNotIn('--ai-tag=false', cmd) self.assertNotIn('--ai-tag=false', cmd)
self.assertNotIn('--robot-code', cmd) self.assertNotIn('--robot-code', cmd)
def test_progress_requires_stage_before_any_dws_call(self):
with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit) as error:
self.execute(['progress', 'milestone', '已更新'])
self.assertEqual(error.exception.code, 2)
self.assertFalse(self.calls)
def test_invalid_stage_never_sends(self):
for stage in ['', ' ', '测试\n验证', '测试验证\r', '测试\t验证', '测试\0验证', '验' * 201]:
with self.subTest(stage=stage):
code, result = self.execute(['progress', 'milestone', '已更新', '--stage', stage])
self.assertEqual(code, 1)
self.assertEqual(result['status'], 'error')
self.assertFalse(self.calls)
def test_stage_changes_per_notification_without_config_change(self):
original = self.path.read_bytes()
text = '**待办事项**\n\n- 由 Hub 请项目负责人确认测试结果。\n\n**遗留**\n\n- 无。'
for stage in [' 阶段六:测试验证 ', '阶段七:平台/客户验收']:
code, result = self.execute(['progress', 'milestone', text, '--stage', stage, '--dry-run'])
self.assertEqual(code, 0)
self.assertEqual(result['text'].splitlines()[0], '**测试项目|' + stage.strip() + '**')
self.assertTrue(result['text'].endswith(text))
self.assertEqual(self.path.read_bytes(), original)
self.assertFalse(self.calls)
def test_message_mentions_exact_name_fixed_profile(self): def test_message_mentions_exact_name_fixed_profile(self):
code, data = self.execute(['message', '--title', '提醒', '--text', '请补充资料', '--at', '测试人,测试人']) code, data = self.execute(['message', '--title', '提醒', '--text', '请补充资料', '--at', '测试人,测试人'])
self.assertEqual(code, 0) self.assertEqual(code, 0)
@@ -88,11 +113,105 @@ class NotifyTests(unittest.TestCase):
self.assertEqual(data['mentions'], ['测试人']) self.assertEqual(data['mentions'], ['测试人'])
self.assertEqual(data['openTaskId'], 'fake-task') self.assertEqual(data['openTaskId'], 'fake-task')
def test_approved_closing_template_preserves_conditions_and_scope(self):
body = '''**已确认的进展**
- 相关负责人已确认:产品验收材料已在线下完成,业务已确认客户验收通过。
- 项目已进入资料归档与复盘结项阶段,目前开始整理结项资料,项目尚未关闭。
- 已通知相关负责人进行阶段交接,不新增审批。
**待办事项**
- 结项资料整理:由 Hub 整理四类结项工作稿。
- 资料补充:由产品 Agent、业务 Agent 补充现有线下成果及确认索引,不重做验收。
- 旧事项停止:由 Hub 停止此前的验收准备催办。
- 结项确认:四类成果具备后,由 Hub 请 @{测试人} 确认结项,确认后再锁定成果。
- 本轮工作范围:不新增应用修复、设备测试或发布验证。
**遗留**
- 原测试不通过的结果如实保留,不因进入结项阶段改为通过。
- 已接受延期的问题如实保留,后续沿用 Bug 系统中的记录和已有处理安排继续跟进。'''
path = self.root / '通知正文.md'
path.write_bytes(body.replace('\n', '\r\n').encode('utf-8'))
before = self.path.read_bytes()
args = ['progress', 'milestone', '--stage', '阶段八:复盘结项', '--file', str(path), '--at', '测试人']
code, preview = self.execute(args + ['--dry-run'])
self.assertEqual(code, 0)
self.assertFalse(self.calls)
self.assertEqual(preview['text'].splitlines()[0], '**测试项目|阶段八:复盘结项**')
self.assertEqual(preview['text'].split('\n\n', 2)[2], body.replace('@{测试人}', '@测试人'))
code, result = self.execute(args)
self.assertEqual(code, 0)
self.assertEqual(result['status'], 'accepted')
self.assertEqual(len(self.calls), 2)
command = self.calls[-1][0]
markdown = command[command.index('--markdown') + 1]
self.assertEqual(markdown.split('\n\n', 2)[2], body.replace('@{测试人}', '<@fake-person>'))
self.assertEqual(command[command.index('--at-open-dingtalk-ids') + 1], PERSON)
self.assertEqual(self.path.read_bytes(), before)
def test_progress_can_include_mention_once(self): def test_progress_can_include_mention_once(self):
code, _ = self.execute(['progress', 'blocked', '缺输入', '--at', '测试人']) code, _ = self.execute(['progress', 'blocked', '--stage', '测试验证', '缺输入', '--at', '测试人'])
self.assertEqual(code, 0) self.assertEqual(code, 0)
self.assertEqual(sum(c[0][1:3] == ['chat', '+messages-send'] for c in self.calls), 1) self.assertEqual(sum(c[0][1:3] == ['chat', '+messages-send'] for c in self.calls), 1)
def test_inline_mentions_match_each_item_and_deduplicate_recipients(self):
ids = {'甲': 'fake-a', '乙': 'fake-b', '丙': 'fake-c'}
def lookup(command, **kwargs):
if command[1:3] == ['aisearch', 'person']:
self.calls.append((command, kwargs))
name = command[command.index('--query') + 1]
return reply({'result': [{'openDingTalkId': ids[name], 'meta': {'name': name}}]})
return self.fake(command, **kwargs)
body = '**待办事项**\n\n- 由 Hub 请 @{甲} 确认测试结果。\n- 请 @{乙} 补充说明。\n\n**遗留**\n\n- @{乙} 按已定计划跟进未解决问题。'
code, data = self.execute(['progress', 'milestone', '--stage', '阶段六:测试验证',
body, '--at', '甲,乙,甲', '--at', '丙'], lookup)
self.assertEqual(code, 0)
self.assertEqual(data['mentions'], ['甲', '乙', '丙'])
self.assertEqual(len(self.calls), 4)
cmd = self.calls[-1][0]
actual = cmd[cmd.index('--markdown') + 1]
expected = body.replace('@{甲}', '<@fake-a>').replace('@{乙}', '<@fake-b>')
self.assertTrue(actual.endswith(expected + '\n\n<@fake-c>'))
self.assertEqual(cmd[cmd.index('--at-open-dingtalk-ids') + 1], 'fake-a,fake-b,fake-c')
for command, _ in self.calls:
self.assertEqual(command[command.index('--profile') + 1], PROFILE)
def test_inline_mentions_in_message_file(self):
path = self.root / '正文.md'
path.write_text('**待办事项**\n\n- 请 @{测试人} 补充资料。', encoding='utf-8')
code, _ = self.execute(['message', '--title', '阶段三:方案设计', '--file', str(path), '--at', '测试人'])
self.assertEqual(code, 0)
cmd = self.calls[-1][0]
text = cmd[cmd.index('--markdown') + 1]
self.assertTrue(text.endswith('- 请 <@fake-person> 补充资料。'))
self.assertEqual(text.count('<@fake-person>'), 1)
def test_inline_preview_shows_names_without_lookup(self):
code, data = self.execute(['progress', 'blocked', '--stage', '阶段三:方案设计',
'- 请 @{甲} 补充接口说明。', '--at', '甲,甲', '--at', '乙', '--dry-run'])
self.assertEqual(code, 0)
self.assertTrue(data['text'].endswith('- 请 @甲 补充接口说明。\n\n@乙'))
self.assertEqual(data['mentions'], ['甲', '乙'])
self.assertFalse(self.calls)
def test_invalid_inline_mentions_stop_before_lookup_or_send(self):
for body in ['请 @{乙} 确认', '请 @{} 确认', '请 @{甲 确认', '请 @{甲\n} 确认']:
for options in [[], ['--dry-run']]:
with self.subTest(body=body, options=options):
code, result = self.execute(['message', '--title', '提醒', '--text', body, '--at', '甲'] + options)
self.assertEqual(code, 1)
self.assertEqual(result['status'], 'error')
self.assertFalse(self.calls)
def test_inline_mentions_with_conflicting_lookup_results_never_send(self):
code, result = self.execute(['message', '--title', '提醒', '--text', '- @{甲} 和 @{乙} 请核对。', '--at', '甲,乙'])
self.assertEqual(code, 1)
self.assertEqual(result['status'], 'error')
self.assertEqual(len(self.calls), 2)
self.assertTrue(all(c[0][1:3] == ['aisearch', 'person'] for c in self.calls))
def test_lookup_ambiguity_no_match_and_malformed_never_send(self): def test_lookup_ambiguity_no_match_and_malformed_never_send(self):
for rows in [[], [{'openDingTalkId': 'a', 'meta': {'name': '甲'}}, for rows in [[], [{'openDingTalkId': 'a', 'meta': {'name': '甲'}},
{'openDingTalkId': 'b', 'meta': {'name': '甲'}}], {'openDingTalkId': 'b', 'meta': {'name': '甲'}}],
@@ -119,14 +238,14 @@ class NotifyTests(unittest.TestCase):
def test_disabled_and_missing_config_no_tools(self): def test_disabled_and_missing_config_no_tools(self):
self.config(ENABLED='0') self.config(ENABLED='0')
self.assertEqual(self.execute(['progress', 'start', '开始'])[1]['status'], 'skipped') self.assertEqual(self.execute(['progress', 'start', '--stage', '测试验证', '开始'])[1]['status'], 'skipped')
self.path.unlink() self.path.unlink()
self.assertEqual(self.execute(['progress', 'start', '开始'])[1]['status'], 'skipped') self.assertEqual(self.execute(['progress', 'start', '--stage', '测试验证', '开始'])[1]['status'], 'skipped')
self.assertFalse(self.calls) self.assertFalse(self.calls)
def test_preview_without_config_or_dws(self): def test_preview_without_config_or_dws(self):
self.path.unlink() self.path.unlink()
code, result = self.execute(['progress', 'blocked', '说明', '--at', '甲', '--dry-run']) code, result = self.execute(['progress', 'blocked', '--stage', '测试验证', '说明', '--at', '甲', '--dry-run'])
self.assertEqual(code, 0) self.assertEqual(code, 0)
self.assertEqual(result['status'], 'preview') self.assertEqual(result['status'], 'preview')
self.assertFalse(self.calls) self.assertFalse(self.calls)
@@ -136,7 +255,7 @@ class NotifyTests(unittest.TestCase):
self.config() self.config()
with self.path.open('a', encoding='utf-8') as f: with self.path.open('a', encoding='utf-8') as f:
f.write(n.PREFIX + suffix + '\n') f.write(n.PREFIX + suffix + '\n')
code, data = self.execute(['progress', 'start', '开始']) code, data = self.execute(['progress', 'start', '--stage', '测试验证', '开始'])
self.assertEqual(code, 1) self.assertEqual(code, 1)
self.assertNotIn('fake-secret', json.dumps(data)) self.assertNotIn('fake-secret', json.dumps(data))
self.assertFalse(self.calls) self.assertFalse(self.calls)
@@ -144,20 +263,20 @@ class NotifyTests(unittest.TestCase):
def test_config_bom_crlf_and_literal_values(self): def test_config_bom_crlf_and_literal_values(self):
raw = self.path.read_text(encoding='utf-8').replace('测试项目', '中文 "引号" $()') raw = self.path.read_text(encoding='utf-8').replace('测试项目', '中文 "引号" $()')
self.path.write_bytes(b'\xef\xbb\xbf' + raw.replace('\n', '\r\n').encode()) self.path.write_bytes(b'\xef\xbb\xbf' + raw.replace('\n', '\r\n').encode())
code, data = self.execute(['progress', 'start', '开始', '--dry-run']) code, data = self.execute(['progress', 'start', '--stage', '测试验证', '开始', '--dry-run'])
self.assertEqual(code, 0) self.assertEqual(code, 0)
self.assertIn('中文 "引号" $()', data['text']) self.assertIn('中文 "引号" $()', data['text'])
def test_files_and_invalid_bodies(self): def test_files_and_invalid_bodies(self):
body = self.root / '正文.md' body = self.root / '正文.md'
body.write_text('**进展**\n\n- 中文 "引号" $()', encoding='utf-8') body.write_text('**进展**\n\n- 中文 "引号" $()', encoding='utf-8')
for args in [['progress', 'milestone'], ['message', '--title', '说明']]: for args in [['progress', 'milestone', '--stage', '测试验证'], ['message', '--title', '说明']]:
self.assertEqual(self.execute(args + ['--file', str(body)])[0], 0) self.assertEqual(self.execute(args + ['--file', str(body)])[0], 0)
before = len(self.calls) before = len(self.calls)
for args in [['progress', 'start', ''], ['progress', 'start', 'x' * 12001], for args in [['progress', 'start', '--stage', '测试验证', ''], ['progress', 'start', '--stage', '测试验证', 'x' * 12001],
['progress', 'start', '正文', '--file', str(body)], ['progress', 'start', '--stage', '测试验证', '正文', '--file', str(body)],
['message', '--title', '坏\n标题', '--text', '内容'], ['message', '--title', '坏\n标题', '--text', '内容'],
['progress', 'start', '--file', str(self.path), '--dry-run']]: ['progress', 'start', '--stage', '测试验证', '--file', str(self.path), '--dry-run']]:
self.assertEqual(self.execute(args)[0], 1) self.assertEqual(self.execute(args)[0], 1)
self.assertEqual(len(self.calls), before) self.assertEqual(len(self.calls), before)
@@ -166,14 +285,14 @@ class NotifyTests(unittest.TestCase):
def failed(*a, **kw): def failed(*a, **kw):
calls.append(a) calls.append(a)
return reply(code=1, error='lookup api.dingtalk.com: no such host') return reply(code=1, error='lookup api.dingtalk.com: no such host')
code, _ = self.execute(['progress', 'start', '开始'], failed) code, _ = self.execute(['progress', 'start', '--stage', '测试验证', '开始'], failed)
self.assertEqual(code, 1) self.assertEqual(code, 1)
self.assertEqual(len(calls), 3) self.assertEqual(len(calls), 3)
self.assertEqual(self.sleeps, [10, 10]) self.assertEqual(self.sleeps, [10, 10])
def test_dns_then_success(self): def test_dns_then_success(self):
responses = iter([reply(code=1, error='lookup api.dingtalk.com: no such host'), reply(OK)]) responses = iter([reply(code=1, error='lookup api.dingtalk.com: no such host'), reply(OK)])
self.assertEqual(self.execute(['progress', 'start', '开始'], lambda *a, **kw: next(responses))[0], 0) self.assertEqual(self.execute(['progress', 'start', '--stage', '测试验证', '开始'], lambda *a, **kw: next(responses))[0], 0)
self.assertEqual(self.sleeps, [10]) self.assertEqual(self.sleeps, [10])
def test_failure_and_unknown_never_retry(self): def test_failure_and_unknown_never_retry(self):
@@ -186,14 +305,14 @@ class NotifyTests(unittest.TestCase):
def failed(*a, **kw): def failed(*a, **kw):
count.append(1) count.append(1)
return result return result
code, data = self.execute(['progress', 'start', '开始'], failed) code, data = self.execute(['progress', 'start', '--stage', '测试验证', '开始'], failed)
self.assertEqual(code, 1) self.assertEqual(code, 1)
self.assertEqual(len(count), 1) self.assertEqual(len(count), 1)
self.assertNotIn('fake-secret', json.dumps(data)) self.assertNotIn('fake-secret', json.dumps(data))
self.assertFalse(self.sleeps) self.assertFalse(self.sleeps)
def test_receipt_wins_nonzero_exit(self): def test_receipt_wins_nonzero_exit(self):
self.assertEqual(self.execute(['progress', 'start', '开始'], lambda *a, **kw: reply(OK, code=1))[0], 0) self.assertEqual(self.execute(['progress', 'start', '--stage', '测试验证', '开始'], lambda *a, **kw: reply(OK, code=1))[0], 0)
def test_wrong_receipts_rejected(self): def test_wrong_receipts_rejected(self):
for bad in [OK | {'identity': 'bot'}, OK | {'tool': 'other'}, OK | {'failures': [1]}, for bad in [OK | {'identity': 'bot'}, OK | {'tool': 'other'}, OK | {'failures': [1]},
@@ -207,13 +326,13 @@ class NotifyTests(unittest.TestCase):
def fail(*a, **kw): def fail(*a, **kw):
calls.append(1) calls.append(1)
raise error raise error
code, _ = self.execute(['progress', 'start', '开始'], fail) code, _ = self.execute(['progress', 'start', '--stage', '测试验证', '开始'], fail)
self.assertEqual(code, 1) self.assertEqual(code, 1)
self.assertEqual(len(calls), 1) self.assertEqual(len(calls), 1)
def test_no_app_credentials_in_child_environment(self): def test_no_app_credentials_in_child_environment(self):
with patch.dict(os.environ, {'DWS_CLIENT_SECRET': 'fake-secret', 'DWS_CLIENT_ID': 'fake-app', 'DING_SEC': 'fake-key'}): with patch.dict(os.environ, {'DWS_CLIENT_SECRET': 'fake-secret', 'DWS_CLIENT_ID': 'fake-app', 'DING_SEC': 'fake-key'}):
self.execute(['progress', 'start', '开始']) self.execute(['progress', 'start', '--stage', '测试验证', '开始'])
env = self.calls[-1][1]['env'] env = self.calls[-1][1]['env']
self.assertNotIn('DWS_CLIENT_SECRET', env) self.assertNotIn('DWS_CLIENT_SECRET', env)
self.assertNotIn('DWS_CLIENT_ID', env) self.assertNotIn('DWS_CLIENT_ID', env)
@@ -282,7 +401,7 @@ class NotifyTests(unittest.TestCase):
def test_hardlinked_config_cannot_be_message_body(self): def test_hardlinked_config_cannot_be_message_body(self):
other = self.root / 'not-a-summary.md' other = self.root / 'not-a-summary.md'
os.link(self.path, other) os.link(self.path, other)
self.assertEqual(self.execute(['progress', 'start', '--file', str(other), '--dry-run'])[0], 1) self.assertEqual(self.execute(['progress', 'start', '--stage', '测试验证', '--file', str(other), '--dry-run'])[0], 1)
def test_real_powershell_launcher_in_isolated_project(self): def test_real_powershell_launcher_in_isolated_project(self):
pwsh = shutil.which('pwsh') pwsh = shutil.which('pwsh')
@@ -295,15 +414,17 @@ class NotifyTests(unittest.TestCase):
shutil.copyfile(source / name, folder / name) shutil.copyfile(source / name, folder / name)
env = n.clean_env() env = n.clean_env()
env['DINGTALK_PYTHON'] = sys.executable env['DINGTALK_PYTHON'] = sys.executable
text = '**中文**\n\n- "引号" $() `原样`' text = '**中文**\n\n- 请 @{测试人} 确认 "引号" $() `原样`'
for script, args in [('dingtalk-progress.ps1', ['milestone', text]), for script, args in [('dingtalk-progress.ps1', ['milestone', text, '--stage', '测试验证']),
('dingtalk-message.ps1', ['--title', '中文 "标题"', '--text', text])]: ('dingtalk-message.ps1', ['--title', '中文 "标题"', '--text', text])]:
result = subprocess.run([pwsh, '-NoProfile', '-File', str(folder / script), *args, '--dry-run'], result = subprocess.run([pwsh, '-NoProfile', '-File', str(folder / script), *args, '--at', '测试人', '--dry-run'],
cwd=self.root, env=env, capture_output=True, encoding='utf-8', timeout=30) cwd=self.root, env=env, capture_output=True, encoding='utf-8', timeout=30)
self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual(result.returncode, 0, result.stderr)
data = json.loads(result.stdout) data = json.loads(result.stdout)
self.assertEqual(data['status'], 'preview') self.assertEqual(data['status'], 'preview')
self.assertIn(text, data['text']) self.assertIn(text.replace('@{测试人}', '@测试人'), data['text'])
if script == 'dingtalk-progress.ps1':
self.assertEqual(data['text'].splitlines()[0], '**测试项目|测试验证**')
result = subprocess.run([pwsh, '-NoProfile', '-File', str(folder / 'dingtalk-python.ps1'), 'init', result = subprocess.run([pwsh, '-NoProfile', '-File', str(folder / 'dingtalk-python.ps1'), 'init',
'--profile', PROFILE, '--group', GROUP, '--project', '甲'], '--profile', PROFILE, '--group', GROUP, '--project', '甲'],
cwd=self.root, env=env, capture_output=True, encoding='utf-8', timeout=30) cwd=self.root, env=env, capture_output=True, encoding='utf-8', timeout=30)