Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20c0de9b38 | ||
|
|
a08cfd6a3b |
No files matched your search
+52
-69
@@ -1,83 +1,66 @@
|
|||||||
# 钉钉进度通知参考
|
# 个人 DWS 项目通知
|
||||||
|
|
||||||
这里的脚本和配置只演示 Hub 如何向项目群发送辅助通知,不属于 Etunel 的任务通信主链路,也不能代替任务派发、负责人确认、阶段门禁或项目留档。成员 Agent 不调用本脚本。
|
Hub 使用负责人确认的个人 DWS 账号发阶段汇报和 @ 提醒。只需要原生 DWS、Python 3.9+ 和一个项目配置文件;不使用机器人、应用密钥、Webhook 或 Stream 监听。
|
||||||
|
|
||||||
当前布局刻意把工具和项目配置分开:
|
**旧版升级:** 用新的个人账号配置替换旧配置,旧机器人配置不会自动转成个人发送授权。摘要入口已改名为 `dingtalk-message`/`dingtalk-message.ps1`,删除目标部署中的旧 `dingtalk-bot-message` 入口及 Stream 实验脚本。更新脚本和 Hub Skill/Hook 不等于已经迁移在途项目;按实际部署安排确认。
|
||||||
|
|
||||||
- `dingtalk-workspace-cli@1.0.60` 由 Bun 安装在用户级全局目录,项目不保存二进制或 `node_modules`。
|
## 首次由 Hub 完成配置
|
||||||
- 官方 multi skills 保留在项目 `.agents/skills/dingtalk-*`,只对本项目生效。
|
|
||||||
- Client ID、群目标、`robotCode` 和汇报开关保存在项目 `.dingtalk/config.env`。
|
|
||||||
- Client Secret 从环境变量 `DING_SEC` 读取,未设置时回退到 macOS Keychain;脚本只临时注入给 `dws api`,App Token 由 DWS 自动获取和缓存。
|
|
||||||
|
|
||||||
项目统一通过稳定入口调用全局命令:
|
负责人不需要找编号、复制配置模板或填写密钥。Hub 在项目初始化时执行:
|
||||||
|
|
||||||
```sh
|
1. 检查本机 DWS;未登录时请负责人通过官方 DWS 登录流程授权,不索取密码或 Token。
|
||||||
./scripts/dws version
|
2. 用 `dws profile list --format json` 查看可用账号,以姓名和组织向负责人确认发送身份。
|
||||||
```
|
3. 在选定的同一账号下,用 `dws chat +chat-search --query "负责人提供的群名称" --page-all --profile "查询返回的账号标识" --format json` 查群;结果不完整或有多个候选时不能自行选第一个。
|
||||||
|
4. 一次问清:“是否由这个账号向这个项目群自动发送进度、阻塞和必要的 @ 提醒?”得到确认后,Hub 把工具实际返回的账号标识、群标识及项目名称传给初始化入口。下面为参数结构,不是需要人填写的编号表:
|
||||||
Bun 默认阻止上游 `postinstall`;本机安装时已校验官方 SHA-256 并只释放 DWS 原生二进制,没有执行会发布全局 Skills 的安装脚本。升级 DWS 时也应保持这一边界。
|
|
||||||
|
|
||||||
## 应用身份(无需用户登录)
|
|
||||||
|
|
||||||
本项目不执行 `dws auth login`。进度脚本通过 `dws api` 调用企业内部应用 OpenAPI:
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
POST https://api.dingtalk.com/v1.0/robot/groupMessages/send
|
python scripts/dingtalk_notify.py init --profile "实际查询结果" --group "实际查询结果" --project "项目名称" --yes
|
||||||
```
|
```
|
||||||
|
|
||||||
管理员侧只需确保:
|
初始化检查账号存在且可登录,创建 `.dingtalk/config.env` 并启用通知;**不会发测试消息,也不会替人确认群选择**。群标识必须来自该账号的查询结果。初始化不创建钉钉群、不申请权限。已有配置默认不覆盖;迁移或变更账号、群时重新确认,再加 `--replace`,原配置被替换且不另存含密钥的备份。
|
||||||
|
|
||||||
- Client ID/Secret 属于企业内部应用;
|
配置仅 4 项:
|
||||||
- 应用已有“企业内机器人发送消息”权限(`qyapi_robot_sendmsg`);
|
|
||||||
- `robotCode` 属于同一个应用;
|
|
||||||
- 机器人已加入目标 `openConversationId` 对应的群。
|
|
||||||
|
|
||||||
当前使用 zsh 环境变量时,先确保它已导出(不要把真实值写进项目文件):
|
| 字段 | 含义 | 谁填写 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `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
|
||||||
|
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`。不支持临时换群、手填人员编号或退回机器人发送。
|
||||||
|
|
||||||
|
事件含义见 [通知规则](../etunel-role-collaboration/references/dingtalk-progress-reporting.md),详细调用与结果见 [脚本用法](../etunel-role-collaboration/references/dingtalk-script-usage.md)。
|
||||||
|
|
||||||
|
## 检查与失败处理
|
||||||
|
|
||||||
|
- `python scripts/dingtalk_notify.py check --at "完整姓名"`:只检查固定账号登录和可选姓名,不发消息,不证明实际 @ 提醒成功。不是每条通知的必做步骤。
|
||||||
|
- `accepted` 加有效 `openTaskId`:个人发送请求已接受,不等于已投递或已读。必要时在同账号下用 `dws chat +messages-query-send-status --open-task-id "回执中的实际值" --profile "配置中的账号" --format json` 只读查询;不把任务回执当消息编号,不循环重发。
|
||||||
|
- 仅明确 DNS 解析失败最多尝试 3 次、间隔 10 秒;认证、权限或未知结果停止。登录失效请负责人恢复登录,不切账号、不退回机器人。通知失败不阻塞 Etunel 主任务。
|
||||||
|
|
||||||
|
维护时只做离线测试;真实发送必须另获明确授权:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
export DING_SEC='<Client Secret>'
|
python -X utf8 scripts/test_dingtalk_notify.py
|
||||||
|
bash -n scripts/dingtalk-progress
|
||||||
|
bash -n scripts/dingtalk-message
|
||||||
```
|
```
|
||||||
|
|
||||||
也可以不设置 `DING_SEC`,改用本项目专用的 macOS Keychain 项。`-w` 必须放在最后,它会在终端中隐藏输入:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
/usr/bin/security add-generic-password \
|
|
||||||
-U \
|
|
||||||
-a '<Client ID>' \
|
|
||||||
-s 'com.example.dingtalk.your-project' \
|
|
||||||
-l 'Your project DingTalk bot Client Secret' \
|
|
||||||
-w
|
|
||||||
```
|
|
||||||
|
|
||||||
`dws auth status` 显示未登录是正常状态,与 App Token 发送链路无关。
|
|
||||||
|
|
||||||
## 配置进度群
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cp .dingtalk/config.env.example .dingtalk/config.env
|
|
||||||
chmod 600 .dingtalk/config.env
|
|
||||||
```
|
|
||||||
|
|
||||||
填写以下非密钥参数:
|
|
||||||
|
|
||||||
```dotenv
|
|
||||||
DINGTALK_PROGRESS_MODE=app-bot
|
|
||||||
DINGTALK_PROGRESS_CLIENT_ID=<Client ID>
|
|
||||||
DINGTALK_PROGRESS_TARGET=<openConversationId>
|
|
||||||
DINGTALK_PROGRESS_ROBOT_CODE=<robotCode>
|
|
||||||
```
|
|
||||||
|
|
||||||
先保持 `DINGTALK_PROGRESS_ENABLED=0`。凭据和参数就绪后改为 `1`,并保持 `DINGTALK_PROGRESS_DRY_RUN=1` 做无网络、无发送的请求预览:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
./scripts/dingtalk-progress milestone "**已完成**
|
|
||||||
|
|
||||||
- 项目资料已经归档
|
|
||||||
- 本阶段成果已经负责人确认
|
|
||||||
|
|
||||||
**下一步**
|
|
||||||
|
|
||||||
- Hub 将安排下一阶段任务"
|
|
||||||
```
|
|
||||||
|
|
||||||
确认请求中的目标和正文正确后,再把 `DINGTALK_PROGRESS_DRY_RUN=0`。真实发送由 Hub 按根目录 `AGENTS.md` 的时机调用;响应中的 `processQueryKey` 才表示服务端已经接受该条机器人消息。摘要可以使用多行 Markdown,重点写已经确认的结果、下一步或阻塞原因,不要粘贴大段日志。
|
|
||||||
@@ -1,19 +1,7 @@
|
|||||||
# Copy this file to .dingtalk/config.env, fill the selected mode, then chmod 600 it.
|
# Hub 查询账号和群、取得负责人确认后,用 init 生成真实 config.env。
|
||||||
# Values are literal text after '='; do not add shell quotes.
|
# 不需手工复制或填编号;不保存机器人密钥或个人登录凭据。
|
||||||
|
# 等号右侧为字面值,不加 Shell 引号。ENABLED=0 暂停通知。
|
||||||
# Keep both switches safe until credential setup and a dry-run have succeeded.
|
|
||||||
DINGTALK_PROGRESS_ENABLED=0
|
DINGTALK_PROGRESS_ENABLED=0
|
||||||
DINGTALK_PROGRESS_DRY_RUN=1
|
DINGTALK_PROGRESS_PROFILE=
|
||||||
|
|
||||||
# Enterprise-internal app bot: App Token via dws api; no user OAuth login.
|
|
||||||
DINGTALK_PROGRESS_MODE=app-bot
|
|
||||||
DINGTALK_PROGRESS_CLIENT_ID=
|
|
||||||
DINGTALK_PROGRESS_TARGET=
|
DINGTALK_PROGRESS_TARGET=
|
||||||
DINGTALK_PROGRESS_ROBOT_CODE=
|
DINGTALK_PROGRESS_PROJECT=
|
||||||
DINGTALK_PROGRESS_KEYCHAIN_SERVICE=com.example.dingtalk.your-project
|
|
||||||
|
|
||||||
# Client Secret is never stored here. The script first reads exported DING_SEC,
|
|
||||||
# then falls back to the project-specific macOS Keychain item above.
|
|
||||||
|
|
||||||
DINGTALK_PROGRESS_PROJECT=你的项目名称
|
|
||||||
DINGTALK_PROGRESS_TITLE_PREFIX=[项目进度]
|
|
||||||
+6
-1
@@ -1,5 +1,8 @@
|
|||||||
* text=auto
|
* text=auto
|
||||||
|
.gitattributes text eol=lf
|
||||||
|
.gitignore text eol=lf
|
||||||
|
.dingtalk/config.env.example text eol=lf
|
||||||
|
.dingtalk/config.env
|
||||||
*.md text eol=lf
|
*.md text eol=lf
|
||||||
*.txt text eol=lf
|
*.txt text eol=lf
|
||||||
*.html text eol=lf
|
*.html text eol=lf
|
||||||
@@ -8,4 +11,6 @@
|
|||||||
*.json text eol=lf
|
*.json text eol=lf
|
||||||
*.py text eol=lf
|
*.py text eol=lf
|
||||||
*.sh text eol=lf
|
*.sh text eol=lf
|
||||||
|
*.ps1 text eol=lf
|
||||||
scripts/dingtalk-progress text eol=lf
|
scripts/dingtalk-progress text eol=lf
|
||||||
|
scripts/dingtalk-message text eol=lf
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
# Local credentials and machine-specific configuration
|
# Local credentials and machine-specific configuration
|
||||||
.dingtalk/config.env
|
.dingtalk/config.env
|
||||||
|
.dingtalk/config.env.*
|
||||||
|
!.dingtalk/config.env.example
|
||||||
**/.env
|
**/.env
|
||||||
**/.env.*
|
**/.env.*
|
||||||
!**/.env.example
|
!**/.env.example
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
- `doc/`:当前流程设计基线和模板参考资料;
|
- `doc/`:当前流程设计基线和模板参考资料;
|
||||||
- 钉钉参考集成:仅帮助 Hub 理解如何调用辅助通知,不属于 Etunel 任务通信主链路。
|
- 钉钉参考集成:仅帮助 Hub 理解如何调用辅助通知,不属于 Etunel 任务通信主链路。
|
||||||
|
|
||||||
这是以 Markdown、HTML 和 Shell 为主的约束仓库,没有需要猜测的安装或构建步骤。
|
这是以 Markdown、HTML 为主、附带 Python 和平台启动脚本的约束仓库,没有需要猜测的应用构建步骤。
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
| `etunel-role-hook-contexts/` | 八角色 Hook 的完整精简职责与每轮提醒 | 独立于 Skill 发现树;简短、可直接注入、与正式职责一致 |
|
| `etunel-role-hook-contexts/` | 八角色 Hook 的完整精简职责与每轮提醒 | 独立于 Skill 发现树;简短、可直接注入、与正式职责一致 |
|
||||||
| `doc/` | 当前流程来源及模板参考 | 固定路径维护一份当前文档;不建日期或版本副本,历史查 Git;变更同步核对链接与基线说明 |
|
| `doc/` | 当前流程来源及模板参考 | 固定路径维护一份当前文档;不建日期或版本副本,历史查 Git;变更同步核对链接与基线说明 |
|
||||||
| `.agents/skills/dingtalk-*` | 钉钉官方能力的项目内参考副本 | 不是 Etunel 核心 Skill;除非任务明确要求更新参考包,否则不要批量改动 |
|
| `.agents/skills/dingtalk-*` | 钉钉官方能力的项目内参考副本 | 不是 Etunel 核心 Skill;除非任务明确要求更新参考包,否则不要批量改动 |
|
||||||
| `.dingtalk/`、`scripts/dingtalk-progress` | Hub 通知接入说明、示例配置和当前演示封装 | 不把通知能力扩大为任务派发、审批、门禁或事实源 |
|
| `.dingtalk/`、`scripts/` 中的钉钉封装 | Hub 通知配置说明、跨平台入口、共用 Python 实现和离线测试 | 不把通知能力扩大为任务派发、审批、门禁或事实源 |
|
||||||
|
|
||||||
修改共享流程、角色边界、阶段、正式成果或跨角色信息流时,必须同步检查根 Skill、相关公共 reference、受影响角色 reference 和对应 Hook。角色细节优先下沉到角色文件;测试专项优先下沉到测试二级 reference。不要靠复制整段规则维持一致性。
|
修改共享流程、角色边界、阶段、正式成果或跨角色信息流时,必须同步检查根 Skill、相关公共 reference、受影响角色 reference 和对应 Hook。角色细节优先下沉到角色文件;测试专项优先下沉到测试二级 reference。不要靠复制整段规则维持一致性。
|
||||||
|
|
||||||
@@ -61,10 +61,10 @@
|
|||||||
|
|
||||||
- 只有 Hub/主 Agent 可以调用通知;成员 Agent 只向 Hub 返回成果、状态或阻塞。
|
- 只有 Hub/主 Agent 可以调用通知;成员 Agent 只向 Hub 返回成果、状态或阻塞。
|
||||||
- 通知是项目群的单向辅助可见性与异常提醒,不是角色间通信、任务派发、审批、人类确认、项目门禁或项目事实源。
|
- 通知是项目群的单向辅助可见性与异常提醒,不是角色间通信、任务派发、审批、人类确认、项目门禁或项目事实源。
|
||||||
- Agent 只能使用 `scripts/dingtalk-progress`,事件类型限定为 `start`、`milestone`、`blocked`、`complete` 或 `failed`;不得绕过脚本直接调用 OpenAPI 或 `dws api`。可验证里程碑先完成项目留档,再发送简短中文 Markdown。例如:
|
- Agent 只用 `scripts/dingtalk_notify.py` 的 `progress`/`message`,或等价的 `dingtalk-progress`/`dingtalk-message` Shell、PowerShell 入口,统一以已确认的个人 DWS 账号发送。进度事件仍限定为 `start`、`milestone`、`blocked`、`complete`、`failed`;负责人提醒不新增状态。同一事项不重复通知,不得绕过封装直接调用 OpenAPI 或 `dws api`。可验证里程碑先完成项目留档,再发送简短中文 Markdown。例如:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
./scripts/dingtalk-progress milestone "**已完成**
|
python scripts/dingtalk_notify.py progress milestone "**已完成**
|
||||||
|
|
||||||
- 阶段成果已确认并归档
|
- 阶段成果已确认并归档
|
||||||
|
|
||||||
@@ -73,9 +73,10 @@
|
|||||||
- Hub 安排后续任务"
|
- Hub 安排后续任务"
|
||||||
```
|
```
|
||||||
- 事件语义与消息格式以 [dingtalk-progress-reporting.md](etunel-role-collaboration/references/dingtalk-progress-reporting.md) 为准;安装与本机配置参考 [.dingtalk/README.md](.dingtalk/README.md)。
|
- 事件语义与消息格式以 [dingtalk-progress-reporting.md](etunel-role-collaboration/references/dingtalk-progress-reporting.md) 为准;安装与本机配置参考 [.dingtalk/README.md](.dingtalk/README.md)。
|
||||||
- `scripts/dingtalk-progress` 是当前用于跑通通知链路的演示封装,不得据此反推或改变 Etunel 主流程。
|
- 通知封装是辅助接入参考,不得据此反推或改变 Etunel 主流程;不创建通知队列或外层重试。仅明确 DNS 失败最多 3 次、间隔 10 秒,结果未知不盲目重发。
|
||||||
- 仓库维护和测试期间不得把真实发送当作冒烟测试。只有任务明确要求发送且当前环境已经授权时,才允许触发真实通知。
|
- 仓库维护和测试期间不得把真实发送当作冒烟测试。只有任务明确要求发送且当前环境已经授权时,才允许触发真实通知。
|
||||||
- 不读取、输出、修改或提交 `.dingtalk/config.env`、Client Secret、`DING_SEC`、App Token 或其他凭据和内部标识。
|
- Hub 首次查询个人账号及项目群,以姓名、组织和群名取得负责人确认后,用封装 `init` 创建 `.dingtalk/config.env`;只含通知开关、固定账号、群和项目名,不保存机器人或登录密钥。AI 可以在授权范围内维护该配置,但不提交或在群里展示内部标识;换账号或群须重新确认。
|
||||||
|
- 不读取、输出或提交 DWS 登录凭据、Client Secret、`DING_SEC`、App Token。旧机器人配置须经确认重新初始化,不静默启用个人代发;保留 AI 标识,个人通知不代表负责人亲自批准。
|
||||||
|
|
||||||
## 编辑约定
|
## 编辑约定
|
||||||
|
|
||||||
@@ -89,8 +90,9 @@
|
|||||||
|
|
||||||
## 版本与发布
|
## 版本与发布
|
||||||
|
|
||||||
- 全仓库统一使用 `v主版本.次版本.修订版本` 发布,Git 标签与发布包确定该版 Skill、Hook 和文档;组件不另设发布序列。流程图版本只表示设计基线,不决定每次发布号,流程未变不为凑版本改图。
|
- 全仓库统一使用三位版本 `vX.Y.Z`:前两位 `X.Y` 跟随 `doc/Codex多角色项目推进总流程图.html` 内明确标注且已确认的流程版本;第三位 `Z` 是该流程版本下的仓库发布迭代号。Git 标签与发布包确定该版 Skill、Hook、脚本和文档,组件不另设发布序列。
|
||||||
- 不改变既定角色、必需成果和阶段条件的纠错或执行澄清升修订号;兼容的可选能力新增升次版本号;改变既有职责、成果或门禁且旧项目需重新确认执行方式时升主版本号。按影响判断,不按文件数判断。
|
- HTML 主流程版本不迭代时,所有变更均只递增第三位,包括 Skill/Hook 优化、脚本新增、修复、配置调整和文档更新;不按功能规模或兼容性自行提升前两位。流程保持 V1.0 时依次发布 `v1.0.0`、`v1.0.1`、`v1.0.2`。HTML 流程正式升到新的 `X.Y` 时,仓库前两位同步,第三位从 0 开始,例如流程 V1.1 对应 `v1.1.0`;不为凑发布号改流程图。
|
||||||
|
- 这是流程版本驱动的项目约定,不采用“新增功能就升次版本”的语义化版本规则;第三位更新也可能需要配置迁移,发布说明必须如实列出。涉及职责或门禁变化仍须先按项目约束确认流程,不能借版本编号规则跳过确认。
|
||||||
- 日常提交不必发版。发版前确认版本号、完成适用校验,说明流程基线、变更内容、需更新的 Skill/Hook 和在途项目影响;具体说明见 [README 版本与升级](README.md#版本与升级)。
|
- 日常提交不必发版。发版前确认版本号、完成适用校验,说明流程基线、变更内容、需更新的 Skill/Hook 和在途项目影响;具体说明见 [README 版本与升级](README.md#版本与升级)。
|
||||||
- 仅在用户要求时提交、推送或发版;已发布标签不可覆盖或移动,修正另发新版本。推送后核验远程分支与标签提交,不能把本地提交当成已发布。
|
- 仅在用户要求时提交、推送或发版;已发布标签不可覆盖或移动,修正另发新版本。推送后核验远程分支与标签提交,不能把本地提交当成已发布。
|
||||||
- 发布不等于部署。仅更新受影响的 Skill/Hook 配置,插件缓存更新及在途项目迁移仍须明确确认;不要求角色每轮核对发布号,不将仓库发版规则塞进运行时 Skill。
|
- 发布不等于部署。仅更新受影响的 Skill/Hook 配置,插件缓存更新及在途项目迁移仍须明确确认;不要求角色每轮核对发布号,不将仓库发版规则塞进运行时 Skill。
|
||||||
@@ -101,7 +103,9 @@
|
|||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
rtk python -X utf8 "$env:USERPROFILE\.codex\skills\.system\skill-creator\scripts\quick_validate.py" "etunel-role-collaboration"
|
rtk python -X utf8 "$env:USERPROFILE\.codex\skills\.system\skill-creator\scripts\quick_validate.py" "etunel-role-collaboration"
|
||||||
|
rtk python -X utf8 scripts/test_dingtalk_notify.py
|
||||||
rtk "C:\Program Files\Git\bin\bash.exe" -n scripts/dingtalk-progress
|
rtk "C:\Program Files\Git\bin\bash.exe" -n scripts/dingtalk-progress
|
||||||
|
rtk "C:\Program Files\Git\bin\bash.exe" -n scripts/dingtalk-message
|
||||||
rtk proxy git diff --check
|
rtk proxy git diff --check
|
||||||
rtk proxy git status --short
|
rtk proxy git status --short
|
||||||
```
|
```
|
||||||
@@ -111,7 +115,7 @@ Skill Creator 的验证脚本位置取决于协作者的 Codex 安装。若默
|
|||||||
## Definition of Done
|
## Definition of Done
|
||||||
|
|
||||||
- 修改 Skill 时已通过 Skill Creator 的结构校验。
|
- 修改 Skill 时已通过 Skill Creator 的结构校验。
|
||||||
- 仅在修改 `scripts/dingtalk-progress` 时执行 Shell 语法检查。
|
- 修改钉钉封装时运行离线测试;修改 Shell 入口时检查语法,修改 PowerShell 入口时核验解析和实际参数传递。只使用隔离假配置,不触发真实通知。
|
||||||
- 所有新增或改动的 Markdown 相对链接均能解析。
|
- 所有新增或改动的 Markdown 相对链接均能解析。
|
||||||
- 文件中没有待办标记、占位内容、真实凭据、群标识或误导性的缺失文件引用。
|
- 文件中没有待办标记、占位内容、真实凭据、群标识或误导性的缺失文件引用。
|
||||||
- 根 Skill、公共 reference、角色 reference 和对应 Hook 在受影响规则上保持一致。
|
- 根 Skill、公共 reference、角色 reference 和对应 Hook 在受影响规则上保持一致。
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
本仓库为 Etunel 多 Agent 项目提供一套中文版、渐进式披露的角色职责与协作流程:项目Hub负责跨角色中继和全局推进,七个成员角色在各自真人负责人参与下完成专业任务,并以可追踪成果交回 Hub。
|
本仓库为 Etunel 多 Agent 项目提供一套中文版、渐进式披露的角色职责与协作流程:项目Hub负责跨角色中继和全局推进,七个成员角色在各自真人负责人参与下完成专业任务,并以可追踪成果交回 Hub。
|
||||||
|
|
||||||
> **钉钉定位说明:** 仓库中的 `.agents/skills/dingtalk-*`、`.dingtalk/` 和 `scripts/dingtalk-progress` 只作为参考与演示,用来告诉 Hub 如何调用钉钉发送项目进度或异常通知。钉钉不是 Etunel 的角色通信链路、任务队列、审批、门禁或项目事实源,也不会改变任何角色边界。
|
> **钉钉定位说明:** 仓库中的 `.agents/skills/dingtalk-*`、`.dingtalk/` 和 `scripts/` 下的钉钉封装只作为接入参考,用来告诉 Hub 如何发送项目进度、异常摘要或负责人提醒。钉钉不是 Etunel 的角色通信链路、任务队列、审批、门禁或项目事实源,也不会改变任何角色边界。
|
||||||
|
|
||||||
## 先了解这三件事
|
## 先了解这三件事
|
||||||
|
|
||||||
@@ -14,18 +14,37 @@
|
|||||||
|
|
||||||
## 版本与升级
|
## 版本与升级
|
||||||
|
|
||||||
本版为 `v1.0.1`,流程设计基线仍为 **V1.0**。发布版本表示一套可领取的仓库文件,流程基线表示其依据的已确认流程,两者不必同步递增。正式发布以 Git 标签和对应发布包为准,主分支可能包含尚未发布的修改。
|
本版为 `v1.0.3`,流程设计基线仍为 **V1.0**。版本采用 `vX.Y.Z`:前两位 `X.Y` 跟随 HTML 主流程中明确标注且已确认的版本,第三位 `Z` 表示该流程版本下的仓库发布迭代。正式发布以 Git 标签和对应发布包为准,主分支可能包含尚未发布的修改。
|
||||||
|
|
||||||
| 变更 | 版本规则 |
|
| 变更 | 版本规则 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| 不改变既定角色边界、必需成果或阶段条件的纠错、澄清与执行优化 | 升修订号,例如 `1.0.0 → 1.0.1` |
|
| HTML 流程版本不变,更新 Skill、Hook、脚本、配置或说明等任意内容 | 只递增第三位,例如流程保持 V1.0,`v1.0.1 → v1.0.2` |
|
||||||
| 增加可选能力或场景,原有项目仍能按原约定执行 | 升次版本号,例如 `1.0.1 → 1.1.0` |
|
| HTML 流程正式更新到新的 X.Y | 前两位同步,第三位从 0 开始,例如流程 V1.1 对应 `v1.1.0` |
|
||||||
| 改变既有职责、成果或阶段门禁,旧项目需重新确认执行方式 | 升主版本号,例如 `1.x.x → 2.0.0` |
|
| 日常提交,尚未安排发布 | 不必为每次提交创建标签;下一次正式发版按上述规则编号 |
|
||||||
|
|
||||||
全仓库只维护这一套发布序列,不给 Skill、Hook 分别编号。流程设计变更先确认,再同步受影响文件;只修正 Skill 执行说明时,不必改流程图版本。日常修改正常提交,需要交给团队使用时才统一校验、确定版本、创建 Git 标签及发布说明;已发布标签和内容不覆盖,修正发新版本。
|
全仓库只维护这一套发布序列,不给 Skill、Hook 或脚本分别编号。前两位不按功能规模、变更文件数或兼容性自行调整,也不为新增脚本等非流程更新修改 HTML 版本。这不是按变更性质升主/次版本的语义化版本规则,第三位更新也可能需要配置迁移;职责或门禁变更仍须先确认流程。日常修改正常提交,需要交给团队使用时才统一校验、确定版本、创建 Git 标签及发布说明;已发布标签和内容不覆盖,修正发新版本。
|
||||||
|
|
||||||
发布说明应写清:流程基线、改了什么、使用者需要更新哪些文件、在途项目是否需要重新确认。不要把“仓库已发布”写成“角色已更新”。更新插件缓存、Etunel 中独立配置的 Hook 或迁移在途项目,都需另外明确确认;已运行项目继续使用其登记基线。
|
发布说明应写清:流程基线、改了什么、使用者需要更新哪些文件、在途项目是否需要重新确认。不要把“仓库已发布”写成“角色已更新”。更新插件缓存、Etunel 中独立配置的 Hook 或迁移在途项目,都需另外明确确认;已运行项目继续使用其登记基线。
|
||||||
|
|
||||||
|
### v1.0.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 更新范围
|
### v1.0.1 更新范围
|
||||||
|
|
||||||
- 根据一轮项目复盘,补清旧任务收尾、开工输入与本次产出的区分、资料异常交专业角色核实、查询无新线索时转交负责人决定,以及模拟不默认跳过交接。
|
- 根据一轮项目复盘,补清旧任务收尾、开工输入与本次产出的区分、资料异常交专业角色核实、查询无新线索时转交负责人决定,以及模拟不默认跳过交接。
|
||||||
@@ -100,6 +119,7 @@ EP-Hub-Skill/
|
|||||||
│ ├── human-readable-communication.md
|
│ ├── human-readable-communication.md
|
||||||
│ ├── project-files-and-progress.md
|
│ ├── project-files-and-progress.md
|
||||||
│ ├── dingtalk-progress-reporting.md
|
│ ├── dingtalk-progress-reporting.md
|
||||||
|
│ ├── dingtalk-script-usage.md # 通知的跨平台调用与负责人提醒
|
||||||
│ ├── roles/ # 七个成员角色职责
|
│ ├── roles/ # 七个成员角色职责
|
||||||
│ └── testing/ # 测试任务按需读取的二级细则
|
│ └── testing/ # 测试任务按需读取的二级细则
|
||||||
├── etunel-role-hook-contexts/ # 八角色独立 Hook 源文件
|
├── etunel-role-hook-contexts/ # 八角色独立 Hook 源文件
|
||||||
@@ -108,7 +128,14 @@ EP-Hub-Skill/
|
|||||||
│ └── 文档模板参考/ # 按任务读取的参考笔记与来源清单
|
│ └── 文档模板参考/ # 按任务读取的参考笔记与来源清单
|
||||||
├── .agents/skills/dingtalk-*/ # 钉钉官方能力参考副本,不是核心 Skill
|
├── .agents/skills/dingtalk-*/ # 钉钉官方能力参考副本,不是核心 Skill
|
||||||
├── .dingtalk/ # 通知接入说明与无秘密示例配置
|
├── .dingtalk/ # 通知接入说明与无秘密示例配置
|
||||||
└── scripts/dingtalk-progress # Hub 钉钉通知的当前演示封装
|
└── scripts/ # Hub 辅助通知,不是 Etunel 任务机制
|
||||||
|
├── dingtalk_notify.py # Python 共用实现,含有限重试
|
||||||
|
├── dingtalk-progress # macOS/Linux 进度入口
|
||||||
|
├── dingtalk-message # macOS/Linux 个人摘要与负责人提醒
|
||||||
|
├── dingtalk-progress.ps1 # Windows PowerShell 进度入口
|
||||||
|
├── dingtalk-message.ps1 # Windows PowerShell 个人摘要与提醒
|
||||||
|
├── dingtalk-python.ps1 # Windows 共用启动器,含初始化与检查
|
||||||
|
└── test_dingtalk_notify.py # 不联网的隔离测试
|
||||||
```
|
```
|
||||||
|
|
||||||
Skill 被用于实际项目时,Hub 在目标项目中维护统一的 `项目资料/`,而不是把项目成果写回本约束仓库:
|
Skill 被用于实际项目时,Hub 在目标项目中维护统一的 `项目资料/`,而不是把项目成果写回本约束仓库:
|
||||||
@@ -159,17 +186,21 @@ Hook 文件需保持短小,并与完整 Skill 职责一致。它们由 Etunel
|
|||||||
|
|
||||||
钉钉集成是 Hub 的辅助通知参考,不是 Etunel 协作系统的一部分。仓库保留它,是为了让 Hub 在满足事件条件时有一个清晰、安全、可替换的调用方法。
|
钉钉集成是 Hub 的辅助通知参考,不是 Etunel 协作系统的一部分。仓库保留它,是为了让 Hub 在满足事件条件时有一个清晰、安全、可替换的调用方法。
|
||||||
|
|
||||||
只有 Hub/主 Agent 可以使用下面的封装入口:
|
只有 Hub/主 Agent 可以使用下面的通用 Python 封装入口(Python 3.9+):
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
./scripts/dingtalk-progress <start|milestone|blocked|complete|failed> "<简短中文 Markdown 摘要>"
|
python scripts/dingtalk_notify.py progress <start|milestone|blocked|complete|failed> "<简短中文 Markdown 摘要>"
|
||||||
```
|
```
|
||||||
|
|
||||||
五类事件分别表示项目正式开始、可验证里程碑、真实阻塞、整体完成和最终失败。可验证里程碑由 Hub 先完成成果和进度留档,再发送钉钉消息。成员角色不得发送钉钉消息,只把结果交给 Hub。通知正文可使用多行 Markdown,只写可公开的已验证结论、下一步或阻塞原因,不发送密钥、个人数据、大段日志和未经证实的推断。
|
五类事件分别表示项目正式开始、可验证里程碑、真实阻塞、整体完成和最终失败。可验证里程碑由 Hub 先完成成果和进度留档,再发送钉钉消息。成员角色不得发送钉钉消息,只把结果交给 Hub。通知正文可使用多行 Markdown,只写可公开的已验证结论、下一步或阻塞原因,不发送密钥、个人数据、大段日志和未经证实的推断。
|
||||||
|
|
||||||
更详细的 Hub 事件语义、有限重试和结果判定见 [钉钉项目进度汇报](etunel-role-collaboration/references/dingtalk-progress-reporting.md);当前演示接入与本地配置见 [.dingtalk/README.md](.dingtalk/README.md)。`.agents/skills/dingtalk-*` 是钉钉官方多 Skill 的项目内参考副本,不应被当作 Etunel 核心 Skill,也不应被成员用来绕过 Hub。
|
新增 `message --title "中文标题" --text "问题和下一步"` 用于辅助摘要与负责人提醒,可按需用 `--at`;提醒结果回各自角色会话,不在群里另派任务或批准。两个入口均支持文件正文与离线 `--dry-run`,Windows 可用 `.ps1`,macOS/Linux 保留原 Shell 入口。它们是同一封装,不要重复调用。
|
||||||
|
|
||||||
本地 `.dingtalk/config.env` 已被 Git 忽略。不要读取、输出或提交其中的配置;不要在文档、Skill、Hook 或脚本中硬编码 Client Secret、Client ID、`robotCode`、`openConversationId`、App Token 或群名。仓库维护时也不要用真实发送作为普通测试。
|
所有通知统一使用负责人确认的个人 DWS 登录,不再使用机器人。Hub 在首次初始化时查询账号和项目群,用姓名、组织和群名取得确认后,调用 `init` 自动生成配置;人不用找编号或填密钥。配置只保留通知开关、固定账号、固定群和项目名,个人登录凭据由 DWS 管理。`progress` 也支持 `--at`,事件汇报和行动提醒可合成一条;保留 AI 标识和自动汇报说明,不冒充本人批准。
|
||||||
|
|
||||||
|
事件语义、有限重试和结果判定见 [钉钉项目进度汇报](etunel-role-collaboration/references/dingtalk-progress-reporting.md),命令细节见 [通知脚本用法](etunel-role-collaboration/references/dingtalk-script-usage.md);跨平台安装、初始化和旧配置迁移见 [.dingtalk/README.md](.dingtalk/README.md)。`.agents/skills/dingtalk-*` 是钉钉官方多 Skill 的项目内参考副本,不应被当作 Etunel 核心 Skill,也不应被成员用来绕过 Hub。
|
||||||
|
|
||||||
|
本地 `.dingtalk/config.env` 已被 Git 忽略,Hub 可在负责人授权范围内创建或修改;不要提交配置、硬编码真实账号/群标识,或读取和传播 DWS 凭据。`accepted` 表示取得个人发送任务回执,不等于已投递、已读或接收端 @ 提醒成功。仓库维护时不要用真实发送作为普通测试。
|
||||||
|
|
||||||
## 修改项目的推荐流程
|
## 修改项目的推荐流程
|
||||||
|
|
||||||
@@ -190,10 +221,12 @@ git status --short
|
|||||||
|
|
||||||
修改核心 Skill 后,使用已安装的 Skill Creator 对 `etunel-role-collaboration/` 运行 `scripts/quick_validate.py`。Skill Creator 的位置取决于协作者的 Codex 安装,不要为此把验证器复制进仓库。
|
修改核心 Skill 后,使用已安装的 Skill Creator 对 `etunel-role-collaboration/` 运行 `scripts/quick_validate.py`。Skill Creator 的位置取决于协作者的 Codex 安装,不要为此把验证器复制进仓库。
|
||||||
|
|
||||||
修改钉钉演示脚本后再运行:
|
修改钉钉封装后运行隔离测试;Shell 入口有修改时再检查对应语法:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
python -X utf8 scripts/test_dingtalk_notify.py
|
||||||
bash -n scripts/dingtalk-progress
|
bash -n scripts/dingtalk-progress
|
||||||
|
bash -n scripts/dingtalk-message
|
||||||
```
|
```
|
||||||
|
|
||||||
还应人工确认:
|
还应人工确认:
|
||||||
|
|||||||
@@ -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 且发生钉钉汇报事件:读 [钉钉项目进度汇报](references/dingtalk-progress-reporting.md)。
|
- 仅当当前会话是 Hub 且需初始化个人 DWS 通知、钉钉进度汇报或负责人辅助提醒:读 [钉钉项目进度汇报](references/dingtalk-progress-reporting.md),实际调用再读其中的脚本用法。
|
||||||
- 测试角色仅在任务类型匹配时,按 [测试职责](references/roles/testing.md) 中的二级链接读取更深细则。
|
- 测试角色仅在任务类型匹配时,按 [测试职责](references/roles/testing.md) 中的二级链接读取更深细则。
|
||||||
|
|
||||||
不要为“全面了解”一次加载全部引用或全部角色文件。Etunel Hook 源文件独立配置,不属于本 Skill 的渐进式发现树。
|
不要为“全面了解”一次加载全部引用或全部角色文件。Etunel Hook 源文件独立配置,不属于本 Skill 的渐进式发现树。
|
||||||
|
|||||||
@@ -1,18 +1,16 @@
|
|||||||
# Hub 钉钉项目进度汇报
|
# Hub 钉钉项目进度汇报
|
||||||
|
|
||||||
仅当当前会话是 Hub 且发生本文件定义的汇报事件时读取。钉钉是面向项目群组的单向辅助可见性与异常提醒,不是任务派发、跨角色沟通、审批、门禁、人类确认或项目记录的事实源。
|
仅当当前会话是 Hub 且发生汇报事件或需提醒负责人处理具体问题时读取。钉钉是面向项目群组的单向辅助可见性与异常提醒,不是任务派发、跨角色沟通、审批、门禁、人类确认或项目记录的事实源。
|
||||||
|
|
||||||
## 权限与唯一入口
|
## 权限与封装入口
|
||||||
|
|
||||||
- 只有 Hub/主 Agent 可以发送;成员 Agent 只把成果、状态或阻塞交给 Hub。
|
- 只有 Hub/主 Agent 可以发送;成员 Agent 只把成果、状态或阻塞交给 Hub。
|
||||||
- 项目配置为启用时视为持续发送授权,不需每条消息再次询问。
|
- 项目首次初始化时,由 Hub 查询可用个人 DWS 账号和群,以姓名、组织和群名向负责人确认自动通知范围,再调用 `init` 创建配置;不让人填写编号或密钥。确认并启用后,范围内通知不需每条再次询问,变更账号或群须重新确认。
|
||||||
- 只能调用项目封装入口:
|
- 只用项目封装:`dingtalk-progress` 处理五类项目事件,`dingtalk-message` 处理自定义中文摘要或负责人提醒;二者共用 `scripts/dingtalk_notify.py`,统一使用确认的个人 DWS 账号。初始化、跨平台入口及 @ 用法见 [通知脚本用法](dingtalk-script-usage.md)。
|
||||||
|
|
||||||
`./scripts/dingtalk-progress <start|milestone|blocked|complete|failed> "<中文 Markdown 摘要>"`
|
|
||||||
|
|
||||||
- Agent 不得直接调用底层 OpenAPI、`dws api` 或其他消息入口绕过封装脚本。
|
- Agent 不得直接调用底层 OpenAPI、`dws api` 或其他消息入口绕过封装脚本。
|
||||||
- Agent 不读取、修改、输出或请求 Client Secret、DING_SEC、App Token、Client ID、robotCode、openConversationId 等凭据和内部标识。
|
- 配置只保存开关、固定账号、项目群和项目名称;Hub 可在已确认范围内创建或更新,登录凭据由 DWS 管理,不索取、读取或输出 Token、密码与应用密钥。旧机器人配置须重新初始化,不静默切换发送身份。
|
||||||
- Skill 与消息正文不得硬编码项目 Client ID、robotCode、openConversationId、Client Secret 或群名;项目差异只由 `.dingtalk/config.env` 和封装脚本处理。
|
- 不在 Skill 或群消息里硬编码账号、群或人员标识;标识从同一账号的真实工具结果取得,仅用于本地配置和工具参数。不因默认账号切换而更换发送人,不退回机器人或 Webhook。
|
||||||
|
|
||||||
## 汇报单位与顺序
|
## 汇报单位与顺序
|
||||||
|
|
||||||
@@ -60,17 +58,21 @@ Hub 必须先确认进展真实有效,并完成成果文件、成果索引和
|
|||||||
|
|
||||||
使用中文 Markdown 自然排版,可以使用标题、短段落和列表,不要求固定模板,也不要把全部内容挤成一行。像项目负责人向团队说明进展,不像日志转储。
|
使用中文 Markdown 自然排版,可以使用标题、短段落和列表,不要求固定模板,也不要把全部内容挤成一行。像项目负责人向团队说明进展,不像日志转储。
|
||||||
|
|
||||||
|
保留个人消息的 AI 标识和自动汇报说明,让群成员知道这是 Hub 自动通知,不是负责人本人新增批准、验收或承诺。
|
||||||
|
|
||||||
根据事件选择必要内容:项目名称、当前阶段、已验证进展、关键成果、下一步和责任角色;阻塞时再说明问题、影响以及需要谁采取什么动作。通常保持一屏内可读。
|
根据事件选择必要内容:项目名称、当前阶段、已验证进展、关键成果、下一步和责任角色;阻塞时再说明问题、影响以及需要谁采取什么动作。通常保持一屏内可读。
|
||||||
|
|
||||||
不在正文中写运行时 ID、英文成果名、机器状态码或内部字段。只写已验证结论,不得包含密钥、Token、个人数据、客户敏感信息、大段日志、完整内部对话或未经验证的根因推断。
|
不在正文中写运行时 ID、英文成果名、机器状态码或内部字段。只写已验证结论,不得包含密钥、Token、个人数据、客户敏感信息、大段日志、完整内部对话或未经验证的根因推断。
|
||||||
|
|
||||||
|
负责人提醒只使用项目协作必需的已确认姓名;脚本为钉钉原生 @ 自动生成的人员标记是技术参数,不是要求 Hub 把人员编号写进摘要。需要负责人行动时,说明缺什么、影响什么、请谁做什么,以及回哪个角色会话补充结果。不要在群里建立另一套任务或审批流程。
|
||||||
|
|
||||||
## 调用与结果
|
## 调用与结果
|
||||||
|
|
||||||
1. Hub 判断事件并形成中文 Markdown 摘要。
|
1. Hub 判断事件并形成中文 Markdown 摘要。
|
||||||
2. 调用封装脚本一次;脚本自行处理配置检查、发送和有限重试。
|
2. 调用封装脚本一次;脚本自行处理配置检查、发送和有限重试。
|
||||||
3. 只有真实发送响应包含非空 `processQueryKey`,才表示钉钉服务端已经接受;不证明群成员已读。
|
3. 只有真实个人发送响应成功且含非空 `openTaskId`,才表示请求已经接受;不证明已投递、已读或接收端 @ 提醒成功。必要时按脚本用法查询投递状态,不把任务回执当消息编号。
|
||||||
4. 缺少或未启用配置时安全跳过;预览模式只检查请求,不视为真实发送。
|
4. 缺少或未启用配置时不真实发送;显式 `--dry-run` 仅在本地生成正文,不查通讯录、不联网,也不证明目标配置和 @ 有效。配置仍为旧格式时停止并请求重新初始化。
|
||||||
5. 真实发送失败或没有有效 key 时,脚本最多总计尝试 3 次,间隔 10 秒;首次成功立即停止。
|
5. 只有明确的域名解析失败才自动重试,总计最多 3 次,间隔 10 秒;首次收到有效回执立即停止。认证、权限或未知错误不自动重试;超时、连接中断或缺少回执可能已发出,不盲目重发。单次命令最长等待 60 秒。
|
||||||
6. 三次仍失败只终止本次钉钉发送,Etunel 主任务继续。Hub 在本地结果中向负责人说明一次,不递归发送新的阻塞或失败通知,也不自动补发历史事件。
|
6. 有效回执优先于命令退出码,不因退出异常重复发送。发送停止或结果未知时,Etunel 主任务继续;Hub 在本地结果中向负责人说明一次,不递归发送新的阻塞或失败通知,不自动补发历史事件。
|
||||||
|
|
||||||
脚本输出和退出码只用于判断通知调用,不得把钉钉结果升级成项目门禁。Agent 不在脚本外追加重试循环。
|
脚本输出和退出码只用于判断通知调用,不得把钉钉结果升级成项目门禁。Agent 不在脚本外追加重试循环。
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Hub 个人 DWS 通知用法
|
||||||
|
|
||||||
|
仅在 Hub 初始化通知或按 [通知规则](dingtalk-progress-reporting.md) 准备发送时读取。脚本放在实际 Hub 项目的 `scripts/`,配置位于同项目 `.dingtalk/config.env`;安装 Skill 不会自动部署脚本。缺少入口时说明并继续主任务,不猜路径或绕过封装。
|
||||||
|
|
||||||
|
## 项目首次初始化
|
||||||
|
|
||||||
|
1. 用 `dws profile list --format json` 查询账号,向负责人显示姓名与组织;未登录请负责人完成 DWS 登录,不索取凭据。
|
||||||
|
2. 在选定账号下,用 `dws chat +chat-search --query "负责人提供的群名称" --page-all --profile "实际账号标识" --format json` 查群。账号或群有歧义、结果不完整时先澄清,不猜编号、不选第一项。
|
||||||
|
3. 一次确认发送账号、项目群及是否允许自动进度和必要 @ 提醒。人只确认名字与用途,不负责找编号。
|
||||||
|
4. Hub 将真实查询结果传入 `python scripts/dingtalk_notify.py init --profile "实际账号标识" --group "实际群标识" --project "项目名称" --yes`。这是参数结构示例;值必须替换为同一账号的实际结果。脚本创建仅含开关、账号、群和项目名的配置,不存机器人密钥或登录凭据,不发测试消息。
|
||||||
|
|
||||||
|
已有有效配置直接沿用;变更发送账号或群须重新确认,初始化时加 `--replace`。旧机器人配置必须重新初始化,不能静默转换发送身份。账号、群选择与查询由 Hub 完成;`init` 校验账号登录,不替代负责人对目标群的确认。暂停通知可由 Hub 按负责人要求将 `DINGTALK_PROGRESS_ENABLED` 改为 `0`。配置和标识只用于本地工具,不粘贴到群或普通回复,不提交真实配置。
|
||||||
|
|
||||||
|
初始化时同时确认目标项目的 Git 忽略规则包含 `.dingtalk/config.env`,不把本机通知配置提交到仓库。
|
||||||
|
|
||||||
|
## 选择本机入口
|
||||||
|
|
||||||
|
共用实现依赖 Python 3.9+ 和已登录的原生 DWS。相对命令从目标项目根目录执行。
|
||||||
|
|
||||||
|
| 环境 | 初始化/检查 | 项目事件 | 摘要/提醒 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 通用 Python | `python scripts/dingtalk_notify.py init ...`/`check ...` | 同入口 `progress ...` | 同入口 `message ...` |
|
||||||
|
| Windows PowerShell 7.3+ | `./scripts/dingtalk-python.ps1 init ...`/`check ...` | `./scripts/dingtalk-progress.ps1 ...` | `./scripts/dingtalk-message.ps1 ...` |
|
||||||
|
| macOS/Linux | 通常用 `python3` 执行共用入口 | `sh scripts/dingtalk-progress ...` | `sh scripts/dingtalk-message ...` |
|
||||||
|
|
||||||
|
Windows 优先用 `.ps1`,须同时部署共用启动器 `dingtalk-python.ps1`,它选择独立 Python,不使用可能隔离 DWS 登录的商店版;必要时用 `DINGTALK_PYTHON` 指定解释器。各系统均通过 PATH 查找原生 DWS,不再设置机器人相关参数。这些入口是同一实现,不能重复调用。
|
||||||
|
|
||||||
|
## 日常发送
|
||||||
|
|
||||||
|
```text
|
||||||
|
python scripts/dingtalk_notify.py progress milestone "**已完成**:阶段成果已确认并归档。下一步安排联调。"
|
||||||
|
python scripts/dingtalk_notify.py progress blocked "缺少接口说明,请补充后回自己的角色会话同步。" --at "完整姓名"
|
||||||
|
python scripts/dingtalk_notify.py message --title "资料补充提醒" --text "请补齐已约定资料,并回自己的角色会话确认。" --at "完整姓名"
|
||||||
|
```
|
||||||
|
|
||||||
|
事件仍只有 `start`、`milestone`、`blocked`、`complete`、`failed`。脚本增加项目名、中文标题、带时区时间和自动汇报说明,不替 Hub 判断阶段。保留 DWS 的 AI 标识,不能冒充负责人本人批准或承诺。
|
||||||
|
|
||||||
|
- 多行中文 Markdown 作为一个参数传入;较长摘要可先保存成 UTF-8 文件,再用 `--file "实际正文文件路径"` 替代摘要或 `--text`。文件须实际存在,路径相对工作目录;这是正文,不是上传附件,不发送大段成果或日志。
|
||||||
|
- `progress` 和 `message` 均支持 `--at`,多个姓名可逗号分隔或重复传入。负责人姓名来自 Etunel 已绑定的 `member_alias`,不把角色名称当姓名;人员查询和发送固定使用初始化账号,不跟随默认账号切换。
|
||||||
|
- 脚本只接受唯一姓名匹配,自动构造个人消息所需的 `openDingTalkId` 和提及标记。查不到、重名或权限不足时停止,不让负责人手填编号,不删掉 @ 降级发送。
|
||||||
|
- 默认且仅发配置的项目群。同一事项合成一条,事件通知可直接 @ 人,不额外重复提醒。提醒讨论结果回各自角色会话,不在群里另派任务或批准。
|
||||||
|
|
||||||
|
## 检查与结果
|
||||||
|
|
||||||
|
排障可用 `python scripts/dingtalk_notify.py check --at "完整姓名"`,省略 `--at` 只查登录;它联网但不发送,不是每轮必做。两个发送入口加 `--dry-run` 可完全离线预览,不查人、不验证目标或 @ 效果。
|
||||||
|
|
||||||
|
- `initialized`:已创建经确认的个人通知配置,未发送。
|
||||||
|
- `accepted`:真实个人发送响应成功且含非空 `openTaskId`,请求已接受,不等于已投递、已读或接收端提醒成功。保留回执供排障,面向负责人只说中文结果。
|
||||||
|
- `checked`/`preview`/`skipped`:分别为只读检查、离线预览、未配置或未启用;都没发送。
|
||||||
|
- `error`:本次停止,按说明区分失败与未知,不自行补发。错误退出码为 1,参数错误可能为 2,不能只凭退出码 0 判断已发。
|
||||||
|
|
||||||
|
需要进一步核实投递时,按发送回执在同一账号下调用 `dws chat +messages-query-send-status --open-task-id "真实回执值" --profile "配置账号" --format json` 只读查询;按实际结果判断,不把 `openTaskId` 当消息 ID。脚本只在明确 DNS 失败时最多尝试 3 次、间隔 10 秒;其余失败或未知结果不重发,Agent 不追加外层重试。登录失效交负责人恢复,不换身份或渠道;通知异常不阻塞主任务,也不递归发送异常通知。
|
||||||
@@ -23,6 +23,7 @@ Hub 只检查角色、必填内容、成果、版本、证据、负责人确认
|
|||||||
5. 从《项目概览》读取当前项目已经确认的特殊规则;只登记有权负责人明确确认的内容,并持续沿用到同一决定权人修改或取消,不在每项任务中重复询问。
|
5. 从《项目概览》读取当前项目已经确认的特殊规则;只登记有权负责人明确确认的内容,并持续沿用到同一决定权人修改或取消,不在每项任务中重复询问。
|
||||||
6. 在途项目继续使用已登记流程基线,除非取得明确迁移决定。
|
6. 在途项目继续使用已登记流程基线,除非取得明确迁移决定。
|
||||||
7. 有权业务负责人发起的正式项目即使资料不齐也可接收,但缺口、责任和风险必须如实记录,不能把未知写成已确认。
|
7. 有权业务负责人发起的正式项目即使资料不齐也可接收,但缺口、责任和风险必须如实记录,不能把未知写成已确认。
|
||||||
|
8. 首次使用钉钉通知时,按 [通知初始化](dingtalk-script-usage.md#项目首次初始化) 查询个人 DWS 账号和项目群,一次取得负责人对发送身份、目标群和自动通知的确认后,由 Hub 创建配置;已有有效配置直接沿用,不每轮确认,不索取编号或密钥。配置暂未完成不阻塞 Etunel 主任务。
|
||||||
|
|
||||||
## 启动阶段并明确完成约定
|
## 启动阶段并明确完成约定
|
||||||
|
|
||||||
@@ -159,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 禁止事项
|
## Hub 禁止事项
|
||||||
|
|
||||||
|
|||||||
@@ -27,14 +27,15 @@
|
|||||||
"non_goals": [
|
"non_goals": [
|
||||||
"不代专业角色开发、测试、验收或批准,不替成员完成任务,不自行批准豁免或创建Etunel角色。",
|
"不代专业角色开发、测试、验收或批准,不替成员完成任务,不自行批准豁免或创建Etunel角色。",
|
||||||
"不重造Etunel的队列、传输、重试和去重机制;不以通知送达或消息结算替代成果完成。",
|
"不重造Etunel的队列、传输、重试和去重机制;不以通知送达或消息结算替代成果完成。",
|
||||||
"钉钉只作辅助通知,不作任务通道、审批或事实来源;只使用项目封装脚本,不读取输出凭据,通知失败不阻塞主任务。"
|
"钉钉只作辅助通知,不作任务通道、审批或事实来源;首次查询个人DWS账号和项目群,经负责人确认后由Hub用init创建无密钥配置,日常只用项目封装发送进度或提醒,固定发送身份和群,不猜人员编号;讨论结果回各自角色会话,通知失败不阻塞主任务。"
|
||||||
],
|
],
|
||||||
"completion_tools": [
|
"completion_tools": [
|
||||||
"etunel_list_roles",
|
"etunel_list_roles",
|
||||||
"etunel_get_project_status",
|
"etunel_get_project_status",
|
||||||
"etunel_send_to_role",
|
"etunel_send_to_role",
|
||||||
"etunel_complete_coordination",
|
"etunel_complete_coordination",
|
||||||
"./scripts/dingtalk-progress"
|
"python scripts/dingtalk_notify.py progress",
|
||||||
|
"python scripts/dingtalk_notify.py message"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -49,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,每次一个实际角色;依赖就绪可多任务派发,同角色相关任务可打包。给足输入并写清成果要求,要求成员先与真人对齐、完成后自审并取得确认。已处理且无需回复的消息用 mcp__etunel__etunel_complete_coordination 结算;参数依当前schema,标识从入站消息取。
|
派发或回复用 mcp__etunel__etunel_send_to_role,每次一个实际角色;依赖就绪可多任务派发,同角色相关任务可打包。给足输入并写清成果要求,要求成员先与真人对齐、完成后自审并取得确认。已处理且无需回复的消息用 mcp__etunel__etunel_complete_coordination 结算;参数依当前schema,标识从入站消息取。
|
||||||
只检查当前必需条件,复用成果,阶段通过后推进,后续资料按需索取。决定变化要说明旧任务停续和新输入;资料冲突交专业角色核实。按阶段留档并更新进度、成果索引和记录;直交测试文件登记索引、版本及产品收讫。阻塞请相应负责人协调;跳转、回退或暂缓如实记录,模拟默认不跳过交接、不关闭真实问题。
|
只检查当前必需条件,复用成果,阶段通过后推进,后续资料按需索取。决定变化要说明旧任务停续和新输入;资料冲突交专业角色核实。按阶段留档并更新进度、成果索引和记录;直交测试文件登记索引、版本及产品收讫。阻塞请相应负责人协调;跳转、回退或暂缓如实记录,模拟默认不跳过交接、不关闭真实问题。
|
||||||
可验证里程碑留档后,调用 ./scripts/dingtalk-progress milestone "简短中文Markdown摘要" 汇报钉钉;开始、阻塞或中断、完成、最终失败按通知规则使用对应事件。用短段或列表说清结果、问题和下一步,不堆日志或中英混杂;失败不阻塞主流程,钉钉不能代替Etunel回复或结算。
|
钉钉统一用个人DWS:首次按通知细则查询账号和项目群,经负责人确认后用init创建配置,已有配置沿用。可验证里程碑留档后用 python scripts/dingtalk_notify.py progress milestone "中文Markdown摘要" 汇报,Windows优先对应.ps1;其他事件按规则。需行动可在progress或message中加--at "完整姓名",同一事项合并,保留自动汇报说明。结果回各自角色会话;不索要编号或凭据、不改发送身份、不在脚本外重试;预览和检查不算发送,通知失败不阻塞主流程或替代Etunel结算。
|
||||||
```
|
```
|
||||||
@@ -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
|
#!/bin/sh
|
||||||
|
# macOS/Linux entry; Windows can use the .ps1 or Python entry.
|
||||||
set -eu
|
set -eu
|
||||||
|
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||||
project_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
if command -v python3 >/dev/null 2>&1; then
|
||||||
config_file="$project_root/.dingtalk/config.env"
|
exec python3 "$script_dir/dingtalk_notify.py" progress "$@"
|
||||||
|
|
||||||
usage() {
|
|
||||||
echo "用法:./scripts/dingtalk-progress <start|milestone|blocked|complete|failed> <中文 Markdown 摘要>" >&2
|
|
||||||
}
|
|
||||||
|
|
||||||
if [ "$#" -lt 2 ]; then
|
|
||||||
usage
|
|
||||||
exit 2
|
|
||||||
fi
|
fi
|
||||||
|
exec python "$script_dir/dingtalk_notify.py" progress "$@"
|
||||||
event_name=$1
|
|
||||||
shift
|
|
||||||
summary=$*
|
|
||||||
|
|
||||||
case "$event_name" in
|
|
||||||
start|milestone|blocked|complete|failed) ;;
|
|
||||||
*)
|
|
||||||
usage
|
|
||||||
exit 2
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
if [ ! -f "$config_file" ]; then
|
|
||||||
echo "DingTalk progress reporting is not configured; skipping." >&2
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
file_mode=$(stat -f '%Lp' "$config_file" 2>/dev/null || stat -c '%a' "$config_file" 2>/dev/null || true)
|
|
||||||
case "$file_mode" in
|
|
||||||
400|600) ;;
|
|
||||||
*)
|
|
||||||
echo "Refusing to read $config_file because its mode is ${file_mode:-unknown}; run chmod 600 on it." >&2
|
|
||||||
exit 2
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
while IFS= read -r line || [ -n "$line" ]; do
|
|
||||||
case "$line" in
|
|
||||||
''|'#'*) continue ;;
|
|
||||||
esac
|
|
||||||
key=${line%%=*}
|
|
||||||
value=${line#*=}
|
|
||||||
case "$key" in
|
|
||||||
DINGTALK_PROGRESS_ENABLED|DINGTALK_PROGRESS_DRY_RUN|DINGTALK_PROGRESS_MODE|DINGTALK_PROGRESS_CLIENT_ID|DINGTALK_PROGRESS_TARGET|DINGTALK_PROGRESS_ROBOT_CODE|DINGTALK_PROGRESS_KEYCHAIN_SERVICE|DINGTALK_PROGRESS_PROJECT|DINGTALK_PROGRESS_TITLE_PREFIX)
|
|
||||||
export "$key=$value"
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "Unknown key in $config_file: $key" >&2
|
|
||||||
exit 2
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
done < "$config_file"
|
|
||||||
|
|
||||||
if [ "${DINGTALK_PROGRESS_ENABLED:-0}" != "1" ]; then
|
|
||||||
echo "DingTalk progress reporting is disabled; skipping." >&2
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "${DINGTALK_PROGRESS_MODE:-app-bot}" != "app-bot" ]; then
|
|
||||||
echo "DINGTALK_PROGRESS_MODE must be app-bot." >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -z "${DINGTALK_PROGRESS_CLIENT_ID:-}" ] ||
|
|
||||||
[ -z "${DINGTALK_PROGRESS_TARGET:-}" ] ||
|
|
||||||
[ -z "${DINGTALK_PROGRESS_ROBOT_CODE:-}" ]; then
|
|
||||||
echo "App-bot mode requires DINGTALK_PROGRESS_CLIENT_ID, DINGTALK_PROGRESS_TARGET, and DINGTALK_PROGRESS_ROBOT_CODE." >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! command -v jq >/dev/null 2>&1; then
|
|
||||||
echo "jq is required to construct and validate the DingTalk OpenAPI request." >&2
|
|
||||||
exit 127
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! command -v dws >/dev/null 2>&1; then
|
|
||||||
echo "Global dws command was not found in PATH." >&2
|
|
||||||
exit 127
|
|
||||||
fi
|
|
||||||
dws=$(command -v dws)
|
|
||||||
|
|
||||||
if [ -n "${DING_SEC:-}" ]; then
|
|
||||||
client_secret=$DING_SEC
|
|
||||||
else
|
|
||||||
if [ ! -x /usr/bin/security ]; then
|
|
||||||
echo "DING_SEC is unset and macOS Keychain command /usr/bin/security is unavailable." >&2
|
|
||||||
exit 127
|
|
||||||
fi
|
|
||||||
|
|
||||||
keychain_service=${DINGTALK_PROGRESS_KEYCHAIN_SERVICE:-com.eapil.dingtalk.$(basename -- "$project_root")}
|
|
||||||
if ! client_secret=$(/usr/bin/security find-generic-password \
|
|
||||||
-a "$DINGTALK_PROGRESS_CLIENT_ID" \
|
|
||||||
-s "$keychain_service" \
|
|
||||||
-w 2>/dev/null); then
|
|
||||||
echo "DingTalk Client Secret was not found in DING_SEC or this project's macOS Keychain item." >&2
|
|
||||||
echo "See .dingtalk/README.md for credential setup." >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -z "$client_secret" ]; then
|
|
||||||
echo "The DingTalk Client Secret is empty." >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
|
|
||||||
case "$event_name" in
|
|
||||||
start) event_label="开始推进" ;;
|
|
||||||
milestone) event_label="阶段里程碑" ;;
|
|
||||||
blocked) event_label="遇到阻塞" ;;
|
|
||||||
complete) event_label="本轮完成" ;;
|
|
||||||
failed) event_label="执行失败" ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
project_name=${DINGTALK_PROGRESS_PROJECT:-$(basename -- "$project_root")}
|
|
||||||
title_prefix=${DINGTALK_PROGRESS_TITLE_PREFIX:-[项目进度]}
|
|
||||||
title="$title_prefix $event_label"
|
|
||||||
timestamp=$(date '+%Y-%m-%d %H:%M:%S %Z')
|
|
||||||
body=$(printf '## %s\n\n**项目:** %s \n**状态:** %s \n**更新时间:** %s\n\n---\n\n%s' \
|
|
||||||
"$title" "$project_name" "$event_label" "$timestamp" "$summary")
|
|
||||||
|
|
||||||
msg_param=$(jq -cn --arg title "$title" --arg text "$body" '{title:$title,text:$text}')
|
|
||||||
request_body=$(jq -cn \
|
|
||||||
--arg msgParam "$msg_param" \
|
|
||||||
--arg openConversationId "$DINGTALK_PROGRESS_TARGET" \
|
|
||||||
--arg robotCode "$DINGTALK_PROGRESS_ROBOT_CODE" \
|
|
||||||
'{msgParam:$msgParam,msgKey:"sampleMarkdown",openConversationId:$openConversationId,robotCode:$robotCode}')
|
|
||||||
|
|
||||||
if [ "${DINGTALK_PROGRESS_DRY_RUN:-0}" = "1" ]; then
|
|
||||||
if DWS_CLIENT_ID="$DINGTALK_PROGRESS_CLIENT_ID" \
|
|
||||||
DWS_CLIENT_SECRET="$client_secret" \
|
|
||||||
"$dws" api POST /v1.0/robot/groupMessages/send \
|
|
||||||
--data "$request_body" --dry-run --yes --format json; then
|
|
||||||
send_status=0
|
|
||||||
else
|
|
||||||
send_status=$?
|
|
||||||
fi
|
|
||||||
unset client_secret DWS_CLIENT_SECRET
|
|
||||||
exit "$send_status"
|
|
||||||
fi
|
|
||||||
|
|
||||||
max_send_attempts=3
|
|
||||||
retry_delay_seconds=10
|
|
||||||
attempt=1
|
|
||||||
final_status=1
|
|
||||||
|
|
||||||
while [ "$attempt" -le "$max_send_attempts" ]; do
|
|
||||||
response=
|
|
||||||
if response=$(DWS_CLIENT_ID="$DINGTALK_PROGRESS_CLIENT_ID" \
|
|
||||||
DWS_CLIENT_SECRET="$client_secret" \
|
|
||||||
"$dws" api POST /v1.0/robot/groupMessages/send \
|
|
||||||
--data "$request_body" --yes --format json); then
|
|
||||||
send_status=0
|
|
||||||
else
|
|
||||||
send_status=$?
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -n "$response" ]; then
|
|
||||||
printf '%s\n' "$response"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$send_status" -eq 0 ] &&
|
|
||||||
printf '%s\n' "$response" |
|
|
||||||
jq -e '.processQueryKey | type == "string" and length > 0' >/dev/null 2>&1; then
|
|
||||||
unset client_secret DWS_CLIENT_SECRET
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$send_status" -eq 0 ]; then
|
|
||||||
echo "DingTalk attempt $attempt returned no valid processQueryKey; service acceptance is unverified." >&2
|
|
||||||
final_status=1
|
|
||||||
else
|
|
||||||
echo "DingTalk attempt $attempt failed with status $send_status." >&2
|
|
||||||
final_status=$send_status
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$attempt" -lt "$max_send_attempts" ]; then
|
|
||||||
echo "Retrying DingTalk send in $retry_delay_seconds seconds." >&2
|
|
||||||
sleep "$retry_delay_seconds"
|
|
||||||
fi
|
|
||||||
attempt=$((attempt + 1))
|
|
||||||
done
|
|
||||||
|
|
||||||
unset client_secret DWS_CLIENT_SECRET
|
|
||||||
echo "DingTalk progress reporting failed after $max_send_attempts attempts; Etunel work should continue." >&2
|
|
||||||
exit "$final_status"
|
|
||||||
@@ -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