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