Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69911543c3 | ||
|
|
b58b8c51bf | ||
|
|
a7678472ab | ||
|
|
f413db06be | ||
|
|
3d67d83110 | ||
|
|
df088573fb | ||
|
|
a0c64e5ef4 | ||
|
|
7a140e59c3 | ||
|
|
38832448d2 | ||
|
|
3d4e43f4fc | ||
|
|
155ce984c9 | ||
|
|
4b93a1cb28 | ||
|
|
1d384b9189 | ||
|
|
a3f5a83527 | ||
|
|
931af6af59 | ||
|
|
44311d0160 | ||
|
|
f81d09fb95 | ||
|
|
cd1ba34d96 | ||
|
|
2cc410db6c | ||
|
|
06ec207d17 | ||
|
|
66468c703f | ||
|
|
74ef426064 | ||
|
|
461b9b773a | ||
|
|
4bd9f75231 | ||
|
|
8f8f64c391 | ||
|
|
ae9caa06af | ||
|
|
ee0c3507a5 | ||
|
|
72a9902254 | ||
|
|
5f337e0ce5 |
@@ -34,8 +34,8 @@ jobs:
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: read
|
||||
# 该 job 仅处理 PR;评论写入也限定在 PR Conversation 这一权限域。
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Check out default branch tooling
|
||||
uses: actions/checkout@v4
|
||||
@@ -69,12 +69,10 @@ jobs:
|
||||
PARSE_ERROR: ${{ steps.parse.outputs.error }}
|
||||
run: |
|
||||
body="❌ /eval 命令解析失败:${PARSE_ERROR}"
|
||||
jq -n --arg body "$body" '{body: $body}' | curl --fail --silent --show-error \
|
||||
-X POST \
|
||||
-H "Authorization: Bearer ${GH_TOKEN}" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
--data @- \
|
||||
"https://api.github.com/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" > /dev/null
|
||||
gh api --method POST \
|
||||
"repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" \
|
||||
--raw-field body="$body" \
|
||||
> /dev/null
|
||||
exit 1
|
||||
|
||||
- name: Verify reviewed PR head
|
||||
@@ -103,13 +101,9 @@ jobs:
|
||||
set -euo pipefail
|
||||
placeholder_body="🛰️ /eval 请求已通过权限与版本校验,正在生成可验证的评测请求。"
|
||||
response="$(
|
||||
jq -n --arg body "$placeholder_body" '{body: $body}' \
|
||||
| curl --fail --silent --show-error \
|
||||
-X POST \
|
||||
-H "Authorization: Bearer ${GH_TOKEN}" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
--data @- \
|
||||
"https://api.github.com/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments"
|
||||
gh api --method POST \
|
||||
"repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" \
|
||||
--raw-field body="$placeholder_body"
|
||||
)"
|
||||
comment_id="$(
|
||||
printf '%s' "$response" \
|
||||
@@ -277,13 +271,9 @@ jobs:
|
||||
fi
|
||||
body="<!-- eval-dispatch: ${marker_json} -->"$'\n'"🛰️ /eval 已受理:产品集 \`${PRODUCTS}\`${cases_note},评测对象 \`${PR_HEAD_SHA}\`。"$'\n'"受控评测服务将在数分钟内处理,完成后由 bot 回贴报告。"
|
||||
response="$(
|
||||
jq -n --arg body "$body" '{body: $body}' \
|
||||
| curl --fail --silent --show-error \
|
||||
-X PATCH \
|
||||
-H "Authorization: Bearer ${GH_TOKEN}" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
--data @- \
|
||||
"https://api.github.com/repos/${GITHUB_REPOSITORY}/issues/comments/${DISPATCH_COMMENT_ID}"
|
||||
gh api --method PATCH \
|
||||
"repos/${GITHUB_REPOSITORY}/issues/comments/${DISPATCH_COMMENT_ID}" \
|
||||
--raw-field body="$body"
|
||||
)"
|
||||
printf '%s' "$response" \
|
||||
| jq -e \
|
||||
@@ -299,12 +289,8 @@ jobs:
|
||||
DISPATCH_COMMENT_ID: ${{ steps.placeholder.outputs.comment_id }}
|
||||
run: |
|
||||
failure_body="❌ /eval 请求准备失败,未生成可消费的评测请求。请稍后重试。"
|
||||
jq -n --arg body "$failure_body" '{body: $body}' \
|
||||
| curl --fail --silent --show-error \
|
||||
-X PATCH \
|
||||
-H "Authorization: Bearer ${GH_TOKEN}" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
--data @- \
|
||||
"https://api.github.com/repos/${GITHUB_REPOSITORY}/issues/comments/${DISPATCH_COMMENT_ID}" \
|
||||
gh api --method PATCH \
|
||||
"repos/${GITHUB_REPOSITORY}/issues/comments/${DISPATCH_COMMENT_ID}" \
|
||||
--raw-field body="$failure_body" \
|
||||
> /dev/null \
|
||||
|| true
|
||||
|
||||
@@ -1,188 +0,0 @@
|
||||
# lark-cli Shortcut 深度对齐矩阵
|
||||
|
||||
> 12 个 agent 逐条深读 lark 每个 shortcut 的智能实现(Validate/DryRun/ID解析/投影/多步/分页),映射钉钉、标注保真度差距。
|
||||
|
||||
## 2026-07-13 最新源码复核
|
||||
|
||||
对比基线:
|
||||
|
||||
- DWS:`feature/shortcut@b7c14c1`(已合并 `origin/main@390b611`)
|
||||
- lark-cli:`main@e96c4fa5`
|
||||
- lark-cli 本轮更新范围:`f495cbb1..e96c4fa5`
|
||||
|
||||
本轮 lark-cli **没有增加或删除生产 shortcut 命令**,变化集中在已有命令的实现保真度:统一 `--json` shorthand、文档分享锚点读取、whiteboard 本地文件安全内联、VC meeting events 的 identity/timeline/NDJSON 投影、Apps DB 环境自动选择、Drive push 错误分类,以及 Wiki token 解析兼容性。因此下方历史 gap 清单的命令面没有因本轮 pull 新增条目,但若要追平体验,以下实现差距需要上调优先级。
|
||||
|
||||
### 当前命令面快照
|
||||
|
||||
| 指标 | 数量 | 说明 |
|
||||
|---|---:|---|
|
||||
| DWS built-in shortcut | 366 | 16 个服务;运行时 registry 实测 |
|
||||
| lark-cli primary shortcut | 363 | 19 个服务;排除 `_test.go` 与 42 个 `sheets/backward` 隐藏兼容别名 |
|
||||
| 双方可映射服务内命令 | DWS 313 / lark 324 | 12 组产品映射,不含平台特有服务 |
|
||||
| 同服务同名命令 | 50 | 仅是名称交集,不等于语义等价或保真度一致 |
|
||||
| DWS 平台特有 shortcut | 53 | attendance / ding / oa / report 等 |
|
||||
| lark 平台特有 shortcut | 39 | okr / vc / slides / markdown / whiteboard / note / event |
|
||||
|
||||
双方重叠服务的命令面如下;“同名”只用于定位,能力判断仍需看参数、验证、多步编排、输出投影和 dry-run:
|
||||
|
||||
| 产品映射 | DWS | lark | 同名 |
|
||||
|---|---:|---:|---:|
|
||||
| aitable ↔ base | 82 | 87 | 31 |
|
||||
| calendar ↔ calendar | 23 | 10 | 3 |
|
||||
| chat ↔ im | 89 | 21 | 2 |
|
||||
| contact ↔ contact | 16 | 2 | 1 |
|
||||
| devapp ↔ apps | 30 | 63 | 3 |
|
||||
| doc ↔ doc | 19 | 14 | 1 |
|
||||
| drive ↔ drive | 9 | 26 | 3 |
|
||||
| mail ↔ mail | 10 | 21 | 0 |
|
||||
| minutes ↔ minutes | 13 | 9 | 1 |
|
||||
| sheet ↔ sheets | 2 | 42 | 0 |
|
||||
| todo ↔ task | 13 | 17 | 2 |
|
||||
| wiki ↔ wiki | 7 | 12 | 3 |
|
||||
|
||||
### 最新优先差距
|
||||
|
||||
1. **文档与白板资源保真度**:lark `doc +fetch/+update` 已支持分享链接 selection anchor、HTML5 block 资源引用,以及相对路径内的 SVG/Mermaid/PlantUML whiteboard 安全内联。DWS 具备文档读写和媒体原子能力,但缺少统一引用解析、路径门禁和资源回写编排。
|
||||
2. **Sheets typed workflow**:lark 的 typed table、批量样式、维度移动/冻结、range copy/fill/sort、workbook import/export 仍是最大可建设缺口。DWS 原生 helper 已有部分底层能力,但 shortcut 层只有 2 个精选命令,缺少跨 sheet 分块写、类型推断和 partial rollback。
|
||||
3. **Drive 本地同步体验**:lark `+push/+pull/+sync/+import/+export` 带批量计划、错误分类、路径保护和版本操作;DWS 目前偏原子上传/搜索,缺完整目录同步和可恢复批处理。
|
||||
4. **Mail 高保真写链路**:lark 对 send/reply/reply-all/forward 提供模板、签名、HTML lint、线程头、定时和附件编排;DWS 有底层发信/草稿工具,但 smart shortcut 尚未覆盖这些组合体验。
|
||||
5. **消息资源与统一搜索**:DWS 已有 `+search-msg/+chat-messages/+thread-replies/+at-me` 等拆分场景,lark `+messages-search` 仍在统一多维过滤、会话上下文富化、reaction/资源下载方面更完整。
|
||||
6. **会议事件输出**:lark `vc +meeting-events` 本轮新增当前身份、actor、会议状态推断、timeline 与 NDJSON 元数据。DWS 最新 main 已有更强的实时 event bus 和个人事件订阅,但尚未沉淀成同等级 shortcut 投影;这是“底层能力领先、shortcut UX 未收口”。
|
||||
|
||||
### 不建议机械追平
|
||||
|
||||
- lark Apps DB、Spark 发布、Lark Drive/Wiki 特有对象模型属于平台差异,不应只为同名率复制。
|
||||
- DWS 的 attendance、DING、OA、report、agoal 和最新 event bus 是钉钉侧差异化能力,应优先做场景化组合,而不是追求 363 vs 366 的数字对齐。
|
||||
- DWS 已具备按姓名解析、跨产品智能编排、失败回滚和 usage→自定义 shortcut 沉淀闭环,这些能力无法由同名命令统计体现。
|
||||
|
||||
> 注:下方“361 条”汇总是上一轮逐条人工分类的历史基线;当前 lark-cli primary shortcut 是 363 条,另有 42 个不应重复计为能力的 Sheets 隐藏兼容别名。历史条目的判断仍可复用,但总量数字不能直接代表本轮最新覆盖率,后续应把新增条目按 covered-1to1 / covered-smart / gap-buildable / no-dingtalk-tool 四类补录。
|
||||
|
||||
## 汇总(361 条 lark shortcut)
|
||||
|
||||
| dws_status | 数量 | 含义 |
|
||||
|---|:---:|---|
|
||||
| covered-1to1 | 144 | lark 组合在钉钉塌缩成 1:1,封装层已覆盖 |
|
||||
| no-dingtalk-tool | 127 | 钉钉无对应工具,客观不可对齐 |
|
||||
| **gap-buildable** | **41** | 钉钉有工具、值得补成智能 shortcut(**建设目标**);已建 minutes `+detail`/`+replace-batch`、base `+record-share-links`/`+resolve-base`、im `+thread-replies`/`+chat-messages`/`+chat-list`、task `+related-tasks` |
|
||||
| covered-smart | 49 | 已建智能 shortcut / 部分覆盖 |
|
||||
|
||||
## 🎯 gap-buildable 目标清单(原 49 条,已建 8 → 剩 41,按服务)
|
||||
|
||||
> 已落地:minutes `+detail`(✅ smart `+detail`)、minutes `+word-replace`(✅ smart `+replace-batch`,批量+去重)、base `+record-share-link-create`(✅ smart `+record-share-links`,>20 去重+分片+合并)、im `+threads-messages-list`(✅ smart `chat +thread-replies`,list_topic_replies + 投影)、im `+chat-list`(✅ smart `chat +chat-list`)、task `+get-related-tasks`(✅ smart `todo +related-tasks`,三角色并集+去重+投影)。
|
||||
|
||||
### im → chat(7)
|
||||
|
||||
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|
||||
|---|---|---|
|
||||
| `+chat-list` ✅ | read | **已建 smart `chat +chat-list`**:`list_all_conversations` + 默认仅群聊 + `--types group/p2p` + `--exclude-muted` + page-size/page-token 别名 + `--page-all/--page-limit` 数字 cursor 自动翻页、跨页去重、合并后类型过滤和完整性 ledger。剩余未做:sort/sort-type、bot 身份 p2p 剥离(DWS 无对应身份模型) |
|
||||
| `+chat-messages-list` ✅ | read | **已建 smart `chat +chat-messages`**:群/单聊互斥解析、时间范围、asc/desc、时间边界全量翻页、reaction、资源下载与完整性 ledger |
|
||||
| `+chat-search` ✅ | read | **已建 smart `chat +chat-search`**:真实 `search_groups` 关键词搜索 + page-size/page-token 别名 + `--page-all/--page-limit` 不透明 cursor 自动翻页、跨页去重和完整性 ledger。Lark v2 的 member/type/mode/manager/sort 过滤没有可验证的钉钉对应参数,未伪造 |
|
||||
| `+flag-list` ✅ | read | **已建 smart `chat +flag-list`**:真实 `list_message_favorites` 的 `items + hasMore + 数字 nextCursor`,支持 page-size/page-token、`--page-all/--page-limit`、跨页去重和完整性 ledger;仅对齐 message favorite,不模拟 Lark Feed thread flag |
|
||||
| `+messages-resources-download` | write | dws download-media 走 get_resource_download_url 拿URL,缺分片Range下载/重试/扩展名推断/安全落盘路径校验 |
|
||||
| `+messages-search` ✅ | read | **已建 smart `chat +search-msg`**:统一多维过滤、精确时间范围、asc/desc、cursor 全量翻页、mget 富化、reaction、资源下载与完整性 ledger。剩余差异是 Lark chat 上下文和部分 sender/attachment 类型过滤 |
|
||||
| `+threads-messages-list` ✅ | read | **已建 smart `chat +thread-replies`**:支持主消息 ID 自动只读解析 conversation/thread,也支持显式 group + thread/topic ID;list_topic_replies + sender/text/time/reaction/resource 投影 + 下层毫秒级 nextCursor 有界自动翻页、跨页去重、完整性 ledger,以及全量结果 asc/desc。与 Lark 的剩余差异是钉钉底层没有服务端 asc 单页,因此 DWS 的 asc 明确要求 `--page-all`,避免伪全局排序 |
|
||||
|
||||
### task → todo(3)
|
||||
|
||||
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|
||||
|---|---|---|
|
||||
| `+reminder` | write | dws 有 add_todo_reminder/reset_todo_reminder 但无 lark 的先查现有再替换编排、相对时间(15m/1h)解析与互斥校验,值得补智能 shortcut |
|
||||
| `+get-related-tasks` ✅ | read | **已建 smart `todo +related-tasks`**:creator+executor+participant 三角色并集 + taskId 去重 + 投影。剩余未做:followed-by-me 成员比对、subtask_count/tasklists 富投影 |
|
||||
| `+upload-attachment` | write | dws add-attachment 走 init→PUT→commit 三步 MCP 上传(能力更重),但无 50MB/regular 校验、applink 提取与 dry-run 计划展示;可对齐成更智能 shortcut |
|
||||
|
||||
### calendar → calendar(1)
|
||||
|
||||
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|
||||
|---|---|---|
|
||||
| `+room-find` | read | dws 有 room search(query_available_meeting_room 按单一时间段+过滤)和 busy search,但无多slot并发room_find聚合、无city/building/floor/capacity维度过滤、无按attendee推荐可用室,值得补成智能 shortcut 但未建 |
|
||||
|
||||
### doc (docs) → doc(2)
|
||||
|
||||
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|
||||
|---|---|---|
|
||||
| `+media-insert` | write | dws doc media insert 为3步(取凭证→PUT→insert_document_block)无回滚、无selection定位、无剪贴板、无宽高比补算、无wiki解析;可补成带回滚的智能shortcut |
|
||||
| `+media-download` | read | dws doc media download 走resourceId→downloadUrl两段,缺whiteboard导图分支、自动扩展名、路径安全、overwrite防护;media分支可对齐,whiteboard无工具 |
|
||||
|
||||
### drive → drive(1)
|
||||
|
||||
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|
||||
|---|---|---|
|
||||
| `+import` | write | dws drive upload 有 --workspace --convert 可转在线文档,但缺按目标类型(docx/sheet/bitable/slides)导入、缺 target-token 挂载与异步轮询 |
|
||||
|
||||
### mail → mail(4)
|
||||
|
||||
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|
||||
|---|---|---|
|
||||
| `+reply` | write | dws reply 走 create_reply_draft+send_draft 两步、附件仅上传会话,缺 EML 线程头构造、签名自动注入、模板合并、HTML lint、读回执、send-time 定时、跨字段校验 |
|
||||
| `+reply-all` | write | dws reply-all 两步且收件人由服务端决定,缺原文收件人抽取去重排己、线程头、签名/模板/lint/定时等编排保真 |
|
||||
| `+send` | write | dws send_email 单步(附件时先 create_draft 再传再 send),缺签名/模板/lint/日历内嵌/定时发送/发件人profile解析/跨字段校验 |
|
||||
| `+forward` | write | dws forward 走 create_forward_draft+send_draft,缺 Fw:主题/引用块/原附件转载 EML 构建、签名/模板/lint/定时保真 |
|
||||
|
||||
### wiki → wiki(1)
|
||||
|
||||
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|
||||
|---|---|---|
|
||||
| `+node-get` | read | dws 无 get_node 对应 tool(proxy wiki doc read 读的是文档正文而非节点元数据/space解析);缺 token/obj_token/URL→node 解析、obj_type推断、space交叉校验——是值得补的智能 shortcut 缺口 |
|
||||
|
||||
### minutes → minutes(4)
|
||||
|
||||
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|
||||
|---|---|---|
|
||||
| `+search` | read | dws list_by_keyword_and_time_range 只按 keyword+时间+归属(created/shared)过滤,缺 owner/participant 的 me 解析与筛选、缺 query 长度与跨字段互斥校验、缺输出投影与去头像 |
|
||||
| `+download` | read | dws 只有 query_minutes_audio_url 返回 OSS 地址(相当于 --url-only 单条),缺真正落盘下载、批量 fanout+限速+去重、文件名推断、SSRF 防护与覆盖保护 |
|
||||
| `+word-replace` ✅ | write | **已建 smart `+replace-batch`**:多组 `原文=>替换` 批量替换 + 去重校验 + 逐组结果聚合(补齐 1:1 `+word-replace` 的单组限制)。剩余未做:@file/stdin 输入 |
|
||||
| `+detail` ✅ | read | **已建 smart `+detail`**:单命令按 `--artifacts` fanout basic/summary/keywords/transcript/todos + partial-failure 容错 + rt.Output 投影。剩余未做:wait-ready 轮询、transcript 落盘 |
|
||||
|
||||
### base → aitable(10)
|
||||
|
||||
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|
||||
|---|---|---|
|
||||
| `+title-resolve` ✅ | read | **已建 smart `aitable +resolve-base`**:search_bases 按名解析 baseId + 0/1/多候选消歧投影。剩余未做:Drive doc_wiki 全文搜索 |
|
||||
| `+field-create` | write | dws create_fields 支持批量,但缺 formula/lookup guide-ack 门禁与逐字段节流,可补智能 shortcut |
|
||||
| `+field-update` | write | dws update_field 缺 formula/lookup guide-ack 保护 |
|
||||
| `+record-share-link-create` ✅ | read | **已建 smart `+record-share-links`**:>20 条记录去重 + 分片(≤20/批) + 跨 aitable-helper server fanout + 合并 {recordId,shareUrl},补齐单批 20 条上限 |
|
||||
| `+record-upload-attachment` | write | dws 只有 prepare_attachment_upload(拿上传凭证),缺 分片上传编排+append_attachments 回填单元格的完整链路 |
|
||||
| `+dashboard-block-list` | read | dws 仪表盘块是 chart(create/get/update/delete_chart),缺通用 block list,可对齐补 |
|
||||
| `+dashboard-block-get` | read | dws get_chart 覆盖 chart 类块,缺通用 block get |
|
||||
| `+dashboard-block-create` | write | dws create_chart 覆盖图表块,缺其他 block 类型的通用创建 |
|
||||
| `+dashboard-block-update` | write | dws update_chart 覆盖图表块更新 |
|
||||
| `+dashboard-block-delete` | high-risk-write | dws delete_chart 覆盖图表块删除 |
|
||||
|
||||
### sheets → sheet(14)
|
||||
|
||||
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|
||||
|---|---|---|
|
||||
| `+sheet-hide` | write | dws update_sheet可能含hidden属性但未见独立hide命令,需确认 |
|
||||
| `+sheet-unhide` | write | 同上,dws无独立unhide命令 |
|
||||
| `+sheet-set-tab-color` | write | dws update_sheet或可设tab色但无独立命令 |
|
||||
| `+sheet-show-gridline` | write | dws无网格线显隐命令 |
|
||||
| `+sheet-hide-gridline` | write | dws无网格线显隐命令 |
|
||||
| `+workbook-create` | write | dws有create_workspace_sheet但仅建空表,缺typed一步建表+填充+样式+partial回滚编排 |
|
||||
| `+dim-hide` | write | dws update-dimension或含hidden但无独立hide命令 |
|
||||
| `+dim-unhide` | write | 同上,dws无独立unhide命令 |
|
||||
| `+dim-freeze` | write | dws update-dimension可能含frozen但无独立freeze命令 |
|
||||
| `+cells-get` | read | dws range read存在但缺include样式/公式投影统一封装 |
|
||||
| `+table-get` | read | dws缺typed table读回+列类型推断+多sheet编排,只有裸csv/range读 |
|
||||
| `+table-put` | write | dws有append/set_cell_range但缺typed多sheet分块写+建缺失sheet+样式+partial回滚编排 |
|
||||
| `+rows-resize` | write | dws update-dimension可调尺寸但无独立rows-resize+size/type互斥校验 |
|
||||
| `+cols-resize` | write | dws update-dimension可调尺寸但无独立cols-resize+互斥校验 |
|
||||
|
||||
### apps → devapp(3)
|
||||
|
||||
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|
||||
|---|---|---|
|
||||
| `+release-create` | write | dws 有 create_dev_app_version(开放平台版本)可类比,但妙搭 release 是低代码应用发布、语义与产物不同 |
|
||||
| `+release-get` | read | dws 有 get_dev_app_version_detail 可类比但产品域(开放平台vs妙搭)不同 |
|
||||
| `+release-list` | read | dws 有 list_dev_app_versions 可类比但无 status 枚举过滤且产品域不同 |
|
||||
|
||||
## 已建智能 shortcut(covered-smart,48)— 可继续升级保真度
|
||||
|
||||
- **im**: +chat-members-list +chat-list +messages-send +threads-messages-list
|
||||
- **task**: +complete +assign +get-my-tasks +get-related-tasks
|
||||
- **contact**: +search-user
|
||||
- **calendar**: +agenda +create +update +freebusy +suggestion
|
||||
- **doc (docs)**: +history-revert
|
||||
- **drive**: +upload +search +inspect
|
||||
- **mail**: +triage
|
||||
- **minutes**: +upload +latest-minutes +action-items +transcript +minutes-search +detail +replace-batch
|
||||
- **base**: +table-get +table-create +view-create +view-get-filter +view-set-filter +view-get-visible-fields +view-set-visible-fields +view-get-group +view-set-group +view-get-sort +view-set-sort +view-get-timebar +view-set-timebar +view-get-card +view-set-card +record-list +record-search +record-get +record-upsert +base-create +workflow-list +form-create +form-list +form-get +record-share-link-create
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"generated_at": "2026-08-06T21:08:26.697858",
|
||||
"count": 357,
|
||||
"generated_at": "2026-08-10T17:25:46.245552",
|
||||
"count": 378,
|
||||
"results": [
|
||||
{
|
||||
"suite": "semantic",
|
||||
@@ -2162,7 +2162,7 @@
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "接受消息列表直接返回的 threadId(兼容 topicId),拉取回复并输出稳定身份、引用、reaction、resourceRefs、可读正文和时间边界分页;可选对回复资源去重后安全落盘并返回逐项失败 ledger。",
|
||||
"semantic_delta": "接受话题主消息 ID 并通过只读消息详情自动解析 conversation/thread,也接受显式 group + threadId(兼容 topicId);拉取回复并输出稳定身份、引用、reaction、resourceRefs、可读正文,使用下层毫秒级 nextCursor 安全分页以避免同秒回复漏读,并支持全量结果 asc/desc;可选对回复资源去重后安全落盘并返回逐项失败 ledger。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
@@ -3004,46 +3004,274 @@
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "minutes",
|
||||
"command": "+action-items",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "Resolves the latest task through strict itemList parsing and retrieves extracted Minutes actions without pretending to write Todo objects.",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "minutes",
|
||||
"command": "+apply-permission",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "Maps view/download/edit intent to policy 4/3/2 and validates the permission-request response.",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "minutes",
|
||||
"command": "+detail",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "Fans out selected artifacts, fully paginates transcript data, validates artifact-specific shapes and returns non-zero on partial reads.",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "minutes",
|
||||
"command": "+download",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "Resolves real audio/video URLs, validates response shape and performs batch safe atomic local downloads with an explicit failure ledger.",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "minutes",
|
||||
"command": "+export-pack",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "Validates selected artifacts, safely publishes a no-clobber local directory and emits a manifest without signed URLs or credentials.",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "minutes",
|
||||
"command": "+latest",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "Validates the real itemList response and chooses latest only from an explicit comparable timestamp before reading details; +latest-minutes remains a compatibility alias.",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "minutes",
|
||||
"command": "+list-all",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "Stable projection of all accessible Minutes with strict itemList response validation.",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "minutes",
|
||||
"command": "+list-mine",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "Stable projection of current-user Minutes with strict itemList response validation.",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "minutes",
|
||||
"command": "+list-shared",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "Stable projection of shared Minutes with strict itemList response validation.",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "minutes",
|
||||
"command": "+mindmap",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "Creates the asynchronous mind-graph task exactly once and polls the explicit 0/1/2 taskStatus to success, failure or timeout.",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "minutes",
|
||||
"command": "+prepare-asr",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "Reads the real personal hot-word set, computes deterministic add/delete differences, defaults to additive changes and verifies final state.",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "minutes",
|
||||
"command": "+record-pause",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "Friendly recording-pause facade with explicit task UUID validation and confirmation.",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "minutes",
|
||||
"command": "+record-resume",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "Friendly recording-resume facade with explicit task UUID validation and confirmation.",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "minutes",
|
||||
"command": "+record-start",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "Friendly recording-create facade with explicit confirmation and stable session parameter.",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "minutes",
|
||||
"command": "+record-stop",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "Friendly recording-stop facade with explicit task UUID validation and confirmation.",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "minutes",
|
||||
"command": "+record-wrap-up",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "Stops recording once, then boundedly waits for selected validated artifacts and preserves taskUuid recovery on partial completion.",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "minutes",
|
||||
"command": "+replace-batch",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "Validates multi-rule input, supports JSON/file/stdin and reports partial writes with stop/continue policy and non-zero failure.",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "minutes",
|
||||
"command": "+search",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "Validated itemList parsing, deterministic title filtering, bounded cursor pagination, de-duplication and completeness ledger; +minutes-search is a compatibility alias.",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "minutes",
|
||||
"command": "+share",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "Grants semantic view/download/edit permissions per stable member UID with stop/continue partial-write ledgers and explicit acknowledgements.",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "minutes",
|
||||
"command": "+speaker-insights",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "Requires a real async taskId, boundedly polls speaker summaries and returns task recovery handles when content is not ready.",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "minutes",
|
||||
"command": "+speaker-replace",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "Fully paginates the transcript to preflight source speaker presence and verifies the nickname replacement after writing.",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "minutes",
|
||||
"command": "+summary",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "Reads current summary, supports literal/file/stdin, preserves Markdown images, previews the change and verifies full read-back.",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "minutes",
|
||||
"command": "+transcript",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "Resolves an explicit or latest task, follows every transcript cursor, de-duplicates paragraphs and publishes completeness.",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "minutes",
|
||||
"command": "+unshare",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "Removes permission per stable member UID with stop/continue partial-write ledgers and explicit acknowledgements.",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "minutes",
|
||||
"command": "+update",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "Reads the current title, previews the diff, avoids no-op writes and verifies the final title by read-back.",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "minutes",
|
||||
"command": "+upload",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "Owns local file validation, create-PUT-complete polling, cancellation compensation and final task read-back verification.",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "minutes",
|
||||
"command": "+upload-and-analyze",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "Completes local upload with compensation, waits for validated artifacts, and optionally orchestrates mind map and speaker insights without re-upload recovery hazards.",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
|
||||
@@ -16,12 +16,12 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
publicShortcutCount = 357
|
||||
publicShortcutCount = 378
|
||||
// schemaPublishedShortcutCount counts every delivered *.shortcut_* tool,
|
||||
// including hidden leaves such as minutes.shortcut_minutes_search.
|
||||
schemaPublishedShortcutCount = 358
|
||||
// including the hidden historical minutes.shortcut_minutes_search contract.
|
||||
schemaPublishedShortcutCount = 379
|
||||
// publiclyDeliveredShortcutCount is the public-catalog subset of that surface.
|
||||
publiclyDeliveredShortcutCount = 357
|
||||
publiclyDeliveredShortcutCount = 378
|
||||
)
|
||||
|
||||
func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) {
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -1322,10 +1325,13 @@ ORG 类型授权不会出现在查询结果中。`,
|
||||
|
||||
通过 --workspace 指定知识库,支持传入知识库 ID 或知识库 URL。
|
||||
支持分页,通过 --cursor 传入上次返回的 nextToken 获取下一页。
|
||||
|
||||
权限要求:调用者需具备知识库的成员权限,非成员会被拒绝访问。`,
|
||||
使用 --exclude-file 可排除普通文件、媒体文件、文件夹及 Office 文件动态,仅保留在线文档操作(创建/更新/评论/点赞等)。
|
||||
当用户要求"只看文档操作""排除文件上传"等意图时,必须使用此 flag,
|
||||
禁止在客户端自行过滤。`,
|
||||
Example: ` dws wiki feed list --workspace <workspaceId>
|
||||
dws wiki feed list --workspace <workspaceId> --limit 10
|
||||
dws wiki feed list --workspace <workspaceId> --exclude-file
|
||||
dws wiki feed list --workspace <workspaceId> --limit 10 --exclude-file
|
||||
dws wiki feed list --workspace <workspaceId> --cursor <nextToken>`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
workspaceID, err := mustFlagOrFallback(cmd, "workspace", "workspace-id")
|
||||
@@ -1345,7 +1351,19 @@ ORG 类型授权不会出现在查询结果中。`,
|
||||
v, _ := cmd.Flags().GetBool("exclude-file")
|
||||
toolArgs["excludeFile"] = v
|
||||
}
|
||||
return callMCPTool("list_workspace_feeds", toolArgs)
|
||||
// 调用后对 feeds[].time 毫秒时间戳做本地化格式化,
|
||||
// 使 Agent 直接看到可读时间,无需自行转换
|
||||
text, err := callMCPToolReturnText(context.Background(), "list_workspace_feeds", toolArgs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 保持与共享 dispatcher (callMCPToolInternalOpts) 一致的输出契约:
|
||||
// 仅 --format json 做时间/标签增强,raw、table 及其他格式输出原始 MCP 文本
|
||||
if deps.Caller.Format() != "json" {
|
||||
deps.Out.PrintRaw(text)
|
||||
return nil
|
||||
}
|
||||
return deps.Out.PrintJSON(formatFeedTime(text))
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(feedListCmd, LeafSpec{
|
||||
@@ -1391,9 +1409,9 @@ ORG 类型授权不会出现在查询结果中。`,
|
||||
},
|
||||
})
|
||||
feedListCmd.Flags().String("workspace", "", "知识库 ID 或 URL (必填)")
|
||||
feedListCmd.Flags().Int("limit", 0, "每页数量 (默认 20,最大 50)")
|
||||
feedListCmd.Flags().Int("limit", 0, "每页数量 (默认 10,最大 20)。用户未明确要求条数时禁止加此 flag,让服务端走默认 10")
|
||||
feedListCmd.Flags().String("cursor", "", "分页游标 (首页留空)")
|
||||
feedListCmd.Flags().Bool("exclude-file", false, "是否排除文件相关的动态 (默认 false)")
|
||||
feedListCmd.Flags().Bool("exclude-file", false, "排除普通文件、媒体文件、文件夹及 Office 文件动态,仅保留在线文档操作(创建/更新/评论/点赞)。用户要求排除文件/只看文档操作时必须使用此 flag,禁止客户端过滤")
|
||||
feedListCmd.Flags().String("workspace-id", "", "")
|
||||
_ = feedListCmd.Flags().MarkHidden("workspace-id")
|
||||
RegisterCrossProductAliases(feedListCmd)
|
||||
@@ -1430,3 +1448,62 @@ ORG 类型授权不会出现在查询结果中。`,
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
// beijingLoc 用于将时间戳格式化为北京时间(UTC+8)
|
||||
var beijingLoc = time.FixedZone("CST", 8*3600)
|
||||
|
||||
// formatFeedTime 将知识库动态 feeds[].time 毫秒时间戳转为可读时间字符串,
|
||||
// 写入 timeFormatted 字段,同时保留原始 time 毫秒时间戳不变(避免破坏已有脚本)。
|
||||
// 解析失败时原样返回,不阻断输出。
|
||||
func formatFeedTime(raw string) any {
|
||||
var result map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &result); err != nil {
|
||||
return raw
|
||||
}
|
||||
feeds, ok := result["feeds"].([]any)
|
||||
if !ok {
|
||||
return result
|
||||
}
|
||||
for _, f := range feeds {
|
||||
item, ok := f.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
ms, ok := item["time"].(float64)
|
||||
if !ok || ms <= 0 {
|
||||
continue
|
||||
}
|
||||
// 保留原始 time 毫秒时间戳,新增 timeFormatted 可读字段
|
||||
item["timeFormatted"] = time.UnixMilli(int64(ms)).In(beijingLoc).Format("2006-01-02 15:04")
|
||||
enrichFeedFields(item)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// enrichFeedFields 为单条 feed 补充可读字段(typeLabel),
|
||||
func enrichFeedFields(item map[string]any) {
|
||||
// 将 type 数字映射为可读标签,使 Agent 无需查表即可直接展示
|
||||
if typeNum, ok := item["type"].(float64); ok {
|
||||
if label, known := feedTypeLabels[int(typeNum)]; known {
|
||||
item["typeLabel"] = label
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// feedTypeLabels 将知识库动态 type 数字映射为可读中文标签,
|
||||
// 与 lippi-combo OpenFeedItemDTO.type 枚举对齐。
|
||||
var feedTypeLabels = map[int]string{
|
||||
0: "创建文档",
|
||||
1: "更新文档",
|
||||
2: "评论文档",
|
||||
3: "点赞文档",
|
||||
4: "加入团队空间",
|
||||
5: "表格选区数据变更",
|
||||
6: "更新 office 文件",
|
||||
7: "上传普通文件",
|
||||
8: "上传媒体文件",
|
||||
9: "上传文件夹",
|
||||
10: "上传文件夹 V2",
|
||||
11: "加入团队",
|
||||
12: "创建知识库",
|
||||
}
|
||||
|
||||
@@ -0,0 +1,582 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ---------- formatFeedTime unit tests ----------
|
||||
|
||||
func TestCrossPlatformCoverageFormatFeedTimeInvalidJSON(t *testing.T) {
|
||||
// JSON 解析失败 → 原样返回原始字符串
|
||||
raw := "not-json"
|
||||
got := formatFeedTime(raw)
|
||||
s, ok := got.(string)
|
||||
if !ok || s != raw {
|
||||
t.Fatalf("formatFeedTime(invalid) = %v (%T), want raw string %q", got, got, raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageFormatFeedTimeEmptyInput(t *testing.T) {
|
||||
// 空字符串 → JSON 解析失败 → 原样返回
|
||||
got := formatFeedTime("")
|
||||
s, ok := got.(string)
|
||||
if !ok || s != "" {
|
||||
t.Fatalf("formatFeedTime(\"\") = %v (%T), want \"\"", got, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageFormatFeedTimeNoFeedsKey(t *testing.T) {
|
||||
// 合法 JSON 但没有 feeds 字段 → 返回解析后的 map
|
||||
raw := `{"status":"ok","count":0}`
|
||||
got := formatFeedTime(raw)
|
||||
m, ok := got.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("formatFeedTime(no feeds) = %T, want map[string]any", got)
|
||||
}
|
||||
if m["status"] != "ok" {
|
||||
t.Fatalf("status = %v, want ok", m["status"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageFormatFeedTimeFeedsNotArray(t *testing.T) {
|
||||
// feeds 不是数组 → 返回解析后的 map
|
||||
raw := `{"feeds":"unexpected-string"}`
|
||||
got := formatFeedTime(raw)
|
||||
m, ok := got.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("formatFeedTime(feeds string) = %T, want map[string]any", got)
|
||||
}
|
||||
if m["feeds"] != "unexpected-string" {
|
||||
t.Fatalf("feeds = %v, want unexpected-string", m["feeds"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageFormatFeedTimeFeedsNull(t *testing.T) {
|
||||
// feeds 为 null → 类型断言失败 → 返回解析后的 map
|
||||
raw := `{"feeds":null}`
|
||||
got := formatFeedTime(raw)
|
||||
_, ok := got.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("formatFeedTime(feeds null) = %T, want map[string]any", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageFormatFeedTimeNonMapItem(t *testing.T) {
|
||||
// feeds 数组元素不是 object → continue 跳过
|
||||
raw := `{"feeds":["string-item", 42, null]}`
|
||||
got := formatFeedTime(raw)
|
||||
m, ok := got.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected map, got %T", got)
|
||||
}
|
||||
feeds := m["feeds"].([]any)
|
||||
if len(feeds) != 3 {
|
||||
t.Fatalf("feeds length = %d, want 3", len(feeds))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageFormatFeedTimeTimeMissingOrInvalid(t *testing.T) {
|
||||
// time 字段缺失或不是 float64 或 <= 0 → 跳过格式化
|
||||
raw := `{"feeds":[
|
||||
{"id":"a"},
|
||||
{"id":"b","time":"not-a-number"},
|
||||
{"id":"c","time":0},
|
||||
{"id":"d","time":-1000}
|
||||
]}`
|
||||
got := formatFeedTime(raw)
|
||||
m, ok := got.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected map, got %T", got)
|
||||
}
|
||||
feeds := m["feeds"].([]any)
|
||||
for i, f := range feeds {
|
||||
item := f.(map[string]any)
|
||||
if _, hasTimeMs := item["timeMs"]; hasTimeMs {
|
||||
t.Fatalf("feed[%d] should not have timeMs, got %v", i, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageFormatFeedTimeNormalPath(t *testing.T) {
|
||||
// 正常路径:time 保留原始毫秒时间戳,timeFormatted 被格式化为北京时间
|
||||
// 1750067400000 ms = 2025-06-16 17:50 Beijing time (UTC+8)
|
||||
tsMs := float64(1750067400000)
|
||||
raw := buildFeedJSON(t, []map[string]any{
|
||||
{"time": tsMs, "type": float64(1), "id": "f1"},
|
||||
})
|
||||
|
||||
got := formatFeedTime(raw)
|
||||
m, ok := got.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected map, got %T", got)
|
||||
}
|
||||
feeds := m["feeds"].([]any)
|
||||
if len(feeds) != 1 {
|
||||
t.Fatalf("feeds length = %d, want 1", len(feeds))
|
||||
}
|
||||
item := feeds[0].(map[string]any)
|
||||
|
||||
// time 保留原始毫秒时间戳不变
|
||||
if ms, ok := item["time"].(float64); !ok || ms != tsMs {
|
||||
t.Fatalf("time = %v (%T), want original ms %v", item["time"], item["time"], tsMs)
|
||||
}
|
||||
|
||||
// timeFormatted 被格式化为北京时间字符串(硬编码期望值)
|
||||
timeStr, ok := item["timeFormatted"].(string)
|
||||
if !ok {
|
||||
t.Fatalf("timeFormatted = %T, want string", item["timeFormatted"])
|
||||
}
|
||||
if timeStr != "2025-06-16 17:50" {
|
||||
t.Fatalf("timeFormatted = %q, want %q", timeStr, "2025-06-16 17:50")
|
||||
}
|
||||
|
||||
// typeLabel 被填充
|
||||
if item["typeLabel"] != "更新文档" {
|
||||
t.Fatalf("typeLabel = %v, want 更新文档", item["typeLabel"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageFormatFeedTimeMultipleFeeds(t *testing.T) {
|
||||
// 多个 feed,混合有效/无效时间戳
|
||||
ts1 := float64(1750067400000)
|
||||
ts2 := float64(1700000000000)
|
||||
raw := buildFeedJSON(t, []map[string]any{
|
||||
{"time": ts1, "type": float64(0), "id": "f1"},
|
||||
{"time": ts2, "type": float64(12), "id": "f2"},
|
||||
{"id": "f3"}, // 没有 time → 跳过
|
||||
})
|
||||
|
||||
got := formatFeedTime(raw)
|
||||
m := got.(map[string]any)
|
||||
feeds := m["feeds"].([]any)
|
||||
|
||||
// 前两个有时间戳
|
||||
for i := 0; i < 2; i++ {
|
||||
item := feeds[i].(map[string]any)
|
||||
if _, ok := item["time"].(float64); !ok {
|
||||
t.Fatalf("feed[%d] time should remain float64, got %T", i, item["time"])
|
||||
}
|
||||
if _, ok := item["timeFormatted"].(string); !ok {
|
||||
t.Fatalf("feed[%d] timeFormatted not string", i)
|
||||
}
|
||||
}
|
||||
|
||||
// 第三个没有时间戳
|
||||
item3 := feeds[2].(map[string]any)
|
||||
if _, hasTimeFormatted := item3["timeFormatted"]; hasTimeFormatted {
|
||||
t.Fatalf("feed[2] should not have timeFormatted")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- enrichFeedFields unit tests ----------
|
||||
|
||||
func TestCrossPlatformCoverageEnrichFeedFieldsPreservesAllFields(t *testing.T) {
|
||||
// enrichFeedFields 不删除任何字段,仅新增 typeLabel
|
||||
item := map[string]any{
|
||||
"parentDoc": map[string]any{"name": "parent"},
|
||||
"userInfo": map[string]any{"nick": "user1"},
|
||||
"id": "f1",
|
||||
"type": float64(1),
|
||||
}
|
||||
enrichFeedFields(item)
|
||||
if _, ok := item["parentDoc"]; !ok {
|
||||
t.Fatal("parentDoc should be preserved")
|
||||
}
|
||||
if _, ok := item["userInfo"]; !ok {
|
||||
t.Fatal("userInfo should be preserved")
|
||||
}
|
||||
if item["id"] != "f1" {
|
||||
t.Fatal("id should be preserved")
|
||||
}
|
||||
if item["typeLabel"] != "更新文档" {
|
||||
t.Fatalf("typeLabel = %v, want 更新文档", item["typeLabel"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageEnrichFeedFieldsTypeLabelKnown(t *testing.T) {
|
||||
// 已知 type → 添加 typeLabel
|
||||
for typeNum, expectedLabel := range feedTypeLabels {
|
||||
item := map[string]any{"type": float64(typeNum)}
|
||||
enrichFeedFields(item)
|
||||
if item["typeLabel"] != expectedLabel {
|
||||
t.Fatalf("type %d: typeLabel = %v, want %q", typeNum, item["typeLabel"], expectedLabel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageEnrichFeedFieldsTypeLabelUnknown(t *testing.T) {
|
||||
// 未知 type → 不添加 typeLabel
|
||||
item := map[string]any{"type": float64(999)}
|
||||
enrichFeedFields(item)
|
||||
if _, ok := item["typeLabel"]; ok {
|
||||
t.Fatal("typeLabel should not be set for unknown type 999")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageEnrichFeedFieldsTypeNotFloat(t *testing.T) {
|
||||
// type 不是 float64 → 不添加 typeLabel
|
||||
item := map[string]any{"type": "string-type"}
|
||||
enrichFeedFields(item)
|
||||
if _, ok := item["typeLabel"]; ok {
|
||||
t.Fatal("typeLabel should not be set for non-float type")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageEnrichFeedFieldsUsersPreserved(t *testing.T) {
|
||||
// users 数组中的 user 对象所有字段保留不变
|
||||
item := map[string]any{
|
||||
"users": []any{
|
||||
map[string]any{"nick": "Alice", "avatarUrl": "http://img", "userId": "u1"},
|
||||
map[string]any{"nick": "Bob", "email": "bob@test"},
|
||||
},
|
||||
}
|
||||
enrichFeedFields(item)
|
||||
users := item["users"].([]any)
|
||||
u0 := users[0].(map[string]any)
|
||||
if u0["avatarUrl"] != "http://img" {
|
||||
t.Fatalf("users[0].avatarUrl should be preserved, got %v", u0["avatarUrl"])
|
||||
}
|
||||
if u0["userId"] != "u1" {
|
||||
t.Fatalf("users[0].userId should be preserved, got %v", u0["userId"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageEnrichFeedFieldsUsersNonMapItem(t *testing.T) {
|
||||
// users 数组中有非 map 元素 → 不 panic
|
||||
item := map[string]any{
|
||||
"users": []any{"not-a-map", 42, nil},
|
||||
}
|
||||
enrichFeedFields(item) // 不应 panic
|
||||
users := item["users"].([]any)
|
||||
if len(users) != 3 {
|
||||
t.Fatalf("users length changed: %d", len(users))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageEnrichFeedFieldsContentPreserved(t *testing.T) {
|
||||
// content 和 content.doc 所有字段保留不变
|
||||
item := map[string]any{
|
||||
"content": map[string]any{
|
||||
"doc": map[string]any{
|
||||
"name": "测试文档",
|
||||
"extension": "adoc",
|
||||
"docKey": "dk123",
|
||||
"url": "http://example",
|
||||
},
|
||||
"dentryKey": "entry1",
|
||||
"workspaceKey": "ws1",
|
||||
},
|
||||
}
|
||||
enrichFeedFields(item)
|
||||
content := item["content"].(map[string]any)
|
||||
if content["dentryKey"] != "entry1" {
|
||||
t.Fatalf("content.dentryKey = %v, want entry1", content["dentryKey"])
|
||||
}
|
||||
if content["workspaceKey"] != "ws1" {
|
||||
t.Fatalf("content.workspaceKey = %v, want ws1", content["workspaceKey"])
|
||||
}
|
||||
doc := content["doc"].(map[string]any)
|
||||
if doc["name"] != "测试文档" {
|
||||
t.Fatalf("doc.name = %v, want 测试文档", doc["name"])
|
||||
}
|
||||
if doc["docKey"] != "dk123" {
|
||||
t.Fatalf("doc.docKey = %v, want dk123", doc["docKey"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageEnrichFeedFieldsNoContentNoUsers(t *testing.T) {
|
||||
// 没有 content / users / type → 所有字段保留
|
||||
item := map[string]any{
|
||||
"id": "f1",
|
||||
"parentDoc": map[string]any{"x": 1},
|
||||
}
|
||||
enrichFeedFields(item)
|
||||
if _, ok := item["parentDoc"]; !ok {
|
||||
t.Fatal("parentDoc should be preserved")
|
||||
}
|
||||
if item["id"] != "f1" {
|
||||
t.Fatal("id should be preserved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageEnrichFeedFieldsEmptyItem(t *testing.T) {
|
||||
// 空 map → 不 panic
|
||||
item := map[string]any{}
|
||||
enrichFeedFields(item)
|
||||
if len(item) != 0 {
|
||||
t.Fatalf("empty item changed: %v", item)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- RunE integration: feed list with formatFeedTime ----------
|
||||
|
||||
func TestCrossPlatformCoverageWikiFeedListFormatIntegration(t *testing.T) {
|
||||
// 通过 scriptedToolCaller 返回真实结构的 feed JSON,验证 RunE 中
|
||||
// callMCPToolReturnText → formatFeedTime → PrintJSON 完整路径(仅 --format json)
|
||||
caller := &scriptedToolCaller{format: "json"}
|
||||
ts := float64(1750067400000)
|
||||
feedPayload := map[string]any{
|
||||
"feeds": []any{
|
||||
map[string]any{
|
||||
"time": ts,
|
||||
"type": float64(0),
|
||||
"id": "feed1",
|
||||
"parentDoc": map[string]any{"name": "parent"},
|
||||
"userInfo": map[string]any{"nick": "user1", "avatarUrl": "http://img"},
|
||||
"users": []any{
|
||||
map[string]any{"nick": "Alice", "userId": "u1", "avatarUrl": "http://avatar"},
|
||||
},
|
||||
"content": map[string]any{
|
||||
"doc": map[string]any{
|
||||
"name": "测试文档",
|
||||
"extension": "adoc",
|
||||
"docKey": "dk123",
|
||||
},
|
||||
"dentryKey": "entry1",
|
||||
"workspaceKey": "ws1",
|
||||
},
|
||||
},
|
||||
map[string]any{
|
||||
"time": float64(1700000000000),
|
||||
"type": float64(7),
|
||||
"id": "feed2",
|
||||
},
|
||||
},
|
||||
}
|
||||
payloadBytes, err := json.Marshal(feedPayload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
caller.steps = []scriptedToolStep{{text: string(payloadBytes)}}
|
||||
|
||||
var out bytes.Buffer
|
||||
oldDeps := deps
|
||||
oldArgs := os.Args
|
||||
InitDeps(caller)
|
||||
deps.Out.w = &out
|
||||
deps.Out.errW = io.Discard
|
||||
t.Cleanup(func() {
|
||||
deps = oldDeps
|
||||
os.Args = oldArgs
|
||||
})
|
||||
|
||||
root := newWikiCommand()
|
||||
installExampleGlobalFlags(root)
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetIn(os.Stdin)
|
||||
|
||||
args := []string{"feed", "list", "--workspace", "ws1", "--exclude-file", "--format", "json"}
|
||||
root.SetArgs(args)
|
||||
os.Args = append([]string{"dws", "wiki"}, args...)
|
||||
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
|
||||
// 验证输出是合法 JSON 且包含格式化后的 feed
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal(out.Bytes(), &parsed); err != nil {
|
||||
t.Fatalf("output is not valid JSON: %v\noutput: %s", err, out.String())
|
||||
}
|
||||
feeds, ok := parsed["feeds"].([]any)
|
||||
if !ok || len(feeds) != 2 {
|
||||
t.Fatalf("feeds length unexpected: %v", parsed["feeds"])
|
||||
}
|
||||
|
||||
// 第一个 feed 验证 time 保留、timeFormatted 格式化、所有原始字段保留
|
||||
f0 := feeds[0].(map[string]any)
|
||||
// time 保留原始毫秒时间戳
|
||||
if f0["time"] != ts {
|
||||
t.Fatalf("time = %v, want %v", f0["time"], ts)
|
||||
}
|
||||
// timeFormatted 被填充
|
||||
if _, ok := f0["timeFormatted"].(string); !ok {
|
||||
t.Fatalf("timeFormatted should be string, got %T", f0["timeFormatted"])
|
||||
}
|
||||
// 所有原始字段保留(不删除任何字段)
|
||||
if _, ok := f0["parentDoc"]; !ok {
|
||||
t.Fatal("parentDoc should be preserved")
|
||||
}
|
||||
if _, ok := f0["userInfo"]; !ok {
|
||||
t.Fatal("userInfo should be preserved")
|
||||
}
|
||||
if f0["typeLabel"] != "创建文档" {
|
||||
t.Fatalf("typeLabel = %v, want 创建文档", f0["typeLabel"])
|
||||
}
|
||||
|
||||
// users 保留验证:avatarUrl / userId 均保留
|
||||
if users, ok := f0["users"].([]any); ok {
|
||||
u0 := users[0].(map[string]any)
|
||||
if u0["avatarUrl"] != "http://avatar" {
|
||||
t.Fatalf("users[0].avatarUrl should be preserved, got %v", u0["avatarUrl"])
|
||||
}
|
||||
if u0["userId"] != "u1" {
|
||||
t.Fatalf("users[0].userId should be preserved, got %v", u0["userId"])
|
||||
}
|
||||
}
|
||||
|
||||
// content 结构保留验证
|
||||
if content, ok := f0["content"].(map[string]any); ok {
|
||||
if content["dentryKey"] != "entry1" {
|
||||
t.Fatalf("content.dentryKey should be preserved, got %v", content["dentryKey"])
|
||||
}
|
||||
if content["workspaceKey"] != "ws1" {
|
||||
t.Fatalf("content.workspaceKey should be preserved, got %v", content["workspaceKey"])
|
||||
}
|
||||
doc := content["doc"].(map[string]any)
|
||||
if doc["docKey"] != "dk123" {
|
||||
t.Fatalf("content.doc.docKey should be preserved, got %v", doc["docKey"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageWikiFeedListRawFormat(t *testing.T) {
|
||||
// --format raw: 输出原始 MCP 文本,不经过 formatFeedTime 后处理
|
||||
rawPayload := `{"feeds":[{"time":1750067400000,"type":1}]}`
|
||||
caller := &scriptedToolCaller{
|
||||
format: "raw",
|
||||
steps: []scriptedToolStep{{text: rawPayload}},
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
oldDeps := deps
|
||||
oldArgs := os.Args
|
||||
InitDeps(caller)
|
||||
deps.Out.w = &out
|
||||
deps.Out.errW = io.Discard
|
||||
t.Cleanup(func() {
|
||||
deps = oldDeps
|
||||
os.Args = oldArgs
|
||||
})
|
||||
|
||||
root := newWikiCommand()
|
||||
installExampleGlobalFlags(root)
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetIn(os.Stdin)
|
||||
|
||||
args := []string{"feed", "list", "--workspace", "ws1", "--format", "raw"}
|
||||
root.SetArgs(args)
|
||||
os.Args = append([]string{"dws", "wiki"}, args...)
|
||||
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
|
||||
// raw 输出应包含原始文本,不经过 formatFeedTime 格式化
|
||||
got := out.String()
|
||||
if !bytes.Contains([]byte(got), []byte(`"feeds"`)) {
|
||||
t.Fatalf("raw output should contain original payload, got: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageWikiFeedListTableFormatMatchesDispatcher(t *testing.T) {
|
||||
// --format table 回归:与共享 dispatcher (callMCPToolInternalOpts) 契约一致,
|
||||
// 输出原始紧凑 MCP 文本,不做 pretty 化,也不注入 timeFormatted / typeLabel
|
||||
rawPayload := `{"feeds":[{"time":1750067400000,"type":1,"id":"f1"}]}`
|
||||
caller := &scriptedToolCaller{
|
||||
format: "table",
|
||||
steps: []scriptedToolStep{{text: rawPayload}},
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
oldDeps := deps
|
||||
oldArgs := os.Args
|
||||
InitDeps(caller)
|
||||
deps.Out.w = &out
|
||||
deps.Out.errW = io.Discard
|
||||
t.Cleanup(func() {
|
||||
deps = oldDeps
|
||||
os.Args = oldArgs
|
||||
})
|
||||
|
||||
root := newWikiCommand()
|
||||
installExampleGlobalFlags(root)
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetIn(os.Stdin)
|
||||
|
||||
args := []string{"feed", "list", "--workspace", "ws1", "--format", "table"}
|
||||
root.SetArgs(args)
|
||||
os.Args = append([]string{"dws", "wiki"}, args...)
|
||||
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
|
||||
// 与共享 dispatcher 一致:输出 = 原始 MCP 文本原文
|
||||
if got := strings.TrimSpace(out.String()); got != rawPayload {
|
||||
t.Fatalf("table output should equal raw MCP payload,\ngot: %s\nwant: %s", got, rawPayload)
|
||||
}
|
||||
// 不注入任何增强字段
|
||||
for _, injected := range []string{"timeFormatted", "typeLabel"} {
|
||||
if strings.Contains(out.String(), injected) {
|
||||
t.Fatalf("table output should not inject %s, got: %s", injected, out.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageWikiFeedListEmptyResponse(t *testing.T) {
|
||||
// MCP 返回空结果 → formatFeedTime 处理空字符串 → 不报错
|
||||
caller := &scriptedToolCaller{} // 无 steps → 空结果
|
||||
|
||||
var out bytes.Buffer
|
||||
oldDeps := deps
|
||||
oldArgs := os.Args
|
||||
InitDeps(caller)
|
||||
deps.Out.w = &out
|
||||
deps.Out.errW = io.Discard
|
||||
t.Cleanup(func() {
|
||||
deps = oldDeps
|
||||
os.Args = oldArgs
|
||||
})
|
||||
|
||||
root := newWikiCommand()
|
||||
installExampleGlobalFlags(root)
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetIn(os.Stdin)
|
||||
|
||||
args := []string{"feed", "list", "--workspace", "ws1"}
|
||||
root.SetArgs(args)
|
||||
os.Args = append([]string{"dws", "wiki"}, args...)
|
||||
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- helpers ----------
|
||||
|
||||
func buildFeedJSON(t *testing.T, feeds []map[string]any) string {
|
||||
t.Helper()
|
||||
payload := map[string]any{"feeds": feeds}
|
||||
b, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func mustMarshal(t *testing.T, v any) []byte {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package localio
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const defaultTextInputLimit = int64(8 << 20)
|
||||
|
||||
var (
|
||||
readTextInputAll = io.ReadAll
|
||||
statTextInputPath = os.Stat
|
||||
openTextInputFile = os.Open
|
||||
readTextInputRel = filepath.Rel
|
||||
)
|
||||
|
||||
// ReadTextInput resolves a literal, "-" stdin, or @workspace-relative file.
|
||||
// File symlinks must resolve inside the current working directory and all input
|
||||
// forms are bounded to prevent accidental unbounded memory use.
|
||||
func ReadTextInput(spec string, stdin io.Reader, maxBytes int64) (string, error) {
|
||||
if maxBytes <= 0 {
|
||||
maxBytes = defaultTextInputLimit
|
||||
}
|
||||
if spec == "-" {
|
||||
if stdin == nil {
|
||||
return "", fmt.Errorf("LOCAL_INPUT_INVALID: stdin 不可用")
|
||||
}
|
||||
data, err := readTextInputAll(io.LimitReader(stdin, maxBytes+1))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("LOCAL_INPUT_READ_FAILED: %w", err)
|
||||
}
|
||||
if int64(len(data)) > maxBytes {
|
||||
return "", fmt.Errorf("LOCAL_INPUT_TOO_LARGE: stdin 超过 %d 字节", maxBytes)
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
if !strings.HasPrefix(spec, "@") {
|
||||
if int64(len(spec)) > maxBytes {
|
||||
return "", fmt.Errorf("LOCAL_INPUT_TOO_LARGE: 文本输入超过 %d 字节", maxBytes)
|
||||
}
|
||||
return spec, nil
|
||||
}
|
||||
path := strings.TrimSpace(strings.TrimPrefix(spec, "@"))
|
||||
if path == "" {
|
||||
return "", fmt.Errorf("LOCAL_INPUT_INVALID: @file 路径不能为空")
|
||||
}
|
||||
if filepath.IsAbs(path) {
|
||||
return "", fmt.Errorf("LOCAL_INPUT_UNSAFE: @file 只接受工作目录内的相对路径")
|
||||
}
|
||||
clean := filepath.Clean(path)
|
||||
if clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
|
||||
return "", fmt.Errorf("LOCAL_INPUT_UNSAFE: @file 不能逃逸工作目录")
|
||||
}
|
||||
cwd, err := localGetwd()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("LOCAL_INPUT_READ_FAILED: %w", err)
|
||||
}
|
||||
realBase, err := localEvalSymlinks(cwd)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("LOCAL_INPUT_READ_FAILED: %w", err)
|
||||
}
|
||||
realPath, err := localEvalSymlinks(filepath.Join(realBase, clean))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("LOCAL_INPUT_READ_FAILED: %w", err)
|
||||
}
|
||||
rel, err := readTextInputRel(realBase, realPath)
|
||||
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) {
|
||||
return "", fmt.Errorf("LOCAL_INPUT_UNSAFE: @file 解析后逃逸工作目录")
|
||||
}
|
||||
pathInfo, err := statTextInputPath(realPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("LOCAL_INPUT_READ_FAILED: %w", err)
|
||||
}
|
||||
if !pathInfo.Mode().IsRegular() {
|
||||
return "", fmt.Errorf("LOCAL_INPUT_INVALID: @file 必须是普通文件")
|
||||
}
|
||||
if pathInfo.Size() > maxBytes {
|
||||
return "", fmt.Errorf("LOCAL_INPUT_TOO_LARGE: 文件大小 %d 超过 %d 字节", pathInfo.Size(), maxBytes)
|
||||
}
|
||||
file, err := openTextInputFile(realPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("LOCAL_INPUT_READ_FAILED: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("LOCAL_INPUT_READ_FAILED: %w", err)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return "", fmt.Errorf("LOCAL_INPUT_INVALID: @file 必须是普通文件")
|
||||
}
|
||||
if info.Size() > maxBytes {
|
||||
return "", fmt.Errorf("LOCAL_INPUT_TOO_LARGE: 文件大小 %d 超过 %d 字节", info.Size(), maxBytes)
|
||||
}
|
||||
data, err := readTextInputAll(io.LimitReader(file, maxBytes+1))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("LOCAL_INPUT_READ_FAILED: %w", err)
|
||||
}
|
||||
if int64(len(data)) > maxBytes {
|
||||
return "", fmt.Errorf("LOCAL_INPUT_TOO_LARGE: 文件超过 %d 字节", maxBytes)
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package localio
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageReadTextInputUsesOpenedDescriptorAndBoundedReadE2E(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
old, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(old) })
|
||||
|
||||
t.Run("file grows after stat", func(t *testing.T) {
|
||||
path := filepath.Join(dir, "grow.txt")
|
||||
if err := os.WriteFile(path, []byte("ok"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
testseam.Swap(t, &readTextInputAll, func(reader io.Reader) ([]byte, error) {
|
||||
file, openErr := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0)
|
||||
if openErr != nil {
|
||||
return nil, openErr
|
||||
}
|
||||
if _, writeErr := file.WriteString("-too-large"); writeErr != nil {
|
||||
_ = file.Close()
|
||||
return nil, writeErr
|
||||
}
|
||||
if closeErr := file.Close(); closeErr != nil {
|
||||
return nil, closeErr
|
||||
}
|
||||
return io.ReadAll(reader)
|
||||
})
|
||||
if _, err := ReadTextInput("@grow.txt", nil, 4); err == nil {
|
||||
t.Fatal("file growth bypassed size limit")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("path becomes directory before descriptor check", func(t *testing.T) {
|
||||
path := filepath.Join(dir, "becomes-directory.txt")
|
||||
if err := os.WriteFile(path, []byte("safe"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
testseam.Swap(t, &openTextInputFile, func(candidate string) (*os.File, error) {
|
||||
if removeErr := os.Remove(candidate); removeErr != nil {
|
||||
return nil, removeErr
|
||||
}
|
||||
if mkdirErr := os.Mkdir(candidate, 0o700); mkdirErr != nil {
|
||||
return nil, mkdirErr
|
||||
}
|
||||
return os.Open(candidate)
|
||||
})
|
||||
if _, err := ReadTextInput("@becomes-directory.txt", nil, 10); err == nil {
|
||||
t.Fatal("path type replacement bypassed descriptor validation")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("file grows before descriptor check", func(t *testing.T) {
|
||||
path := filepath.Join(dir, "grows-before-stat.txt")
|
||||
if err := os.WriteFile(path, []byte("ok"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
testseam.Swap(t, &openTextInputFile, func(candidate string) (*os.File, error) {
|
||||
file, openErr := os.Open(candidate)
|
||||
if openErr != nil {
|
||||
return nil, openErr
|
||||
}
|
||||
writer, writeOpenErr := os.OpenFile(candidate, os.O_APPEND|os.O_WRONLY, 0)
|
||||
if writeOpenErr != nil {
|
||||
_ = file.Close()
|
||||
return nil, writeOpenErr
|
||||
}
|
||||
if _, writeErr := writer.WriteString("-too-large"); writeErr != nil {
|
||||
_ = writer.Close()
|
||||
_ = file.Close()
|
||||
return nil, writeErr
|
||||
}
|
||||
if closeErr := writer.Close(); closeErr != nil {
|
||||
_ = file.Close()
|
||||
return nil, closeErr
|
||||
}
|
||||
return file, nil
|
||||
})
|
||||
if _, err := ReadTextInput("@grows-before-stat.txt", nil, 4); err == nil {
|
||||
t.Fatal("pre-open growth bypassed descriptor size validation")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
//go:build unix
|
||||
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package localio
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageReadTextInputUsesOpenedDescriptorAfterPathReplacementE2E(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
old, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(old) })
|
||||
|
||||
path := filepath.Join(dir, "input.txt")
|
||||
replacement := filepath.Join(dir, "replacement.txt")
|
||||
if err := os.WriteFile(path, []byte("safe"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(replacement, []byte("replacement-exceeds-limit"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
testseam.Swap(t, &openTextInputFile, func(candidate string) (*os.File, error) {
|
||||
file, openErr := os.Open(candidate)
|
||||
if openErr != nil {
|
||||
return nil, openErr
|
||||
}
|
||||
if renameErr := os.Rename(replacement, candidate); renameErr != nil {
|
||||
_ = file.Close()
|
||||
return nil, renameErr
|
||||
}
|
||||
return file, nil
|
||||
})
|
||||
got, err := ReadTextInput("@input.txt", nil, 10)
|
||||
if err != nil || got != "safe" {
|
||||
t.Fatalf("replacement read = %q, %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageReadTextInputRejectsUnconnectedFIFOWithoutBlockingE2E(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
old, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(old) })
|
||||
|
||||
fifo := filepath.Join(dir, "input.fifo")
|
||||
if err := syscall.Mkfifo(fifo, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result := make(chan error, 1)
|
||||
go func() {
|
||||
_, readErr := ReadTextInput("@input.fifo", nil, 1024)
|
||||
result <- readErr
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-result:
|
||||
if err == nil || !strings.Contains(err.Error(), "LOCAL_INPUT_INVALID") {
|
||||
t.Fatalf("FIFO rejection error = %v", err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("FIFO without a writer blocked text input validation")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package localio
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageReadTextInputLiteralStdinAndFileE2E(t *testing.T) {
|
||||
if got, err := ReadTextInput("literal", nil, 10); err != nil || got != "literal" {
|
||||
t.Fatalf("literal = %q, %v", got, err)
|
||||
}
|
||||
if got, err := ReadTextInput("-", strings.NewReader("stdin"), 10); err != nil || got != "stdin" {
|
||||
t.Fatalf("stdin = %q, %v", got, err)
|
||||
}
|
||||
dir := t.TempDir()
|
||||
old, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(old) })
|
||||
if err := os.WriteFile(filepath.Join(dir, "input.json"), []byte("file"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, err := ReadTextInput("@input.json", nil, 10); err != nil || got != "file" {
|
||||
t.Fatalf("file = %q, %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageReadTextInputRejectsEscapeAndOversizeE2E(t *testing.T) {
|
||||
for _, spec := range []string{"@../secret", "@/tmp/secret", "@"} {
|
||||
if _, err := ReadTextInput(spec, nil, 10); err == nil {
|
||||
t.Fatalf("unsafe input accepted: %q", spec)
|
||||
}
|
||||
}
|
||||
if _, err := ReadTextInput("too-large", nil, 2); err == nil {
|
||||
t.Fatal("oversize literal accepted")
|
||||
}
|
||||
if _, err := ReadTextInput("-", strings.NewReader("too-large"), 2); err == nil {
|
||||
t.Fatal("oversize stdin accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageReadTextInputFailureBranchesE2E(t *testing.T) {
|
||||
if got, err := ReadTextInput("literal", nil, 0); err != nil || got != "literal" {
|
||||
t.Fatalf("default limit literal = %q, %v", got, err)
|
||||
}
|
||||
if _, err := ReadTextInput("-", nil, 1); err == nil {
|
||||
t.Fatal("nil stdin accepted")
|
||||
}
|
||||
t.Run("stdin read", func(t *testing.T) {
|
||||
testseam.Swap(t, &readTextInputAll, func(io.Reader) ([]byte, error) { return nil, errors.New("read") })
|
||||
if _, err := ReadTextInput("-", strings.NewReader("x"), 1); err == nil {
|
||||
t.Fatal("stdin read failure ignored")
|
||||
}
|
||||
})
|
||||
|
||||
dir := t.TempDir()
|
||||
file := filepath.Join(dir, "input.txt")
|
||||
if err := os.WriteFile(file, []byte("long"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
old, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(old) })
|
||||
if _, err := ReadTextInput("@input.txt", nil, 1); err == nil {
|
||||
t.Fatal("oversize file accepted")
|
||||
}
|
||||
if _, err := ReadTextInput("@.", nil, 10); err == nil {
|
||||
t.Fatal("directory accepted")
|
||||
}
|
||||
|
||||
for name, run := range map[string]func(*testing.T){
|
||||
"getwd": func(t *testing.T) {
|
||||
testseam.Swap(t, &localGetwd, func() (string, error) { return "", errors.New("getwd") })
|
||||
},
|
||||
"eval base": func(t *testing.T) {
|
||||
testseam.Swap(t, &localEvalSymlinks, func(string) (string, error) { return "", errors.New("eval base") })
|
||||
},
|
||||
"eval path": func(t *testing.T) {
|
||||
calls := 0
|
||||
testseam.Swap(t, &localEvalSymlinks, func(path string) (string, error) {
|
||||
calls++
|
||||
if calls == 2 {
|
||||
return "", errors.New("eval path")
|
||||
}
|
||||
return path, nil
|
||||
})
|
||||
},
|
||||
"relative": func(t *testing.T) {
|
||||
testseam.Swap(t, &readTextInputRel, func(string, string) (string, error) { return "", errors.New("rel") })
|
||||
},
|
||||
"path stat": func(t *testing.T) {
|
||||
testseam.Swap(t, &statTextInputPath, func(string) (os.FileInfo, error) { return nil, errors.New("stat") })
|
||||
},
|
||||
"open": func(t *testing.T) {
|
||||
testseam.Swap(t, &openTextInputFile, func(string) (*os.File, error) { return nil, errors.New("open") })
|
||||
},
|
||||
"opened file stat": func(t *testing.T) {
|
||||
testseam.Swap(t, &openTextInputFile, func(candidate string) (*os.File, error) {
|
||||
file, openErr := os.Open(candidate)
|
||||
if openErr == nil {
|
||||
_ = file.Close()
|
||||
}
|
||||
return file, openErr
|
||||
})
|
||||
},
|
||||
"read file": func(t *testing.T) {
|
||||
testseam.Swap(t, &readTextInputAll, func(io.Reader) ([]byte, error) { return nil, errors.New("read file") })
|
||||
},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
run(t)
|
||||
if _, err := ReadTextInput("@input.txt", nil, 10); err == nil {
|
||||
t.Fatalf("%s failure ignored", name)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("resolved escape", func(t *testing.T) {
|
||||
calls := 0
|
||||
testseam.Swap(t, &localEvalSymlinks, func(path string) (string, error) {
|
||||
calls++
|
||||
if calls == 2 {
|
||||
return filepath.Join(filepath.Dir(path), "..", "outside"), nil
|
||||
}
|
||||
return path, nil
|
||||
})
|
||||
if _, err := ReadTextInput("@input.txt", nil, 10); err == nil {
|
||||
t.Fatal("resolved escape accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package localio
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
const defaultPublishBytesLimit = 64 << 20
|
||||
|
||||
var verifyPublishTarget = func(target *downloadTarget) error { return target.verifyParent() }
|
||||
|
||||
// PublishBytesOptions controls safe no-clobber publication beneath BaseDir.
|
||||
type PublishBytesOptions struct {
|
||||
BaseDir string
|
||||
Output string
|
||||
PreferredName string
|
||||
MaxBytes int64
|
||||
}
|
||||
|
||||
// PublishBytes writes an in-memory artifact through the same symlink-safe,
|
||||
// fsync and atomic no-clobber path used by remote downloads.
|
||||
func PublishBytes(payload []byte, opts PublishBytesOptions) (DownloadResult, error) {
|
||||
limit := opts.MaxBytes
|
||||
if limit <= 0 {
|
||||
limit = defaultPublishBytesLimit
|
||||
}
|
||||
if int64(len(payload)) > limit {
|
||||
return DownloadResult{}, fmt.Errorf("LOCAL_OUTPUT_TOO_LARGE: 内容大小 %d 超过上限 %d 字节", len(payload), limit)
|
||||
}
|
||||
target, err := openDownloadTarget(opts.BaseDir, opts.Output, "", opts.PreferredName)
|
||||
if err != nil {
|
||||
return DownloadResult{}, err
|
||||
}
|
||||
defer target.close()
|
||||
if err := verifyPublishTarget(target); err != nil {
|
||||
return DownloadResult{}, err
|
||||
}
|
||||
tmp, tmpName, err := createDownloadTemp(target.parentRoot)
|
||||
if err != nil {
|
||||
return DownloadResult{}, fmt.Errorf("创建输出临时文件失败: %w", err)
|
||||
}
|
||||
cleanup := func() {
|
||||
_ = tmp.Close()
|
||||
_ = target.parentRoot.Remove(tmpName)
|
||||
}
|
||||
if _, err := tmp.Write(payload); err != nil {
|
||||
cleanup()
|
||||
return DownloadResult{}, fmt.Errorf("写入输出临时文件失败: %w", err)
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
cleanup()
|
||||
return DownloadResult{}, fmt.Errorf("同步输出临时文件失败: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
cleanup()
|
||||
return DownloadResult{}, fmt.Errorf("关闭输出临时文件失败: %w", err)
|
||||
}
|
||||
if err := verifyPublishTarget(target); err != nil {
|
||||
cleanup()
|
||||
return DownloadResult{}, err
|
||||
}
|
||||
if err := publishTempFile(target.parentRoot, tmpName, target.destinationName); err != nil {
|
||||
cleanup()
|
||||
return DownloadResult{}, err
|
||||
}
|
||||
return DownloadResult{AbsolutePath: target.absolutePath, RelativePath: filepath.ToSlash(target.relativePath), SizeBytes: int64(len(payload))}, nil
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package localio
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoveragePublishBytesAtomicNoClobberE2E(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
result, err := PublishBytes([]byte("verified artifact"), PublishBytesOptions{BaseDir: base, Output: "out/", PreferredName: "transcript.json"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(base, result.RelativePath))
|
||||
if err != nil || string(raw) != "verified artifact" || result.SizeBytes != int64(len(raw)) {
|
||||
t.Fatalf("publish result=%#v raw=%q err=%v", result, raw, err)
|
||||
}
|
||||
if _, err := PublishBytes([]byte("overwrite"), PublishBytesOptions{BaseDir: base, Output: "out/transcript.json"}); err == nil {
|
||||
t.Fatal("existing output was overwritten")
|
||||
}
|
||||
if _, err := PublishBytes([]byte("escape"), PublishBytesOptions{BaseDir: base, Output: "../escape"}); err == nil {
|
||||
t.Fatal("path escape accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePublishBytesFailureBranchesE2E(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
if _, err := PublishBytes([]byte("xx"), PublishBytesOptions{BaseDir: base, Output: "large", MaxBytes: 1}); err == nil {
|
||||
t.Fatal("oversize payload accepted")
|
||||
}
|
||||
if _, err := PublishBytes([]byte("x"), PublishBytesOptions{BaseDir: base, Output: "default-limit", MaxBytes: 0}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
make func(*os.Root) (downloadTempFile, string, error)
|
||||
}{
|
||||
{name: "create", make: func(*os.Root) (downloadTempFile, string, error) { return nil, "", errors.New("create") }},
|
||||
{name: "write", make: func(root *os.Root) (downloadTempFile, string, error) {
|
||||
file, name, err := createDownloadTempInRoot(root)
|
||||
return &coverageTempFile{file: file.(*os.File), writeErr: errors.New("write")}, name, err
|
||||
}},
|
||||
{name: "sync", make: func(root *os.Root) (downloadTempFile, string, error) {
|
||||
file, name, err := createDownloadTempInRoot(root)
|
||||
return &coverageTempFile{file: file.(*os.File), syncErr: errors.New("sync")}, name, err
|
||||
}},
|
||||
{name: "close", make: func(root *os.Root) (downloadTempFile, string, error) {
|
||||
file, name, err := createDownloadTempInRoot(root)
|
||||
return &coverageTempFile{file: file.(*os.File), closeErr: errors.New("close")}, name, err
|
||||
}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
testseam.Swap(t, &createDownloadTemp, tc.make)
|
||||
if _, err := PublishBytes([]byte("x"), PublishBytesOptions{BaseDir: base, Output: tc.name}); err == nil {
|
||||
t.Fatalf("%s failure ignored", tc.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("initial parent verification", func(t *testing.T) {
|
||||
testseam.Swap(t, &verifyPublishTarget, func(*downloadTarget) error { return errors.New("verify") })
|
||||
if _, err := PublishBytes([]byte("x"), PublishBytesOptions{BaseDir: base, Output: "verify-initial"}); err == nil {
|
||||
t.Fatal("initial verification failure ignored")
|
||||
}
|
||||
})
|
||||
t.Run("second parent verification", func(t *testing.T) {
|
||||
calls := 0
|
||||
testseam.Swap(t, &verifyPublishTarget, func(*downloadTarget) error {
|
||||
calls++
|
||||
if calls > 1 {
|
||||
return errors.New("changed")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if _, err := PublishBytes([]byte("x"), PublishBytesOptions{BaseDir: base, Output: "verify-second"}); err == nil {
|
||||
t.Fatal("second verification failure ignored")
|
||||
}
|
||||
})
|
||||
t.Run("publish", func(t *testing.T) {
|
||||
testseam.Swap(t, &downloadRootLink, func(*os.Root, string, string) error { return errors.New("link") })
|
||||
if _, err := PublishBytes([]byte("x"), PublishBytesOptions{BaseDir: base, Output: "publish"}); err == nil {
|
||||
t.Fatal("publish failure ignored")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package localio
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
const defaultUploadLimit = int64(5 << 30)
|
||||
|
||||
var (
|
||||
newUploadHTTPClient = secureHTTPClient
|
||||
validateUploadURL = func(raw string) error {
|
||||
_, err := ValidateDownloadURL(raw)
|
||||
return err
|
||||
}
|
||||
openUploadFile = os.Open
|
||||
newUploadRequest = http.NewRequestWithContext
|
||||
)
|
||||
|
||||
// UploadResult records only non-sensitive transfer facts. The signed URL is
|
||||
// deliberately never returned.
|
||||
type UploadResult struct {
|
||||
SizeBytes int64
|
||||
Attempts int
|
||||
}
|
||||
|
||||
// PutFile uploads a regular local file to an exact trusted pre-signed HTTPS
|
||||
// endpoint. Redirects are rejected so file bytes and credentials cannot move to
|
||||
// a second origin. Transient failures reuse the same verified open file so a
|
||||
// path replacement cannot change the bytes sent by a later attempt.
|
||||
func PutFile(ctx context.Context, rawURL, path string, maxBytes int64) (UploadResult, error) {
|
||||
client := newUploadHTTPClient()
|
||||
client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
|
||||
return putFileWithClient(ctx, rawURL, path, maxBytes, client, validateUploadURL)
|
||||
}
|
||||
|
||||
func putFileWithClient(ctx context.Context, rawURL, path string, maxBytes int64, client *http.Client, validate func(string) error) (UploadResult, error) {
|
||||
if validate == nil {
|
||||
return UploadResult{}, fmt.Errorf("upload URL validator is nil")
|
||||
}
|
||||
if err := validate(rawURL); err != nil {
|
||||
return UploadResult{}, err
|
||||
}
|
||||
if maxBytes <= 0 {
|
||||
maxBytes = defaultUploadLimit
|
||||
}
|
||||
file, err := openUploadFile(path)
|
||||
if err != nil {
|
||||
return UploadResult{}, fmt.Errorf("打开上传文件失败: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
return UploadResult{}, fmt.Errorf("读取上传文件失败: %w", err)
|
||||
}
|
||||
if !info.Mode().IsRegular() || info.Size() <= 0 || info.Size() > maxBytes {
|
||||
return UploadResult{}, fmt.Errorf("上传文件必须是 1..%d 字节的普通文件", maxBytes)
|
||||
}
|
||||
result := UploadResult{SizeBytes: info.Size()}
|
||||
var lastErr error
|
||||
for attempt := 1; attempt <= 3; attempt++ {
|
||||
result.Attempts = attempt
|
||||
reader := io.NewSectionReader(file, 0, info.Size())
|
||||
request, requestErr := newUploadRequest(ctx, http.MethodPut, rawURL, reader)
|
||||
if requestErr != nil {
|
||||
return UploadResult{}, fmt.Errorf("创建上传请求失败: %w", requestErr)
|
||||
}
|
||||
request.ContentLength = info.Size()
|
||||
response, callErr := client.Do(request)
|
||||
if callErr == nil {
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 1<<20))
|
||||
bodyCloseErr := response.Body.Close()
|
||||
if response.StatusCode >= 200 && response.StatusCode < 300 && bodyCloseErr == nil {
|
||||
return result, nil
|
||||
}
|
||||
if response.StatusCode < 500 && response.StatusCode != http.StatusRequestTimeout && response.StatusCode != http.StatusTooManyRequests {
|
||||
return result, fmt.Errorf("上传返回 HTTP %d", response.StatusCode)
|
||||
}
|
||||
lastErr = fmt.Errorf("上传返回 HTTP %d", response.StatusCode)
|
||||
} else {
|
||||
lastErr = callErr
|
||||
}
|
||||
if attempt < 3 {
|
||||
timer := time.NewTimer(time.Duration(attempt) * 200 * time.Millisecond)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return result, ctx.Err()
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
return result, fmt.Errorf("上传在 %d 次尝试后失败: %w", result.Attempts, lastErr)
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package localio
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoveragePutFileRetriesAndUploadsExactBytesE2E(t *testing.T) {
|
||||
payload := []byte("minutes-e2e")
|
||||
replacement := []byte("replacement-data")
|
||||
path := filepath.Join(t.TempDir(), "audio.wav")
|
||||
if err := os.WriteFile(path, payload, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
replacementPath := filepath.Join(t.TempDir(), "replacement.wav")
|
||||
if err := os.WriteFile(replacementPath, replacement, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
openCalls := 0
|
||||
testseam.Swap(t, &openUploadFile, func(candidate string) (*os.File, error) {
|
||||
openCalls++
|
||||
if openCalls > 1 {
|
||||
return os.Open(replacementPath)
|
||||
}
|
||||
return os.Open(candidate)
|
||||
})
|
||||
calls := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
if r.Method != http.MethodPut {
|
||||
t.Errorf("method = %s", r.Method)
|
||||
}
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
if string(body) != string(payload) {
|
||||
t.Errorf("body = %q", body)
|
||||
}
|
||||
if calls == 1 {
|
||||
http.Error(w, "retry", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
result, err := putFileWithClient(context.Background(), server.URL, path, 100, server.Client(), func(string) error { return nil })
|
||||
if err != nil || calls != 2 || openCalls != 1 || result.Attempts != 2 || result.SizeBytes != int64(len(payload)) {
|
||||
t.Fatalf("upload = %#v calls=%d opens=%d err=%v", result, calls, openCalls, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePutFileValidatesOpenedDescriptorE2E(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
requestedPath := filepath.Join(base, "requested.wav")
|
||||
if err := os.WriteFile(requestedPath, []byte("ok"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
replacementPath := filepath.Join(base, "replacement.wav")
|
||||
if err := os.WriteFile(replacementPath, []byte("replacement-exceeds-limit"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
testseam.Swap(t, &openUploadFile, func(string) (*os.File, error) {
|
||||
return os.Open(replacementPath)
|
||||
})
|
||||
called := false
|
||||
client := &http.Client{Transport: uploadRoundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
called = true
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("ok"))}, nil
|
||||
})}
|
||||
if _, err := putFileWithClient(context.Background(), "http://example.invalid", requestedPath, 2, client, func(string) error { return nil }); err == nil {
|
||||
t.Fatal("replacement descriptor bypassed size validation")
|
||||
}
|
||||
if called {
|
||||
t.Fatal("upload started before the opened descriptor was validated")
|
||||
}
|
||||
}
|
||||
|
||||
type uploadRoundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f uploadRoundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) {
|
||||
return f(request)
|
||||
}
|
||||
|
||||
type uploadCloseErrorBody struct{ io.Reader }
|
||||
|
||||
func (uploadCloseErrorBody) Close() error { return errors.New("close") }
|
||||
|
||||
func TestCrossPlatformCoveragePutFileFailureBranchesE2E(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
path := filepath.Join(base, "audio.wav")
|
||||
if err := os.WriteFile(path, []byte("payload"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
okClient := &http.Client{Transport: uploadRoundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("ok"))}, nil
|
||||
})}
|
||||
if _, err := PutFile(context.Background(), "not-a-valid-upload-url", path, 10); err == nil {
|
||||
t.Fatal("default upload URL validation accepted invalid URL")
|
||||
}
|
||||
if _, err := putFileWithClient(context.Background(), "http://example.invalid", path, 10, okClient, nil); err == nil {
|
||||
t.Fatal("nil validator accepted")
|
||||
}
|
||||
if _, err := putFileWithClient(context.Background(), "http://example.invalid", path, 10, okClient, func(string) error { return errors.New("invalid") }); err == nil {
|
||||
t.Fatal("validator error ignored")
|
||||
}
|
||||
if _, err := putFileWithClient(context.Background(), "http://example.invalid", filepath.Join(base, "missing"), 10, okClient, func(string) error { return nil }); err == nil {
|
||||
t.Fatal("stat error ignored")
|
||||
}
|
||||
if _, err := putFileWithClient(context.Background(), "http://example.invalid", base, 10, okClient, func(string) error { return nil }); err == nil {
|
||||
t.Fatal("directory accepted")
|
||||
}
|
||||
empty := filepath.Join(base, "empty")
|
||||
if err := os.WriteFile(empty, nil, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := putFileWithClient(context.Background(), "http://example.invalid", empty, 10, okClient, func(string) error { return nil }); err == nil {
|
||||
t.Fatal("empty file accepted")
|
||||
}
|
||||
if _, err := putFileWithClient(context.Background(), "http://example.invalid", path, 1, okClient, func(string) error { return nil }); err == nil {
|
||||
t.Fatal("oversize file accepted")
|
||||
}
|
||||
|
||||
t.Run("wrapper", func(t *testing.T) {
|
||||
redirect := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
||||
http.Redirect(w, request, "http://example.invalid/redirected", http.StatusFound)
|
||||
}))
|
||||
defer redirect.Close()
|
||||
testseam.Swap(t, &newUploadHTTPClient, redirect.Client)
|
||||
testseam.Swap(t, &validateUploadURL, func(string) error { return nil })
|
||||
if _, err := PutFile(context.Background(), redirect.URL, path, 0); err == nil {
|
||||
t.Fatal("PutFile followed redirect")
|
||||
}
|
||||
})
|
||||
t.Run("open", func(t *testing.T) {
|
||||
testseam.Swap(t, &openUploadFile, func(string) (*os.File, error) { return nil, errors.New("open") })
|
||||
if _, err := putFileWithClient(context.Background(), "http://example.invalid", path, 10, okClient, func(string) error { return nil }); err == nil {
|
||||
t.Fatal("open error ignored")
|
||||
}
|
||||
})
|
||||
t.Run("opened file stat", func(t *testing.T) {
|
||||
testseam.Swap(t, &openUploadFile, func(candidate string) (*os.File, error) {
|
||||
file, err := os.Open(candidate)
|
||||
if err == nil {
|
||||
_ = file.Close()
|
||||
}
|
||||
return file, err
|
||||
})
|
||||
if _, err := putFileWithClient(context.Background(), "http://example.invalid", path, 10, okClient, func(string) error { return nil }); err == nil {
|
||||
t.Fatal("opened file stat error ignored")
|
||||
}
|
||||
})
|
||||
t.Run("request", func(t *testing.T) {
|
||||
testseam.Swap(t, &newUploadRequest, func(context.Context, string, string, io.Reader) (*http.Request, error) {
|
||||
return nil, errors.New("request")
|
||||
})
|
||||
if _, err := putFileWithClient(context.Background(), "http://example.invalid", path, 10, okClient, func(string) error { return nil }); err == nil {
|
||||
t.Fatal("request error ignored")
|
||||
}
|
||||
})
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
client *http.Client
|
||||
}{
|
||||
{name: "client", client: &http.Client{Transport: uploadRoundTripFunc(func(*http.Request) (*http.Response, error) { return nil, errors.New("call") })}},
|
||||
{name: "server", client: &http.Client{Transport: uploadRoundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: http.StatusServiceUnavailable, Body: io.NopCloser(strings.NewReader("retry"))}, nil
|
||||
})}},
|
||||
{name: "close", client: &http.Client{Transport: uploadRoundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: uploadCloseErrorBody{Reader: strings.NewReader("ok")}}, nil
|
||||
})}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if _, err := putFileWithClient(context.Background(), "http://example.invalid", path, 10, tc.client, func(string) error { return nil }); err == nil {
|
||||
t.Fatalf("%s retries unexpectedly succeeded", tc.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
cancelled, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
retryClient := &http.Client{Transport: uploadRoundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: http.StatusServiceUnavailable, Body: io.NopCloser(strings.NewReader("retry"))}, nil
|
||||
})}
|
||||
if _, err := putFileWithClient(cancelled, "http://example.invalid", path, 10, retryClient, func(string) error { return nil }); err == nil {
|
||||
t.Fatal("cancelled retry succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePutFileRejectsRedirectAndInvalidFileE2E(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "audio.wav")
|
||||
if err := os.WriteFile(path, []byte("x"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }))
|
||||
defer target.Close()
|
||||
redirect := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, target.URL, http.StatusFound) }))
|
||||
defer redirect.Close()
|
||||
client := redirect.Client()
|
||||
client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
|
||||
if _, err := putFileWithClient(context.Background(), redirect.URL, path, 100, client, func(string) error { return nil }); err == nil {
|
||||
t.Fatal("redirect accepted")
|
||||
}
|
||||
if _, err := putFileWithClient(context.Background(), redirect.URL, filepath.Join(t.TempDir(), "missing"), 100, client, func(string) error { return nil }); err == nil {
|
||||
t.Fatal("missing file accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package builtin_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageMinutesSemanticCatalogMatchesRegistryE2E(t *testing.T) {
|
||||
raw, err := os.ReadFile("../semantic_catalog_minutes.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var catalog struct {
|
||||
Shortcuts map[string]struct {
|
||||
Public bool `json:"public"`
|
||||
Reviewed bool `json:"reviewed"`
|
||||
Delta string `json:"semantic_delta"`
|
||||
} `json:"shortcuts"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &catalog); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registered := map[string]shortcut.Shortcut{}
|
||||
for _, item := range shortcut.All() {
|
||||
if item.Service == "minutes" {
|
||||
registered[item.Command] = item
|
||||
}
|
||||
}
|
||||
if len(registered) != len(catalog.Shortcuts) {
|
||||
t.Fatalf("minutes registry/catalog count = %d/%d", len(registered), len(catalog.Shortcuts))
|
||||
}
|
||||
for command, record := range catalog.Shortcuts {
|
||||
item, ok := registered[command]
|
||||
if !ok {
|
||||
t.Errorf("catalog command %s is not registered", command)
|
||||
continue
|
||||
}
|
||||
if !record.Reviewed || record.Delta == "" || item.Hidden == record.Public || !item.SemanticReviewed || item.Contract.Empty() {
|
||||
t.Errorf("minutes %s review/public/contract mismatch: record=%#v item=%#v", command, record, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,758 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package minutes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/localio"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/minutesdata"
|
||||
)
|
||||
|
||||
var (
|
||||
minutesPutFile = localio.PutFile
|
||||
minutesDownload = localio.Download
|
||||
)
|
||||
|
||||
var Search = shortcut.Shortcut{
|
||||
Service: "minutes", Command: "+search", Product: "minutes",
|
||||
Description: "按范围、标题关键词和时间搜索听记,支持安全全量翻页",
|
||||
Intent: "需要按标题关键词或时间范围搜索自己创建、他人共享或全部可访问听记时使用;对后端返回再做确定性标题匹配,返回稳定 taskUuid 投影与完整性信息。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
|
||||
Contract: minutesContract("+search", "按范围、标题关键词和时间搜索听记,支持安全全量翻页",
|
||||
"需要按标题关键词或时间范围搜索自己创建、他人共享或全部可访问听记,并拿到可继续操作的 taskUuid 时使用",
|
||||
[]string{"只想无条件浏览一页时可用 +list-mine/+list-shared/+list-all", "DWS 后端不支持 owner/participant 精确过滤,不要伪造该条件"},
|
||||
[]string{`dws minutes +search --query "周会" --scope all`, `dws minutes +search --start "2026-08-01T00:00:00+08:00" --scope mine --page-all`}),
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "query", Type: shortcut.FlagString, Desc: "标题关键词;shortcut 会对后端结果再次精确包含过滤"},
|
||||
{Name: "scope", Type: shortcut.FlagString, Default: "all", Desc: "搜索范围", Enum: []string{"mine", "shared", "all"}},
|
||||
{Name: "start", Type: shortcut.FlagString, Desc: "开始时间 RFC3339"},
|
||||
{Name: "end", Type: shortcut.FlagString, Desc: "结束时间 RFC3339"},
|
||||
{Name: "limit", Type: shortcut.FlagInt, Default: "20", Desc: "每页条数"},
|
||||
{Name: "cursor", Type: shortcut.FlagString, Desc: "起始 nextToken"},
|
||||
{Name: "page-all", Type: shortcut.FlagBool, Desc: "自动读取全部页"},
|
||||
{Name: "page-limit", Type: shortcut.FlagInt, Default: "100", Desc: "自动翻页安全上限"},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{
|
||||
{Kind: shortcut.ConstraintAtLeastOne, Flags: []string{"query", "start", "end"}},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"limit", "page-limit"}, Description: "--limit/--page-limit 必须大于 0"},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"start", "end"}, Description: "时间必须是 RFC3339 且开始时间不能晚于结束时间"},
|
||||
},
|
||||
Tips: []string{`dws minutes +search --query "周会" --scope all`, `dws minutes +search --start "2026-08-01T00:00:00+08:00" --scope mine --page-all`},
|
||||
Validate: func(rt *shortcut.RuntimeContext) error {
|
||||
if rt.Int("limit") <= 0 || rt.Int("page-limit") <= 0 {
|
||||
return apperrors.NewValidation("--limit 和 --page-limit 必须大于 0")
|
||||
}
|
||||
_, _, err := minutesTimeRange(rt.Str("start"), rt.Str("end"))
|
||||
return err
|
||||
},
|
||||
Execute: executeMinutesSearch,
|
||||
}
|
||||
|
||||
var Download = shortcut.Shortcut{
|
||||
Service: "minutes", Command: "+download", Product: "minutes",
|
||||
Description: "批量取得听记音视频地址并安全下载到本地",
|
||||
Intent: "已知一个或多个 taskUuid,需要取得音视频 OSS 地址或以 no-clobber、原子发布方式保存到工作目录时使用。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
|
||||
Contract: minutesContract("+download", "批量取得听记音视频地址并安全下载到本地",
|
||||
"已知一个或多个听记 taskUuid,需要下载其音频/视频或显式获取临时 URL 时使用",
|
||||
[]string{"只要查看摘要或逐字稿时不要下载媒体", "媒体未就绪、已过期或无痕模式时会明确失败,不返回空成功"},
|
||||
[]string{`dws minutes +download --id <taskUuid> --output ./meeting-media`, `dws minutes +download --ids <uuid1,uuid2> --output-dir ./minutes-downloads`}),
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "id", Type: shortcut.FlagString, Desc: "单个 taskUuid"},
|
||||
{Name: "ids", Type: shortcut.FlagStringSlice, Desc: "多个 taskUuid,最多 50 个"},
|
||||
{Name: "url-only", Type: shortcut.FlagBool, Desc: "只返回临时签名 URL,不下载"},
|
||||
{Name: "output", Type: shortcut.FlagString, Desc: "单个目标的工作目录内相对输出路径"},
|
||||
{Name: "output-dir", Type: shortcut.FlagString, Default: "minutes-downloads", Desc: "批量下载目录"},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{
|
||||
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"id", "ids"}},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"id", "ids"}, Description: "去重后 taskUuid 数量必须为 1..50"},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"ids", "output"}, Description: "批量下载不能使用 --output"},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"output", "output-dir"}, Description: "输出必须是安全的工作目录相对路径"},
|
||||
},
|
||||
Tips: []string{`dws minutes +download --id <taskUuid> --output ./meeting-media`, `dws minutes +download --ids <uuid1,uuid2> --output-dir ./minutes-downloads`},
|
||||
Validate: func(rt *shortcut.RuntimeContext) error {
|
||||
ids := minutesIDs(rt)
|
||||
if len(ids) == 0 || len(ids) > 50 {
|
||||
return apperrors.NewValidation("taskUuid 数量必须为 1..50")
|
||||
}
|
||||
if len(ids) > 1 && rt.Changed("output") {
|
||||
return apperrors.NewValidation("批量下载不能使用 --output,请使用 --output-dir")
|
||||
}
|
||||
if rt.Changed("output") {
|
||||
return localio.ValidateOutput(rt.Str("output"))
|
||||
}
|
||||
return localio.ValidateOutput(rt.Str("output-dir"))
|
||||
},
|
||||
Execute: executeMinutesDownload,
|
||||
}
|
||||
|
||||
var Upload = shortcut.Shortcut{
|
||||
Service: "minutes", Command: "+upload", Product: "minutes",
|
||||
Description: "把本地音视频完整上传并创建听记,失败时取消会话",
|
||||
Intent: "需要从本地音视频直接创建听记时使用;完成 create、预签名 PUT、complete 和最终详情读回,不需要中转 Drive。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"},
|
||||
Contract: withMinutesDryRun(minutesContract("+upload", "把本地音视频完整上传并创建听记,失败时取消会话",
|
||||
"用户有本地音视频,希望直接上传生成听记并取得可读回的 taskUuid 时使用",
|
||||
[]string{"只需要管理已有 upload session 时使用原子 upload create/complete/cancel", "文件为空、过大或不希望创建远端听记时不要执行"},
|
||||
[]string{`dws minutes +upload --file ./meeting.mp3 --title "项目周会"`, `dws minutes +upload --file ./meeting.mp4 --input-language zh`}), contract.DryRunPreviewPlan, false),
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "file", Type: shortcut.FlagString, Desc: "本地音视频文件", Required: true},
|
||||
{Name: "title", Type: shortcut.FlagString, Desc: "听记标题"},
|
||||
{Name: "template-id", Type: shortcut.FlagString, Desc: "纪要模板 ID"},
|
||||
{Name: "input-language", Type: shortcut.FlagString, Desc: "ASR 输入语言"},
|
||||
{Name: "enable-message-card", Type: shortcut.FlagBool, Desc: "生成后推送闪记卡片"},
|
||||
{Name: "complete-timeout", Type: shortcut.FlagInt, Default: "90", Desc: "等待服务端确认上传完成的秒数"},
|
||||
{Name: "poll-interval", Type: shortcut.FlagInt, Default: "2", Desc: "complete 重试间隔秒数"},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"complete-timeout", "poll-interval"}, Description: "--complete-timeout 和 --poll-interval 必须大于 0"}},
|
||||
Tips: []string{`dws minutes +upload --file ./meeting.mp3 --title "项目周会"`, `dws minutes +upload --file ./meeting.mp4 --input-language zh`},
|
||||
Validate: func(rt *shortcut.RuntimeContext) error {
|
||||
if rt.Int("complete-timeout") <= 0 || rt.Int("poll-interval") <= 0 {
|
||||
return apperrors.NewValidation("--complete-timeout 和 --poll-interval 必须大于 0")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Execute: executeMinutesUpload,
|
||||
}
|
||||
|
||||
var Update = shortcut.Shortcut{
|
||||
Service: "minutes", Command: "+update", Product: "minutes",
|
||||
Description: "读取现状、预览差异、更新听记标题并读回验证",
|
||||
Intent: "已知 taskUuid,需要安全重命名听记标题并确认远端最终值时使用。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "idempotent"},
|
||||
Contract: withMinutesDryRun(minutesContract("+update", "读取现状、更新听记标题并读回验证;dry-run 输出不访问远端的执行计划",
|
||||
"需要修改一条听记的标题,并通过最终读回证明修改生效时使用",
|
||||
[]string{"要修改纪要正文时使用 +summary"},
|
||||
[]string{`dws minutes +update --id <taskUuid> --title "Q3 项目复盘"`}), contract.DryRunPreviewPlan, false),
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "id", Type: shortcut.FlagString, Desc: "听记 taskUuid", Required: true},
|
||||
{Name: "title", Type: shortcut.FlagString, Desc: "新标题", Required: true},
|
||||
},
|
||||
Tips: []string{`dws minutes +update --id <taskUuid> --title "Q3 项目复盘"`},
|
||||
Execute: executeMinutesUpdate,
|
||||
}
|
||||
|
||||
var ApplyPermission = shortcut.Shortcut{
|
||||
Service: "minutes", Command: "+apply-permission", Product: "minutes",
|
||||
Description: "为当前用户申请语义化的听记访问权限",
|
||||
Intent: "打开听记无权限时,用 view/download/edit 语义申请对应权限,不需要记 policy 数字。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"},
|
||||
Contract: withMinutesDryRun(minutesContract("+apply-permission", "为当前用户申请语义化的听记访问权限",
|
||||
"当前用户访问某条听记被拒绝,需要向所有者申请查看、下载或编辑权限时使用",
|
||||
[]string{"所有者给其他成员授权时使用 +share", "要移除权限时使用 +unshare"},
|
||||
[]string{`dws minutes +apply-permission --id <taskUuid> --permission view`, `dws minutes +apply-permission --id <taskUuid> --permission edit`}), contract.DryRunPreviewPlan, false),
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "id", Type: shortcut.FlagString, Desc: "听记 taskUuid", Required: true},
|
||||
{Name: "permission", Type: shortcut.FlagString, Desc: "申请权限", Required: true, Enum: []string{"view", "download", "edit"}},
|
||||
},
|
||||
Tips: []string{`dws minutes +apply-permission --id <taskUuid> --permission view`, `dws minutes +apply-permission --id <taskUuid> --permission edit`},
|
||||
Execute: executeMinutesApplyPermission,
|
||||
}
|
||||
|
||||
var Summary = shortcut.Shortcut{
|
||||
Service: "minutes", Command: "+summary", Product: "minutes",
|
||||
Description: "读取当前纪要、校验图片引用、全量覆盖并读回验证",
|
||||
Intent: "需要安全编辑听记纪要正文时使用;支持字面量、@file 和 stdin,保护现有 Markdown 图片并验证最终全文。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "idempotent"},
|
||||
Contract: withMinutesDryRun(minutesContract("+summary", "校验本地纪要输入、全量覆盖并读回验证;dry-run 不访问远端",
|
||||
"需要用确认后的完整 Markdown 覆盖一条听记纪要,并保护已有图片引用时使用",
|
||||
[]string{"只改标题时使用 +update", "没有完整目标内容时不要执行全量覆盖"},
|
||||
[]string{`dws minutes +summary --id <taskUuid> --content "项目周会纪要"`}), contract.DryRunPreviewPlan, false),
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "id", Type: shortcut.FlagString, Desc: "听记 taskUuid", Required: true},
|
||||
{Name: "content", Type: shortcut.FlagString, Desc: "完整纪要字面量、@相对文件或 - 表示 stdin", Required: true},
|
||||
},
|
||||
Tips: []string{`dws minutes +summary --id <taskUuid> --content @summary.md`, `dws minutes +summary --id <taskUuid> --content -`},
|
||||
Execute: executeMinutesSummary,
|
||||
}
|
||||
|
||||
var SpeakerReplace = shortcut.Shortcut{
|
||||
Service: "minutes", Command: "+speaker-replace", Product: "minutes",
|
||||
Description: "预检逐字稿中的发言人昵称,替换后重新读回验证",
|
||||
Intent: "需要把听记中的一个发言人昵称替换为目标昵称/UID 时使用;这是昵称替换,不是 Lark speaker_id 身份重绑。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "idempotent"},
|
||||
Contract: withMinutesDryRun(minutesContract("+speaker-replace", "预检逐字稿中的发言人昵称,替换后重新读回验证",
|
||||
"逐字稿已经生成且用户明确要把一个源发言人昵称批量替换为目标昵称时使用",
|
||||
[]string{"DWS 不是 speaker_id 到用户 open_id 的身份重绑;需要该语义时当前无法对齐", "源昵称不存在或目标身份不确定时不要写"},
|
||||
[]string{`dws minutes +speaker-replace --id <taskUuid> --from "发言人1" --to "张三"`}), contract.DryRunPreviewPlan, false),
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "id", Type: shortcut.FlagString, Desc: "听记 taskUuid", Required: true},
|
||||
{Name: "from", Type: shortcut.FlagString, Desc: "源发言人昵称", Required: true},
|
||||
{Name: "to", Type: shortcut.FlagString, Desc: "目标发言人昵称", Required: true},
|
||||
{Name: "target-uid", Type: shortcut.FlagString, Desc: "目标钉钉 UID"},
|
||||
{Name: "page-limit", Type: shortcut.FlagInt, Default: "100", Desc: "逐字稿预检翻页上限"},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"from", "to"}, Description: "--from/--to 去空白后不能相同"},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-limit"}, Description: "--page-limit 必须大于 0"},
|
||||
},
|
||||
Tips: []string{`dws minutes +speaker-replace --id <taskUuid> --from "发言人1" --to "张三"`},
|
||||
Validate: func(rt *shortcut.RuntimeContext) error {
|
||||
if strings.TrimSpace(rt.Str("from")) == strings.TrimSpace(rt.Str("to")) {
|
||||
return apperrors.NewValidation("--from 与 --to 不能相同")
|
||||
}
|
||||
if rt.Int("page-limit") <= 0 {
|
||||
return apperrors.NewValidation("--page-limit 必须大于 0")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Execute: executeMinutesSpeakerReplace,
|
||||
}
|
||||
|
||||
func executeMinutesSearch(rt *shortcut.RuntimeContext) error {
|
||||
start, end, err := minutesTimeRange(rt.Str("start"), rt.Str("end"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
belonging := map[string]string{"mine": "createdByMe", "shared": "sharedToMe", "all": "noLimit"}[rt.Str("scope")]
|
||||
token := rt.Str("cursor")
|
||||
seenTokens := map[string]bool{}
|
||||
seenIDs := map[string]bool{}
|
||||
rows := make([]map[string]any, 0)
|
||||
pages := 0
|
||||
complete := false
|
||||
nextToken := ""
|
||||
for {
|
||||
if seenTokens[token] {
|
||||
return fmt.Errorf("minutes search cursor stalled or cycled")
|
||||
}
|
||||
seenTokens[token] = true
|
||||
params := map[string]any{"belongingConditionId": belonging, "maxResults": rt.Int("limit")}
|
||||
if query := strings.TrimSpace(rt.Str("query")); query != "" {
|
||||
params["keyword"] = query
|
||||
}
|
||||
if start != 0 {
|
||||
params["createTimeStart"] = float64(start)
|
||||
}
|
||||
if end != 0 {
|
||||
params["createTimeEnd"] = float64(end)
|
||||
}
|
||||
if token != "" {
|
||||
params["nextToken"] = token
|
||||
}
|
||||
data, err := rt.CallMCPData("minutes", "list_by_keyword_and_time_range", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
page, err := minutesdata.ParseListPage(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
projected, err := minutesdata.ProjectList(page)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, row := range projected {
|
||||
id := row["taskUuid"].(string)
|
||||
if seenIDs[id] {
|
||||
continue
|
||||
}
|
||||
seenIDs[id] = true
|
||||
rows = append(rows, row)
|
||||
}
|
||||
pages++
|
||||
nextToken = page.NextToken
|
||||
if !page.HasMore {
|
||||
complete = true
|
||||
nextToken = ""
|
||||
break
|
||||
}
|
||||
if !rt.Bool("page-all") {
|
||||
break
|
||||
}
|
||||
if pages >= rt.Int("page-limit") {
|
||||
return fmt.Errorf("minutes search exceeded page safety limit %d", rt.Int("page-limit"))
|
||||
}
|
||||
token = page.NextToken
|
||||
}
|
||||
scanned := len(rows)
|
||||
if query := strings.TrimSpace(rt.Str("query")); query != "" {
|
||||
needle := strings.ToLower(query)
|
||||
filtered := make([]map[string]any, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
title, _ := row["title"].(string)
|
||||
if strings.Contains(strings.ToLower(title), needle) {
|
||||
filtered = append(filtered, row)
|
||||
}
|
||||
}
|
||||
rows = filtered
|
||||
}
|
||||
payload := map[string]any{"count": len(rows), "scannedCount": scanned, "minutes": rows, "pages": pages, "complete": complete}
|
||||
if nextToken != "" {
|
||||
payload["nextToken"] = nextToken
|
||||
}
|
||||
return rt.Output(payload)
|
||||
}
|
||||
|
||||
func executeMinutesDownload(rt *shortcut.RuntimeContext) error {
|
||||
ids := minutesIDs(rt)
|
||||
results := make([]map[string]any, 0, len(ids))
|
||||
failures := make([]map[string]any, 0)
|
||||
for _, id := range ids {
|
||||
data, err := rt.CallMCPData("minutes", "query_minutes_audio_url", map[string]any{"taskUuid": id})
|
||||
if err != nil {
|
||||
failures = append(failures, map[string]any{"taskUuid": id, "error": err.Error()})
|
||||
continue
|
||||
}
|
||||
mediaURL, err := minutesdata.MediaURL(data)
|
||||
if err != nil {
|
||||
failures = append(failures, map[string]any{"taskUuid": id, "error": err.Error()})
|
||||
continue
|
||||
}
|
||||
if rt.Bool("url-only") {
|
||||
results = append(results, map[string]any{"taskUuid": id, "url": mediaURL})
|
||||
continue
|
||||
}
|
||||
output := rt.Str("output-dir") + "/"
|
||||
if len(ids) == 1 && rt.Changed("output") {
|
||||
output = rt.Str("output")
|
||||
}
|
||||
preferred := id + mediaExtension(mediaURL)
|
||||
download, err := minutesDownload(rt.Command().Context(), mediaURL, localio.DownloadOptions{Output: output, PreferredName: preferred})
|
||||
if err != nil {
|
||||
failures = append(failures, map[string]any{"taskUuid": id, "error": err.Error()})
|
||||
continue
|
||||
}
|
||||
results = append(results, map[string]any{"taskUuid": id, "path": download.RelativePath, "sizeBytes": download.SizeBytes})
|
||||
}
|
||||
payload := map[string]any{"ok": len(failures) == 0, "requested": len(ids), "succeeded": len(results), "failed": len(failures), "results": results}
|
||||
if len(failures) > 0 {
|
||||
payload["failures"] = failures
|
||||
}
|
||||
if err := rt.Output(payload); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(failures) > 0 {
|
||||
return minutesCompositeError("minutes_download_incomplete", "download", payload)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func executeMinutesUpload(rt *shortcut.RuntimeContext) error {
|
||||
payload, err := performMinutesUpload(rt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(payload)
|
||||
}
|
||||
|
||||
func performMinutesUpload(rt *shortcut.RuntimeContext) (map[string]any, error) {
|
||||
info, err := os.Stat(rt.Str("file"))
|
||||
if err != nil || !info.Mode().IsRegular() || info.Size() <= 0 {
|
||||
return nil, apperrors.NewValidation("--file 必须是非空普通文件")
|
||||
}
|
||||
plan := map[string]any{"operation": "minutes.upload", "fileName": info.Name(), "sizeBytes": info.Size(), "title": rt.Str("title")}
|
||||
if rt.DryRun() {
|
||||
return minutesDryRunPayload(contract.DryRunPreviewPlan, "minutes.upload", plan), nil
|
||||
}
|
||||
params := map[string]any{"fileName": info.Name(), "fileSize": info.Size()}
|
||||
if value := strings.TrimSpace(rt.Str("title")); value != "" {
|
||||
params["title"] = value
|
||||
}
|
||||
option := map[string]any{}
|
||||
if value := strings.TrimSpace(rt.Str("template-id")); value != "" {
|
||||
option["templateId"] = value
|
||||
}
|
||||
if value := strings.TrimSpace(rt.Str("input-language")); value != "" {
|
||||
option["inputLanguage"] = value
|
||||
}
|
||||
if rt.Changed("enable-message-card") {
|
||||
option["enableMessageCard"] = rt.Bool("enable-message-card")
|
||||
}
|
||||
if len(option) > 0 {
|
||||
params["minutesOption"] = option
|
||||
}
|
||||
created, err := rt.CallMCPWriteDataStrict("minutes", "create_upload_session", params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sessionID, presignedURL, err := minutesdata.UploadSession(created)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
transfer, err := minutesPutFile(rt.Command().Context(), presignedURL, rt.Str("file"), 0)
|
||||
if err != nil {
|
||||
cancelled, cancelErr := cancelMinutesUpload(rt, sessionID)
|
||||
return nil, minutesUploadFailure("put", sessionID, cancelled, cancelErr, err)
|
||||
}
|
||||
completed, completeAttempts, err := completeMinutesUpload(rt, sessionID, time.Duration(rt.Int("complete-timeout"))*time.Second, time.Duration(rt.Int("poll-interval"))*time.Second)
|
||||
if err != nil {
|
||||
return nil, minutesUploadCompletionUnknown(sessionID, completeAttempts, err)
|
||||
}
|
||||
taskUUID, err := minutesdata.CompletedTaskUUID(completed)
|
||||
if err != nil {
|
||||
return nil, minutesUploadFailure("complete_response", sessionID, false, nil, err)
|
||||
}
|
||||
readBack, err := rt.CallMCPData("minutes", "get_minutes_basic_info", map[string]any{"taskUuid": taskUUID})
|
||||
if err != nil {
|
||||
return nil, minutesUploadFailure("verify", sessionID, false, nil, err)
|
||||
}
|
||||
if _, err := minutesdata.Basic(taskUUID, readBack); err != nil {
|
||||
return nil, minutesUploadFailure("verify", sessionID, false, nil, err)
|
||||
}
|
||||
return map[string]any{"operation": "minutes.upload", "complete": true, "sessionId": sessionID, "taskUuid": taskUUID, "sizeBytes": transfer.SizeBytes, "uploadAttempts": transfer.Attempts, "completeAttempts": completeAttempts, "verified": true}, nil
|
||||
}
|
||||
|
||||
func executeMinutesUpdate(rt *shortcut.RuntimeContext) error {
|
||||
id, title := rt.Str("id"), strings.TrimSpace(rt.Str("title"))
|
||||
if rt.DryRun() {
|
||||
return rt.Output(minutesDryRunPayload(contract.DryRunPreviewPlan, "minutes.update", map[string]any{
|
||||
"taskUuid": id, "after": title, "remotePreconditions": []string{"read current title", "write title", "read back exact title"},
|
||||
}))
|
||||
}
|
||||
beforeData, err := rt.CallMCPData("minutes", "get_minutes_basic_info", map[string]any{"taskUuid": id})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
before, err := minutesdata.Basic(id, beforeData)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
oldTitle, _ := before["title"].(string)
|
||||
if oldTitle == title {
|
||||
return rt.Output(map[string]any{"taskUuid": id, "changed": false, "verified": true, "title": title})
|
||||
}
|
||||
if _, err := rt.CallMCPWriteDataStrict("minutes", "update_minutes_title", map[string]any{"taskUuid": id, "title": title}); err != nil {
|
||||
return err
|
||||
}
|
||||
afterData, err := rt.CallMCPData("minutes", "get_minutes_basic_info", map[string]any{"taskUuid": id})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
after, err := minutesdata.Basic(id, afterData)
|
||||
if err != nil || after["title"] != title {
|
||||
return minutesCompositeError("minutes_title_verification_failed", "verify", map[string]any{"taskUuid": id, "expectedTitle": title})
|
||||
}
|
||||
return rt.Output(map[string]any{"taskUuid": id, "changed": true, "verified": true, "before": oldTitle, "after": title})
|
||||
}
|
||||
|
||||
func executeMinutesApplyPermission(rt *shortcut.RuntimeContext) error {
|
||||
policy := map[string]float64{"edit": 2, "download": 3, "view": 4}[rt.Str("permission")]
|
||||
plan := map[string]any{"taskUuid": rt.Str("id"), "permission": rt.Str("permission"), "policyId": policy}
|
||||
if rt.DryRun() {
|
||||
return rt.Output(minutesDryRunPayload(contract.DryRunPreviewPlan, "minutes.apply_permission", plan))
|
||||
}
|
||||
data, err := rt.CallMCPWriteDataStrict("minutes", "apply_minutes_permission", map[string]any{"taskUuid": rt.Str("id"), "policyId": policy})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := minutesdata.RequireWriteAcknowledgement("apply permission", data); err != nil {
|
||||
return err
|
||||
}
|
||||
plan["requested"], plan["result"] = true, data["result"]
|
||||
return rt.Output(plan)
|
||||
}
|
||||
|
||||
func executeMinutesSummary(rt *shortcut.RuntimeContext) error {
|
||||
id := rt.Str("id")
|
||||
after, err := localio.ReadTextInput(rt.Str("content"), rt.Command().InOrStdin(), 8<<20)
|
||||
if err != nil {
|
||||
return apperrors.NewValidation(err.Error())
|
||||
}
|
||||
if strings.TrimSpace(after) == "" {
|
||||
return apperrors.NewValidation("纪要内容不能为空")
|
||||
}
|
||||
if rt.DryRun() {
|
||||
return rt.Output(minutesDryRunPayload(contract.DryRunPreviewPlan, "minutes.summary", map[string]any{
|
||||
"taskUuid": id, "afterBytes": len(after), "remotePreconditions": []string{"read current summary", "preserve existing Markdown images", "write summary", "read back exact summary"},
|
||||
}))
|
||||
}
|
||||
beforeData, err := rt.CallMCPData("minutes", "get_minutes_ai_summary", map[string]any{"taskUuid": id})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
before, err := minutesdata.SummaryText(beforeData)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if missing := missingMarkdownImages(before, after); len(missing) > 0 {
|
||||
return apperrors.NewValidation(fmt.Sprintf("新纪要丢失 %d 个原有 Markdown 图片引用", len(missing)))
|
||||
}
|
||||
if before == after {
|
||||
return rt.Output(map[string]any{"taskUuid": id, "changed": false, "verified": true})
|
||||
}
|
||||
if _, err := rt.CallMCPWriteDataStrict("minutes", "update_minutes_summary", map[string]any{"taskUuid": id, "summaryText": after}); err != nil {
|
||||
return err
|
||||
}
|
||||
readBackData, err := rt.CallMCPData("minutes", "get_minutes_ai_summary", map[string]any{"taskUuid": id})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
readBack, err := minutesdata.SummaryText(readBackData)
|
||||
if err != nil || readBack != after {
|
||||
return minutesCompositeError("minutes_summary_verification_failed", "verify", map[string]any{"taskUuid": id, "expectedBytes": len(after)})
|
||||
}
|
||||
return rt.Output(map[string]any{"taskUuid": id, "changed": true, "verified": true, "beforeBytes": len(before), "afterBytes": len(after), "preservedImages": true})
|
||||
}
|
||||
|
||||
func executeMinutesSpeakerReplace(rt *shortcut.RuntimeContext) error {
|
||||
id, from, to := rt.Str("id"), rt.Str("from"), rt.Str("to")
|
||||
if rt.DryRun() {
|
||||
return rt.Output(minutesDryRunPayload(contract.DryRunPreviewPlan, "minutes.speaker_replace", map[string]any{
|
||||
"taskUuid": id, "from": from, "to": to, "targetUid": strings.TrimSpace(rt.Str("target-uid")),
|
||||
"remotePreconditions": []string{"read full transcript", "verify source speaker exists", "replace speaker", "read back full transcript"},
|
||||
}))
|
||||
}
|
||||
before, err := collectTranscriptForMinutes(rt, id, rt.Int("page-limit"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
beforeSpeakers := speakerCounts(before.Paragraphs)
|
||||
if beforeSpeakers[from] == 0 {
|
||||
return apperrors.NewValidation(fmt.Sprintf("逐字稿中不存在发言人 %q", from))
|
||||
}
|
||||
params := map[string]any{"taskUuid": id, "speakerNick": from, "targetNickName": to}
|
||||
if uid := strings.TrimSpace(rt.Str("target-uid")); uid != "" {
|
||||
params["targetUid"] = uid
|
||||
}
|
||||
if _, err := rt.CallMCPWriteDataStrict("minutes", "replace_speaker", params); err != nil {
|
||||
return err
|
||||
}
|
||||
after, err := collectTranscriptForMinutes(rt, id, rt.Int("page-limit"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
afterSpeakers := speakerCounts(after.Paragraphs)
|
||||
if afterSpeakers[from] != 0 || afterSpeakers[to] < beforeSpeakers[from] {
|
||||
return minutesCompositeError("minutes_speaker_verification_failed", "verify", map[string]any{"taskUuid": id, "fromRemaining": afterSpeakers[from], "targetCount": afterSpeakers[to]})
|
||||
}
|
||||
return rt.Output(map[string]any{"taskUuid": id, "changed": true, "verified": true, "from": from, "to": to, "affectedParagraphs": beforeSpeakers[from]})
|
||||
}
|
||||
|
||||
func minutesTimeRange(startRaw, endRaw string) (int64, int64, error) {
|
||||
parse := func(name, raw string) (int64, error) {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return 0, nil
|
||||
}
|
||||
value, err := time.Parse(time.RFC3339, raw)
|
||||
if err != nil {
|
||||
return 0, apperrors.NewValidation(fmt.Sprintf("--%s 必须是 RFC3339 时间: %v", name, err))
|
||||
}
|
||||
return value.UnixMilli(), nil
|
||||
}
|
||||
start, err := parse("start", startRaw)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
end, err := parse("end", endRaw)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
if start != 0 && end != 0 && start > end {
|
||||
return 0, 0, apperrors.NewValidation("--start 不能晚于 --end")
|
||||
}
|
||||
return start, end, nil
|
||||
}
|
||||
|
||||
func minutesIDs(rt *shortcut.RuntimeContext) []string {
|
||||
values := []string{}
|
||||
if id := strings.TrimSpace(rt.Str("id")); id != "" {
|
||||
values = append(values, id)
|
||||
}
|
||||
values = append(values, rt.StrSlice("ids")...)
|
||||
seen := map[string]bool{}
|
||||
out := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
for _, item := range strings.Split(value, ",") {
|
||||
item = strings.TrimSpace(item)
|
||||
if item != "" && !seen[item] {
|
||||
seen[item] = true
|
||||
out = append(out, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func mediaExtension(rawURL string) string {
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(parsed.Path))
|
||||
if len(ext) > 10 {
|
||||
return ""
|
||||
}
|
||||
return ext
|
||||
}
|
||||
|
||||
func cancelMinutesUpload(rt *shortcut.RuntimeContext, sessionID string) (bool, error) {
|
||||
data, err := rt.CallMCPWriteDataStrict("minutes", "cancel_upload_session", map[string]any{"sessionId": sessionID})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := minutesdata.RequireWriteAcknowledgement("cancel upload", data); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func completeMinutesUpload(rt *shortcut.RuntimeContext, sessionID string, timeout, interval time.Duration) (map[string]any, int, error) {
|
||||
deadline := time.Now().Add(timeout)
|
||||
attempts := 0
|
||||
for {
|
||||
attempts++
|
||||
data, err := rt.CallMCPWriteDataStrict("minutes", "complete_upload_session", map[string]any{"sessionId": sessionID})
|
||||
if err == nil {
|
||||
return data, attempts, nil
|
||||
}
|
||||
message := strings.ToLower(err.Error())
|
||||
retryable := strings.Contains(message, "still uploading") || strings.Contains(message, "wait and retry") || strings.Contains(message, "正在上传")
|
||||
if !retryable || time.Now().Add(interval).After(deadline) {
|
||||
return nil, attempts, err
|
||||
}
|
||||
timer := time.NewTimer(interval)
|
||||
select {
|
||||
case <-rt.Command().Context().Done():
|
||||
timer.Stop()
|
||||
return nil, attempts, rt.Command().Context().Err()
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func minutesUploadFailure(stage, sessionID string, cancelled bool, cancelErr, cause error) error {
|
||||
details := map[string]any{"stage": stage, "sessionId": sessionID, "cancelled": cancelled}
|
||||
if cause != nil {
|
||||
details["cause"] = cause.Error()
|
||||
}
|
||||
if cancelErr != nil {
|
||||
details["cancelError"] = cancelErr.Error()
|
||||
}
|
||||
return apperrors.NewAPI(
|
||||
fmt.Sprintf("听记上传在 %s 阶段失败", stage),
|
||||
apperrors.WithOperation("minutes/+upload"),
|
||||
apperrors.WithOrigin("shortcut"),
|
||||
apperrors.WithFailureStage(stage),
|
||||
apperrors.WithReason("minutes_upload_failed"),
|
||||
apperrors.WithExecutionStarted(true),
|
||||
apperrors.WithRetryable(false),
|
||||
apperrors.WithDetails(details),
|
||||
)
|
||||
}
|
||||
|
||||
func minutesUploadCompletionUnknown(sessionID string, attempts int, cause error) error {
|
||||
details := map[string]any{
|
||||
"stage": "complete",
|
||||
"sessionId": sessionID,
|
||||
"completeAttempts": attempts,
|
||||
"cancelled": false,
|
||||
"remoteEffect": "unknown",
|
||||
"recovery": map[string]any{
|
||||
"action": "verify_before_cancel",
|
||||
"nextAction": "保留 sessionId;先重试 complete_upload_session 或查询最近听记确认服务端状态,只有明确证明未完成时才取消会话",
|
||||
},
|
||||
}
|
||||
if cause != nil {
|
||||
details["cause"] = cause.Error()
|
||||
}
|
||||
return apperrors.NewAPI(
|
||||
"听记上传 complete 请求的远端结果未知;为避免破坏可能已完成的上传,已保留会话且未自动取消",
|
||||
apperrors.WithOperation("minutes/+upload"),
|
||||
apperrors.WithOrigin("shortcut"),
|
||||
apperrors.WithFailureStage("complete"),
|
||||
apperrors.WithReason("minutes_upload_completion_unknown"),
|
||||
apperrors.WithExecutionStarted(true),
|
||||
apperrors.WithRetryable(false),
|
||||
apperrors.WithHint("不要重新上传或直接取消;先使用错误详情中的 sessionId 核验服务端状态"),
|
||||
apperrors.WithActions("retry complete_upload_session with the preserved sessionId", "query recent Minutes before cancelling the upload session"),
|
||||
apperrors.WithDetails(details),
|
||||
)
|
||||
}
|
||||
|
||||
func minutesCompositeError(reason, stage string, details map[string]any) error {
|
||||
return apperrors.NewAPI(
|
||||
"Minutes shortcut 未完成,详见结构化错误",
|
||||
apperrors.WithOperation("minutes/shortcut"),
|
||||
apperrors.WithOrigin("shortcut"),
|
||||
apperrors.WithFailureStage(stage),
|
||||
apperrors.WithReason(reason),
|
||||
apperrors.WithRetryable(false),
|
||||
apperrors.WithDetails(details),
|
||||
)
|
||||
}
|
||||
|
||||
var markdownImageRE = regexp.MustCompile(`!\[[^\]]*\]\(([^)]+)\)`)
|
||||
|
||||
func missingMarkdownImages(before, after string) []string {
|
||||
remaining := map[string]bool{}
|
||||
for _, match := range markdownImageRE.FindAllStringSubmatch(after, -1) {
|
||||
remaining[match[1]] = true
|
||||
}
|
||||
missing := []string{}
|
||||
for _, match := range markdownImageRE.FindAllStringSubmatch(before, -1) {
|
||||
if !remaining[match[1]] {
|
||||
missing = append(missing, match[1])
|
||||
}
|
||||
}
|
||||
sort.Strings(missing)
|
||||
return missing
|
||||
}
|
||||
|
||||
func collectTranscriptForMinutes(rt *shortcut.RuntimeContext, taskUUID string, pageLimit int) (minutesdata.TranscriptResult, error) {
|
||||
return minutesdata.CollectTranscript(func(nextToken string) (map[string]any, error) {
|
||||
params := map[string]any{"taskUuid": taskUUID, "direction": "0"}
|
||||
if nextToken != "" {
|
||||
params["nextToken"] = nextToken
|
||||
}
|
||||
return rt.CallMCPData("minutes", "get_minutes_transcription", params)
|
||||
}, "", false, pageLimit)
|
||||
}
|
||||
|
||||
func speakerCounts(paragraphs []map[string]any) map[string]int {
|
||||
counts := map[string]int{}
|
||||
var visit func(any, map[string]bool)
|
||||
visit = func(value any, seen map[string]bool) {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
local := map[string]bool{}
|
||||
for _, key := range []string{"speakerNick", "nickName", "nickname", "subSpeakerNickname"} {
|
||||
if name, ok := typed[key].(string); ok && strings.TrimSpace(name) != "" {
|
||||
local[strings.TrimSpace(name)] = true
|
||||
}
|
||||
}
|
||||
for name := range local {
|
||||
seen[name] = true
|
||||
}
|
||||
for _, child := range typed {
|
||||
visit(child, seen)
|
||||
}
|
||||
case []any:
|
||||
for _, child := range typed {
|
||||
visit(child, seen)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, paragraph := range paragraphs {
|
||||
seen := map[string]bool{}
|
||||
visit(paragraph, seen)
|
||||
for name := range seen {
|
||||
counts[name]++
|
||||
}
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
func init() {
|
||||
shortcut.Register(finalizeMinutesShortcuts(Search, Download, Upload, Update, ApplyPermission, Summary, SpeakerReplace)...)
|
||||
}
|
||||
@@ -0,0 +1,517 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package minutes
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/localio"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type minutesFailWriter struct{}
|
||||
|
||||
func (minutesFailWriter) Write([]byte) (int, error) { return 0, errors.New("fixture output failure") }
|
||||
|
||||
func runMinutesAlignmentCLIWithWriter(t *testing.T, caller *minutesE2ECaller, writer io.Writer, args ...string) error {
|
||||
t.Helper()
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||
root.PersistentFlags().Bool("yes", false, "")
|
||||
root.PersistentFlags().Bool("dry-run", false, "")
|
||||
root.PersistentFlags().String("format", "json", "")
|
||||
root.AddCommand(shortcut.Commands()...)
|
||||
root.SetOut(writer)
|
||||
root.SetErr(&bytes.Buffer{})
|
||||
root.SetArgs(args)
|
||||
return root.Execute()
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesAlignmentValidationAndHelpers(t *testing.T) {
|
||||
caller := &minutesE2ECaller{}
|
||||
manyIDs := make([]string, 51)
|
||||
for index := range manyIDs {
|
||||
manyIDs[index] = fmt.Sprintf("u%d", index)
|
||||
}
|
||||
invalid := [][]string{
|
||||
{"minutes", "+search", "--query", "x", "--limit", "0"},
|
||||
{"minutes", "+search", "--query", "x", "--start", "bad"},
|
||||
{"minutes", "+search", "--query", "x", "--end", "bad"},
|
||||
{"minutes", "+search", "--query", "x", "--start", "2026-08-02T00:00:00Z", "--end", "2026-08-01T00:00:00Z"},
|
||||
{"minutes", "+download", "--id", "u1", "--ids", "u2"},
|
||||
{"minutes", "+download", "--ids", strings.Join(manyIDs, ",")},
|
||||
{"minutes", "+download", "--ids", "u1,u2", "--output", "one"},
|
||||
{"minutes", "+download", "--id", "u1", "--output", "../escape"},
|
||||
{"minutes", "+upload", "--file", "missing", "--complete-timeout", "0"},
|
||||
{"minutes", "+speaker-replace", "--id", "u1", "--from", "same", "--to", " same "},
|
||||
{"minutes", "+speaker-replace", "--id", "u1", "--from", "a", "--to", "b", "--page-limit", "0"},
|
||||
}
|
||||
for _, args := range invalid {
|
||||
if payload, output, err := runMinutesAlignmentCLI(t, caller, args...); err == nil || payload != nil || output != "" {
|
||||
t.Fatalf("invalid argv accepted: %v payload=%#v output=%q err=%v", args, payload, output, err)
|
||||
}
|
||||
}
|
||||
|
||||
if start, end, err := minutesTimeRange("2026-08-01T00:00:00Z", "2026-08-02T00:00:00Z"); err != nil || start == 0 || end <= start {
|
||||
t.Fatalf("valid range = %d %d %v", start, end, err)
|
||||
}
|
||||
if got := mediaExtension("https://example.invalid/a.MP3?token=x"); got != ".mp3" {
|
||||
t.Fatalf("extension = %q", got)
|
||||
}
|
||||
if got := mediaExtension("://bad"); got != "" {
|
||||
t.Fatalf("bad extension = %q", got)
|
||||
}
|
||||
if got := mediaExtension("https://example.invalid/a.12345678901"); got != "" {
|
||||
t.Fatalf("long extension = %q", got)
|
||||
}
|
||||
counts := speakerCounts([]map[string]any{{
|
||||
"speakerNick": " A ", "nested": []any{map[string]any{"nickName": "A", "subSpeakerNickname": "B"}},
|
||||
}})
|
||||
if counts["A"] != 1 || counts["B"] != 1 {
|
||||
t.Fatalf("speaker counts = %#v", counts)
|
||||
}
|
||||
|
||||
decl := corecmd.ContractDecl{Parameters: []contract.ParamDecl{
|
||||
{Name: "a", Description: "parameter"},
|
||||
{Name: "a", Description: "already evidence"},
|
||||
{Name: "other", Description: "other"},
|
||||
}}
|
||||
value := shortcut.Shortcut{Description: "desc", Intent: "intent", Contract: decl,
|
||||
Flags: []shortcut.Flag{{Name: "a", Desc: "flag"}},
|
||||
Constraints: []shortcut.Constraint{
|
||||
{Kind: shortcut.ConstraintAtLeastOne, Flags: []string{"a"}},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"a"}},
|
||||
},
|
||||
}
|
||||
finalized := finalizeMinutesShortcuts(value)[0]
|
||||
if finalized.Contract.Selection.AgentSummary != "desc" || strings.Contains(finalized.Flags[0].Desc, "约束") {
|
||||
t.Fatalf("finalized = %#v", finalized)
|
||||
}
|
||||
withEvidence := value
|
||||
withEvidence.Constraints = []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"a"}, Description: "evidence"}}
|
||||
withEvidence.Flags[0].Desc = "flag evidence"
|
||||
withEvidence.Contract.Parameters[1].Description = "already evidence"
|
||||
finalized = finalizeMinutesShortcuts(withEvidence)[0]
|
||||
if !strings.Contains(finalized.Contract.Parameters[0].Description, "约束:evidence") || finalized.Contract.Parameters[1].Description != "already evidence" || finalized.Contract.Parameters[2].Description != "other" {
|
||||
t.Fatalf("parameter evidence = %#v", finalized.Contract.Parameters)
|
||||
}
|
||||
payload := minutesDryRunPayload("plan", "op", nil)
|
||||
if payload["operation"] != "op" || payload["executed"] != false {
|
||||
t.Fatalf("dry-run payload = %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesSearchFailureAndCursorBranchesE2E(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
caller *minutesE2ECaller
|
||||
args []string
|
||||
wantError bool
|
||||
wantNext string
|
||||
}{
|
||||
{name: "call", caller: &minutesE2ECaller{failAt: map[string]int{"minutes/list_by_keyword_and_time_range": 1}}, args: []string{"minutes", "+search", "--query", "x"}, wantError: true},
|
||||
{name: "parse", caller: &minutesE2ECaller{responses: map[string][]string{"minutes/list_by_keyword_and_time_range": {`{"success":true,"result":{}}`}}}, args: []string{"minutes", "+search", "--query", "x"}, wantError: true},
|
||||
{name: "project", caller: &minutesE2ECaller{responses: map[string][]string{"minutes/list_by_keyword_and_time_range": {`{"success":true,"result":{"itemList":[{"title":"x"}],"hasNext":false}}`}}}, args: []string{"minutes", "+search", "--query", "x"}, wantError: true},
|
||||
{name: "single page", caller: &minutesE2ECaller{responses: map[string][]string{"minutes/list_by_keyword_and_time_range": {`{"success":true,"result":{"itemList":[{"uuid":"u1","title":"x"}],"hasNext":true,"nextToken":"n2"}}`}}}, args: []string{"minutes", "+search", "--start", "2026-08-01T00:00:00Z", "--end", "2026-08-02T00:00:00Z", "--cursor", "n1"}, wantNext: "n2"},
|
||||
{name: "limit", caller: &minutesE2ECaller{responses: map[string][]string{"minutes/list_by_keyword_and_time_range": {`{"success":true,"result":{"itemList":[],"hasNext":true,"nextToken":"n2"}}`}}}, args: []string{"minutes", "+search", "--query", "x", "--page-all", "--page-limit", "1"}, wantError: true},
|
||||
{name: "cycle", caller: &minutesE2ECaller{responses: map[string][]string{"minutes/list_by_keyword_and_time_range": {`{"success":true,"result":{"itemList":[],"hasNext":true,"nextToken":"n1"}}`}}}, args: []string{"minutes", "+search", "--query", "x", "--page-all", "--cursor", "n1"}, wantError: true},
|
||||
}
|
||||
for _, test := range cases {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
payload, _, err := runMinutesAlignmentCLI(t, test.caller, test.args...)
|
||||
if (err != nil) != test.wantError {
|
||||
t.Fatalf("payload=%#v err=%v", payload, err)
|
||||
}
|
||||
if test.wantNext != "" && payload["nextToken"] != test.wantNext {
|
||||
t.Fatalf("payload=%#v", payload)
|
||||
}
|
||||
})
|
||||
}
|
||||
if err := runMinutesAlignmentCLIWithWriter(t, &minutesE2ECaller{responses: map[string][]string{
|
||||
"minutes/list_by_keyword_and_time_range": {`{"success":true,"result":{"itemList":[],"hasNext":false}}`},
|
||||
}}, minutesFailWriter{}, "minutes", "+search", "--query", "x"); err == nil {
|
||||
t.Fatal("output failure accepted")
|
||||
}
|
||||
// Execute is defensive even when invoked without the Shortcut validator.
|
||||
cmd := &cobra.Command{Use: "search"}
|
||||
cmd.Flags().String("start", "bad", "")
|
||||
cmd.Flags().String("end", "", "")
|
||||
cmd.Flags().String("scope", "all", "")
|
||||
cmd.Flags().String("cursor", "", "")
|
||||
cmd.Flags().String("query", "x", "")
|
||||
cmd.Flags().Int("limit", 20, "")
|
||||
cmd.Flags().Int("page-limit", 100, "")
|
||||
cmd.Flags().Bool("page-all", false, "")
|
||||
if err := executeMinutesSearch(shortcut.RuntimeContextForTest(cmd, Search)); err == nil {
|
||||
t.Fatal("direct invalid time accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesDownloadBranchesE2E(t *testing.T) {
|
||||
testseam.Swap(t, &minutesDownload, func(_ context.Context, rawURL string, options localio.DownloadOptions) (localio.DownloadResult, error) {
|
||||
if strings.Contains(rawURL, "fail") {
|
||||
return localio.DownloadResult{}, errors.New("fixture download failure")
|
||||
}
|
||||
return localio.DownloadResult{RelativePath: filepath.ToSlash(filepath.Join(options.Output, options.PreferredName)), SizeBytes: 12}, nil
|
||||
})
|
||||
caller := &minutesE2ECaller{
|
||||
responses: map[string][]string{"minutes/query_minutes_audio_url": {
|
||||
`{"success":true,"result":{"downloadUrl":"https://example.invalid/u1.mp3"}}`,
|
||||
`{"success":true,"result":{"downloadUrl":"https://example.invalid/fail.mp4"}}`,
|
||||
}},
|
||||
failAt: map[string]int{"minutes/query_minutes_audio_url": 3},
|
||||
}
|
||||
payload, output, err := runMinutesAlignmentCLI(t, caller, "minutes", "+download", "--ids", "u1,u2,u3", "--output-dir", "media")
|
||||
if err == nil || output == "" || payload["succeeded"] != float64(1) || payload["failed"] != float64(2) {
|
||||
t.Fatalf("download payload=%#v err=%v", payload, err)
|
||||
}
|
||||
urlOnly := &minutesE2ECaller{responses: map[string][]string{"minutes/query_minutes_audio_url": {`{"success":true,"result":{"downloadUrl":"https://example.invalid/u1.mp3"}}`}}}
|
||||
if payload, _, err = runMinutesAlignmentCLI(t, urlOnly, "minutes", "+download", "--id", "u1", "--url-only"); err != nil || payload["succeeded"] != float64(1) {
|
||||
t.Fatalf("url-only payload=%#v err=%v", payload, err)
|
||||
}
|
||||
if err := runMinutesAlignmentCLIWithWriter(t, urlOnly, minutesFailWriter{}, "minutes", "+download", "--id", "u1", "--url-only"); err == nil {
|
||||
t.Fatal("download output failure accepted")
|
||||
}
|
||||
single := &minutesE2ECaller{responses: map[string][]string{"minutes/query_minutes_audio_url": {`{"success":true,"result":{"downloadUrl":"https://example.invalid/u1.mp3"}}`}}}
|
||||
if payload, _, err = runMinutesAlignmentCLI(t, single, "minutes", "+download", "--id", "u1", "--output", "single.mp3"); err != nil || payload["succeeded"] != float64(1) {
|
||||
t.Fatalf("single payload=%#v err=%v", payload, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesUploadFailureAndSuccessBranchesE2E(t *testing.T) {
|
||||
file := filepath.Join(t.TempDir(), "source.wav")
|
||||
if err := os.WriteFile(file, []byte("audio"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload, output, err := runMinutesAlignmentCLI(t, &minutesE2ECaller{}, "minutes", "+upload", "--file", t.TempDir(), "--yes"); err == nil || payload != nil || output != "" {
|
||||
t.Fatalf("directory upload accepted: %#v %q %v", payload, output, err)
|
||||
}
|
||||
|
||||
createFail := &minutesE2ECaller{failAt: map[string]int{"minutes/create_upload_session": 1}}
|
||||
if _, _, err := runMinutesAlignmentCLI(t, createFail, "minutes", "+upload", "--file", file, "--yes"); err == nil {
|
||||
t.Fatal("create failure accepted")
|
||||
}
|
||||
badSession := &minutesE2ECaller{responses: map[string][]string{"minutes/create_upload_session": {`{"success":true,"result":{}}`}}}
|
||||
if _, _, err := runMinutesAlignmentCLI(t, badSession, "minutes", "+upload", "--file", file, "--yes"); err == nil {
|
||||
t.Fatal("bad session accepted")
|
||||
}
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
cancelRaw string
|
||||
cancelFailAt int
|
||||
}{
|
||||
{name: "cancel success", cancelRaw: `{"success":true,"result":{}}`},
|
||||
{name: "cancel call failure", cancelFailAt: 1},
|
||||
{name: "cancel acknowledgement failure", cancelRaw: `{"result":{}}`},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
testseam.Swap(t, &minutesPutFile, func(context.Context, string, string, int64) (localio.UploadResult, error) {
|
||||
return localio.UploadResult{}, errors.New("put failed")
|
||||
})
|
||||
caller := &minutesE2ECaller{
|
||||
responses: map[string][]string{
|
||||
"minutes/create_upload_session": {`{"success":true,"result":{"sessionId":"s1","presignedUrl":"https://example.invalid/u"}}`},
|
||||
"minutes/cancel_upload_session": {test.cancelRaw},
|
||||
},
|
||||
failAt: map[string]int{"minutes/cancel_upload_session": test.cancelFailAt},
|
||||
}
|
||||
if _, _, err := runMinutesAlignmentCLI(t, caller, "minutes", "+upload", "--file", file, "--yes"); err == nil {
|
||||
t.Fatal("put failure accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
testseam.Swap(t, &minutesPutFile, func(context.Context, string, string, int64) (localio.UploadResult, error) {
|
||||
return localio.UploadResult{SizeBytes: 5, Attempts: 2}, nil
|
||||
})
|
||||
base := map[string][]string{
|
||||
"minutes/create_upload_session": {`{"success":true,"result":{"sessionId":"s1","presignedUrl":"https://example.invalid/u"}}`},
|
||||
"minutes/complete_upload_session": {`{"success":true,"result":{"taskUuid":"u1"}}`},
|
||||
"minutes/get_minutes_basic_info": {`{"success":true,"result":{"taskUuid":"u1","title":"ok"}}`},
|
||||
}
|
||||
success := &minutesE2ECaller{responses: base}
|
||||
payload, _, err := runMinutesAlignmentCLI(t, success, "minutes", "+upload", "--file", file, "--title", "T", "--template-id", "tpl", "--input-language", "zh", "--enable-message-card", "--yes")
|
||||
if err != nil || payload["complete"] != true || payload["verified"] != true {
|
||||
t.Fatalf("upload payload=%#v err=%v", payload, err)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
responses map[string][]string
|
||||
failAt map[string]int
|
||||
}{
|
||||
{name: "invalid completion", responses: map[string][]string{
|
||||
"minutes/create_upload_session": {`{"success":true,"result":{"sessionId":"s1","presignedUrl":"https://example.invalid/u"}}`},
|
||||
"minutes/complete_upload_session": {`{"success":true,"result":{}}`},
|
||||
}},
|
||||
{name: "verify call", responses: base, failAt: map[string]int{"minutes/get_minutes_basic_info": 1}},
|
||||
{name: "verify parse", responses: map[string][]string{
|
||||
"minutes/create_upload_session": {`{"success":true,"result":{"sessionId":"s1","presignedUrl":"https://example.invalid/u"}}`},
|
||||
"minutes/complete_upload_session": {`{"success":true,"result":{"taskUuid":"u1"}}`},
|
||||
"minutes/get_minutes_basic_info": {`{"success":true,"result":{}}`},
|
||||
}},
|
||||
}
|
||||
for _, test := range cases {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
caller := &minutesE2ECaller{responses: test.responses, failAt: test.failAt}
|
||||
if _, _, err := runMinutesAlignmentCLI(t, caller, "minutes", "+upload", "--file", file, "--yes"); err == nil {
|
||||
t.Fatal("upload failure accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
if err := runMinutesAlignmentCLIWithWriter(t, &minutesE2ECaller{}, minutesFailWriter{}, "minutes", "+upload", "--file", file, "--dry-run"); err == nil {
|
||||
t.Fatal("upload output failure accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesUploadErrorConstructors(t *testing.T) {
|
||||
if err := minutesUploadFailure("put", "s", false, nil, nil); err == nil {
|
||||
t.Fatal("nil-cause failure missing")
|
||||
}
|
||||
if err := minutesUploadCompletionUnknown("s", 1, nil); err == nil {
|
||||
t.Fatal("nil-cause unknown completion missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesUpdatePermissionSummarySpeakerFailuresE2E(t *testing.T) {
|
||||
// Dry-run branches must not access MCP and must still propagate output errors.
|
||||
dryCases := [][]string{
|
||||
{"minutes", "+update", "--id", "u1", "--title", "new", "--dry-run"},
|
||||
{"minutes", "+summary", "--id", "u1", "--content", "new", "--dry-run"},
|
||||
{"minutes", "+speaker-replace", "--id", "u1", "--from", "a", "--to", "b", "--dry-run"},
|
||||
}
|
||||
for _, args := range dryCases {
|
||||
if err := runMinutesAlignmentCLIWithWriter(t, &minutesE2ECaller{}, minutesFailWriter{}, args...); err == nil {
|
||||
t.Fatalf("output failure accepted: %v", args)
|
||||
}
|
||||
}
|
||||
|
||||
updateCases := []struct {
|
||||
name string
|
||||
responses map[string][]string
|
||||
failAt map[string]int
|
||||
wantError bool
|
||||
}{
|
||||
{name: "before call", failAt: map[string]int{"minutes/get_minutes_basic_info": 1}, wantError: true},
|
||||
{name: "before parse", responses: map[string][]string{"minutes/get_minutes_basic_info": {`{"success":true,"result":{}}`}}, wantError: true},
|
||||
{name: "unchanged", responses: map[string][]string{"minutes/get_minutes_basic_info": {`{"success":true,"result":{"taskUuid":"u1","title":"new"}}`}}},
|
||||
{name: "write", responses: map[string][]string{"minutes/get_minutes_basic_info": {`{"success":true,"result":{"taskUuid":"u1","title":"old"}}`}}, failAt: map[string]int{"minutes/update_minutes_title": 1}, wantError: true},
|
||||
{name: "after call", responses: map[string][]string{"minutes/get_minutes_basic_info": {`{"success":true,"result":{"taskUuid":"u1","title":"old"}}`}}, failAt: map[string]int{"minutes/get_minutes_basic_info": 2}, wantError: true},
|
||||
{name: "after parse", responses: map[string][]string{"minutes/get_minutes_basic_info": {`{"success":true,"result":{"taskUuid":"u1","title":"old"}}`, `{"success":true,"result":{}}`}, "minutes/update_minutes_title": {`{"success":true,"result":{}}`}}, wantError: true},
|
||||
}
|
||||
for _, test := range updateCases {
|
||||
t.Run("update "+test.name, func(t *testing.T) {
|
||||
payload, _, err := runMinutesAlignmentCLI(t, &minutesE2ECaller{responses: test.responses, failAt: test.failAt}, "minutes", "+update", "--id", "u1", "--title", "new", "--yes")
|
||||
if (err != nil) != test.wantError {
|
||||
t.Fatalf("payload=%#v err=%v", payload, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
permissionCases := []struct {
|
||||
name string
|
||||
responses map[string][]string
|
||||
failAt map[string]int
|
||||
writer io.Writer
|
||||
}{
|
||||
{name: "call", failAt: map[string]int{"minutes/apply_minutes_permission": 1}},
|
||||
{name: "ack", responses: map[string][]string{"minutes/apply_minutes_permission": {`{"result":{}}`}}},
|
||||
{name: "success output", responses: map[string][]string{"minutes/apply_minutes_permission": {`{"success":true,"result":{}}`}}, writer: minutesFailWriter{}},
|
||||
}
|
||||
for _, test := range permissionCases {
|
||||
t.Run("permission "+test.name, func(t *testing.T) {
|
||||
writer := test.writer
|
||||
if writer == nil {
|
||||
writer = &bytes.Buffer{}
|
||||
}
|
||||
if err := runMinutesAlignmentCLIWithWriter(t, &minutesE2ECaller{responses: test.responses, failAt: test.failAt}, writer, "minutes", "+apply-permission", "--id", "u1", "--permission", "view", "--yes"); err == nil {
|
||||
t.Fatal("permission failure accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
permissionSuccess := &minutesE2ECaller{responses: map[string][]string{"minutes/apply_minutes_permission": {`{"success":true,"result":{}}`}}}
|
||||
if payload, _, err := runMinutesAlignmentCLI(t, permissionSuccess, "minutes", "+apply-permission", "--id", "u1", "--permission", "view", "--yes"); err != nil || payload["requested"] != true {
|
||||
t.Fatalf("permission payload=%#v err=%v", payload, err)
|
||||
}
|
||||
|
||||
if _, _, err := runMinutesAlignmentCLI(t, &minutesE2ECaller{}, "minutes", "+summary", "--id", "u1", "--content", "@missing", "--yes"); err == nil {
|
||||
t.Fatal("missing input accepted")
|
||||
}
|
||||
if _, _, err := runMinutesAlignmentCLI(t, &minutesE2ECaller{}, "minutes", "+summary", "--id", "u1", "--content", " ", "--yes"); err == nil {
|
||||
t.Fatal("empty summary accepted")
|
||||
}
|
||||
cmd := &cobra.Command{Use: "summary"}
|
||||
cmd.Flags().String("id", "u1", "")
|
||||
cmd.Flags().String("content", " ", "")
|
||||
if err := executeMinutesSummary(shortcut.RuntimeContextForTest(cmd, Summary)); err == nil {
|
||||
t.Fatal("direct empty summary accepted")
|
||||
}
|
||||
summaryCases := []struct {
|
||||
name string
|
||||
responses map[string][]string
|
||||
failAt map[string]int
|
||||
wantError bool
|
||||
}{
|
||||
{name: "before call", failAt: map[string]int{"minutes/get_minutes_ai_summary": 1}, wantError: true},
|
||||
{name: "before parse", responses: map[string][]string{"minutes/get_minutes_ai_summary": {`{"success":true,"result":{}}`}}, wantError: true},
|
||||
{name: "unchanged", responses: map[string][]string{"minutes/get_minutes_ai_summary": {`{"success":true,"result":{"fullSummary":"new"}}`}}},
|
||||
{name: "write", responses: map[string][]string{"minutes/get_minutes_ai_summary": {`{"success":true,"result":{"fullSummary":"old"}}`}}, failAt: map[string]int{"minutes/update_minutes_summary": 1}, wantError: true},
|
||||
{name: "readback call", responses: map[string][]string{"minutes/get_minutes_ai_summary": {`{"success":true,"result":{"fullSummary":"old"}}`}}, failAt: map[string]int{"minutes/get_minutes_ai_summary": 2}, wantError: true},
|
||||
{name: "readback parse", responses: map[string][]string{"minutes/get_minutes_ai_summary": {`{"success":true,"result":{"fullSummary":"old"}}`, `{"success":true,"result":{}}`}, "minutes/update_minutes_summary": {`{"success":true,"result":{}}`}}, wantError: true},
|
||||
}
|
||||
for _, test := range summaryCases {
|
||||
t.Run("summary "+test.name, func(t *testing.T) {
|
||||
payload, _, err := runMinutesAlignmentCLI(t, &minutesE2ECaller{responses: test.responses, failAt: test.failAt}, "minutes", "+summary", "--id", "u1", "--content", "new", "--yes")
|
||||
if (err != nil) != test.wantError {
|
||||
t.Fatalf("payload=%#v err=%v", payload, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
speakerCases := []struct {
|
||||
name string
|
||||
responses map[string][]string
|
||||
failAt map[string]int
|
||||
}{
|
||||
{name: "before", failAt: map[string]int{"minutes/get_minutes_transcription": 1}},
|
||||
{name: "missing", responses: map[string][]string{"minutes/get_minutes_transcription": {`{"success":true,"result":{"paragraphList":[{"speakerNick":"x"}],"hasNext":false}}`}}},
|
||||
{name: "write", responses: map[string][]string{"minutes/get_minutes_transcription": {`{"success":true,"result":{"paragraphList":[{"speakerNick":"a"}],"hasNext":false}}`}}, failAt: map[string]int{"minutes/replace_speaker": 1}},
|
||||
{name: "after", responses: map[string][]string{"minutes/get_minutes_transcription": {`{"success":true,"result":{"paragraphList":[{"speakerNick":"a"}],"hasNext":false}}`}}, failAt: map[string]int{"minutes/get_minutes_transcription": 2}},
|
||||
{name: "verify", responses: map[string][]string{"minutes/get_minutes_transcription": {`{"success":true,"result":{"paragraphList":[{"speakerNick":"a"}],"hasNext":false}}`, `{"success":true,"result":{"paragraphList":[{"speakerNick":"a"}],"hasNext":false}}`}, "minutes/replace_speaker": {`{"success":true,"result":{}}`}}},
|
||||
}
|
||||
for _, test := range speakerCases {
|
||||
t.Run("speaker "+test.name, func(t *testing.T) {
|
||||
if _, _, err := runMinutesAlignmentCLI(t, &minutesE2ECaller{responses: test.responses, failAt: test.failAt}, "minutes", "+speaker-replace", "--id", "u1", "--from", "a", "--to", "b", "--target-uid", "uid", "--yes"); err == nil {
|
||||
t.Fatal("speaker failure accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesCompleteUploadRetryAndContext(t *testing.T) {
|
||||
// Use the real runtime/caller path so retry classification is tested, while
|
||||
// zero intervals keep the fault-injection cases deterministic and fast.
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
responses []string
|
||||
failAt int
|
||||
failError error
|
||||
cancel bool
|
||||
timeout time.Duration
|
||||
interval time.Duration
|
||||
wantCalls int
|
||||
wantError bool
|
||||
}{
|
||||
{name: "success", responses: []string{`{"success":true,"result":{"taskUuid":"u1"}}`}, timeout: time.Second, wantCalls: 1},
|
||||
{name: "retry success", responses: []string{`{"success":true,"result":{"taskUuid":"u1"}}`}, failAt: 1, failError: errors.New("still uploading"), timeout: time.Second, wantCalls: 2},
|
||||
{name: "nonretryable", failAt: 1, failError: errors.New("denied"), timeout: time.Second, wantCalls: 1, wantError: true},
|
||||
{name: "timeout", failAt: 1, failError: errors.New("wait and retry"), wantCalls: 1, wantError: true},
|
||||
{name: "context", failAt: 1, failError: errors.New("正在上传"), cancel: true, timeout: 2 * time.Hour, interval: time.Hour, wantCalls: 1, wantError: true},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
key := "minutes/complete_upload_session"
|
||||
caller := &minutesE2ECaller{
|
||||
responses: map[string][]string{key: test.responses},
|
||||
failAt: map[string]int{key: test.failAt},
|
||||
failErrors: map[string]error{key: test.failError},
|
||||
}
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
ctx := context.Background()
|
||||
if test.cancel {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithCancel(ctx)
|
||||
cancel()
|
||||
}
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.SetContext(ctx)
|
||||
rt := shortcut.RuntimeContextForTest(cmd, Upload)
|
||||
_, attempts, err := completeMinutesUpload(rt, "s1", test.timeout, test.interval)
|
||||
if (err != nil) != test.wantError || attempts != test.wantCalls || caller.counts[key] != test.wantCalls {
|
||||
t.Fatalf("attempts=%d calls=%d err=%v", attempts, caller.counts[key], err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesLegacyListsAndRecordCommandsE2E(t *testing.T) {
|
||||
listSuccess := &minutesE2ECaller{responses: map[string][]string{"minutes/list_by_keyword_and_time_range": {`{"success":true,"result":{"itemList":[{"uuid":"u1","title":"weekly"}],"hasNext":false}}`}}}
|
||||
for _, command := range []string{"+list-mine", "+list-shared", "+list-all"} {
|
||||
payload, _, err := runMinutesAlignmentCLI(t, listSuccess, "minutes", command, "--query", "weekly", "--limit", "5", "--cursor", "n1")
|
||||
if err != nil || payload["count"] != float64(1) {
|
||||
t.Fatalf("%s payload=%#v err=%v", command, payload, err)
|
||||
}
|
||||
}
|
||||
if calls := listSuccess.arguments["minutes/list_by_keyword_and_time_range"]; len(calls) != 3 || calls[0]["maxResults"] != 5 || calls[0]["keyword"] != "weekly" || calls[0]["nextToken"] != "n1" {
|
||||
t.Fatalf("list args=%#v", calls)
|
||||
}
|
||||
listCall := &minutesE2ECaller{failAt: map[string]int{"minutes/list_by_keyword_and_time_range": 1}}
|
||||
if _, _, err := runMinutesAlignmentCLI(t, listCall, "minutes", "+list-all"); err == nil {
|
||||
t.Fatal("list call failure accepted")
|
||||
}
|
||||
listParse := &minutesE2ECaller{responses: map[string][]string{"minutes/list_by_keyword_and_time_range": {`{"success":true,"result":{}}`}}}
|
||||
if _, _, err := runMinutesAlignmentCLI(t, listParse, "minutes", "+list-all"); err == nil {
|
||||
t.Fatal("list parse failure accepted")
|
||||
}
|
||||
listProject := &minutesE2ECaller{responses: map[string][]string{"minutes/list_by_keyword_and_time_range": {`{"success":true,"result":{"itemList":[{"title":"missing id"}]}}`}}}
|
||||
if _, _, err := runMinutesAlignmentCLI(t, listProject, "minutes", "+list-all"); err == nil {
|
||||
t.Fatal("list projection failure accepted")
|
||||
}
|
||||
if err := runMinutesAlignmentCLIWithWriter(t, listSuccess, minutesFailWriter{}, "minutes", "+list-all"); err == nil {
|
||||
t.Fatal("list output failure accepted")
|
||||
}
|
||||
|
||||
for _, test := range []struct {
|
||||
command string
|
||||
cmd string
|
||||
id string
|
||||
}{
|
||||
{command: "+record-start", cmd: "create"},
|
||||
{command: "+record-pause", cmd: "pause", id: "u1"},
|
||||
{command: "+record-resume", cmd: "resume", id: "u1"},
|
||||
{command: "+record-stop", cmd: "end", id: "u1"},
|
||||
} {
|
||||
t.Run(test.command, func(t *testing.T) {
|
||||
result := fmt.Sprintf(`{"success":true,"result":{"cmd":%q,"uuid":%q}}`, test.cmd, test.id)
|
||||
caller := &minutesE2ECaller{responses: map[string][]string{"minutes/" + listeningNoteCmdTool: {result}}}
|
||||
args := []string{"minutes", test.command, "--session-id", "session", "--yes"}
|
||||
if test.id != "" {
|
||||
args = append(args, "--id", test.id)
|
||||
}
|
||||
payload, _, err := runMinutesAlignmentCLI(t, caller, args...)
|
||||
if err != nil || payload["accepted"] != true || payload["command"] != test.cmd {
|
||||
t.Fatalf("payload=%#v err=%v", payload, err)
|
||||
}
|
||||
if caller.arguments["minutes/"+listeningNoteCmdTool][0]["sessionId"] != "session" {
|
||||
t.Fatalf("args=%#v", caller.arguments)
|
||||
}
|
||||
})
|
||||
}
|
||||
recordCall := &minutesE2ECaller{failAt: map[string]int{"minutes/" + listeningNoteCmdTool: 1}}
|
||||
if _, _, err := runMinutesAlignmentCLI(t, recordCall, "minutes", "+record-start", "--yes"); err == nil {
|
||||
t.Fatal("record call failure accepted")
|
||||
}
|
||||
recordParse := &minutesE2ECaller{responses: map[string][]string{"minutes/" + listeningNoteCmdTool: {`{"success":true,"result":{"cmd":"pause","uuid":"u1"}}`}}}
|
||||
if _, _, err := runMinutesAlignmentCLI(t, recordParse, "minutes", "+record-stop", "--id", "u1", "--yes"); err == nil {
|
||||
t.Fatal("record result mismatch accepted")
|
||||
}
|
||||
recordOutput := &minutesE2ECaller{responses: map[string][]string{"minutes/" + listeningNoteCmdTool: {`{"success":true,"result":{"cmd":"end","uuid":"u1"}}`}}}
|
||||
if err := runMinutesAlignmentCLIWithWriter(t, recordOutput, minutesFailWriter{}, "minutes", "+record-stop", "--id", "u1", "--yes"); err == nil {
|
||||
t.Fatal("record output failure accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package minutes
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/localio"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type minutesE2ECaller struct {
|
||||
responses map[string][]string
|
||||
failAt map[string]int
|
||||
counts map[string]int
|
||||
arguments map[string][]map[string]any
|
||||
beforeFail map[string]func()
|
||||
failErrors map[string]error
|
||||
}
|
||||
|
||||
func (c *minutesE2ECaller) CallTool(_ context.Context, product, tool string, args map[string]any) (*edition.ToolResult, error) {
|
||||
if c.counts == nil {
|
||||
c.counts = map[string]int{}
|
||||
}
|
||||
if c.arguments == nil {
|
||||
c.arguments = map[string][]map[string]any{}
|
||||
}
|
||||
key := product + "/" + tool
|
||||
c.counts[key]++
|
||||
c.arguments[key] = append(c.arguments[key], args)
|
||||
if c.failAt[key] == c.counts[key] {
|
||||
if hook := c.beforeFail[key]; hook != nil {
|
||||
hook()
|
||||
}
|
||||
if err := c.failErrors[key]; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, errors.New("fixture failure")
|
||||
}
|
||||
responses := c.responses[key]
|
||||
text := `{"success":true,"result":{}}`
|
||||
if len(responses) > 0 {
|
||||
index := c.counts[key] - 1
|
||||
if index >= len(responses) {
|
||||
index = len(responses) - 1
|
||||
}
|
||||
text = responses[index]
|
||||
}
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil
|
||||
}
|
||||
|
||||
func (c *minutesE2ECaller) CallReadTool(ctx context.Context, product, tool string, args map[string]any) (*edition.ToolResult, error) {
|
||||
return c.CallTool(ctx, product, tool, args)
|
||||
}
|
||||
|
||||
func (*minutesE2ECaller) Format() string { return "json" }
|
||||
func (*minutesE2ECaller) DryRun() bool { return false }
|
||||
func (*minutesE2ECaller) Fields() string { return "" }
|
||||
func (*minutesE2ECaller) JQ() string { return "" }
|
||||
|
||||
func runMinutesAlignmentCLI(t *testing.T, caller *minutesE2ECaller, args ...string) (map[string]any, string, error) {
|
||||
t.Helper()
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||
root.PersistentFlags().Bool("yes", false, "")
|
||||
root.PersistentFlags().Bool("dry-run", false, "")
|
||||
root.PersistentFlags().String("format", "json", "")
|
||||
root.AddCommand(shortcut.Commands()...)
|
||||
var stdout bytes.Buffer
|
||||
root.SetOut(&stdout)
|
||||
root.SetErr(&bytes.Buffer{})
|
||||
root.SetArgs(args)
|
||||
err := root.Execute()
|
||||
if stdout.Len() == 0 {
|
||||
return nil, "", err
|
||||
}
|
||||
var payload map[string]any
|
||||
if decodeErr := json.Unmarshal(stdout.Bytes(), &payload); decodeErr != nil {
|
||||
t.Fatalf("decode output %q: %v", stdout.String(), decodeErr)
|
||||
}
|
||||
return payload, stdout.String(), err
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesSearchPaginatesFiltersAndRejectsUnknownE2E(t *testing.T) {
|
||||
caller := &minutesE2ECaller{responses: map[string][]string{
|
||||
"minutes/list_by_keyword_and_time_range": {
|
||||
`{"success":true,"result":{"itemList":[{"uuid":"u1","title":"周会 A","startTime":1},{"uuid":"u2","title":"其他","startTime":2}],"hasNext":true,"nextToken":"n2"}}`,
|
||||
`{"success":true,"result":{"itemList":[{"uuid":"u1","title":"周会 A","startTime":1},{"uuid":"u3","title":"周会 B","startTime":3}],"hasNext":false}}`,
|
||||
},
|
||||
}}
|
||||
payload, _, err := runMinutesAlignmentCLI(t, caller, "minutes", "+search", "--query", "周会", "--page-all")
|
||||
if err != nil || payload["count"] != float64(2) || payload["scannedCount"] != float64(3) || payload["pages"] != float64(2) || payload["complete"] != true {
|
||||
t.Fatalf("search payload=%#v err=%v", payload, err)
|
||||
}
|
||||
if calls := caller.arguments["minutes/list_by_keyword_and_time_range"]; len(calls) != 2 || calls[1]["nextToken"] != "n2" {
|
||||
t.Fatalf("search calls=%#v", calls)
|
||||
}
|
||||
|
||||
for _, test := range []struct {
|
||||
scope string
|
||||
belonging string
|
||||
}{
|
||||
{scope: "mine", belonging: "createdByMe"},
|
||||
{scope: "shared", belonging: "sharedToMe"},
|
||||
{scope: "all", belonging: "noLimit"},
|
||||
} {
|
||||
t.Run("scope "+test.scope, func(t *testing.T) {
|
||||
scoped := &minutesE2ECaller{responses: map[string][]string{
|
||||
"minutes/list_by_keyword_and_time_range": {`{"success":true,"result":{"itemList":[],"hasNext":false}}`},
|
||||
}}
|
||||
payload, _, err := runMinutesAlignmentCLI(t, scoped, "minutes", "+search", "--query", "needle", "--scope", test.scope, "--limit", "1")
|
||||
if err != nil || payload["count"] != float64(0) || payload["complete"] != true {
|
||||
t.Fatalf("scope %s payload=%#v err=%v", test.scope, payload, err)
|
||||
}
|
||||
calls := scoped.arguments["minutes/list_by_keyword_and_time_range"]
|
||||
want := map[string]any{"belongingConditionId": test.belonging, "keyword": "needle", "maxResults": 1}
|
||||
if len(calls) != 1 || !reflect.DeepEqual(calls[0], want) {
|
||||
t.Fatalf("scope %s calls=%#v, want exactly %#v", test.scope, calls, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
unknown := &minutesE2ECaller{responses: map[string][]string{"minutes/list_by_keyword_and_time_range": {`{"success":true,"result":{}}`}}}
|
||||
if payload, output, err := runMinutesAlignmentCLI(t, unknown, "minutes", "+search", "--query", "周会"); err == nil || payload != nil || output != "" {
|
||||
t.Fatalf("unknown list accepted: payload=%#v output=%q err=%v", payload, output, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesDownloadEmptyMediaIsPartialFailureE2E(t *testing.T) {
|
||||
caller := &minutesE2ECaller{responses: map[string][]string{"minutes/query_minutes_audio_url": {`{"success":true,"result":{}}`}}}
|
||||
payload, output, err := runMinutesAlignmentCLI(t, caller, "minutes", "+download", "--id", "u1", "--url-only")
|
||||
if err == nil || output == "" || payload["ok"] != false || payload["failed"] != float64(1) || payload["succeeded"] != float64(0) {
|
||||
t.Fatalf("empty media accepted: payload=%#v output=%q err=%v", payload, output, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesUpdateRequiresVerifiedReadbackE2E(t *testing.T) {
|
||||
success := &minutesE2ECaller{responses: map[string][]string{
|
||||
"minutes/get_minutes_basic_info": {`{"success":true,"result":{"taskUuid":"u1","title":"旧标题"}}`, `{"success":true,"result":{"taskUuid":"u1","title":"新标题"}}`},
|
||||
"minutes/update_minutes_title": {`{"success":true,"result":{"updated":true}}`},
|
||||
}}
|
||||
payload, _, err := runMinutesAlignmentCLI(t, success, "minutes", "+update", "--id", "u1", "--title", "新标题", "--yes")
|
||||
if err != nil || payload["changed"] != true || payload["verified"] != true {
|
||||
t.Fatalf("verified update payload=%#v err=%v", payload, err)
|
||||
}
|
||||
|
||||
mismatch := &minutesE2ECaller{responses: map[string][]string{
|
||||
"minutes/get_minutes_basic_info": {`{"success":true,"result":{"taskUuid":"u1","title":"旧标题"}}`, `{"success":true,"result":{"taskUuid":"u1","title":"仍是旧标题"}}`},
|
||||
"minutes/update_minutes_title": {`{"success":true,"result":{"updated":true}}`},
|
||||
}}
|
||||
if payload, output, err := runMinutesAlignmentCLI(t, mismatch, "minutes", "+update", "--id", "u1", "--title", "新标题", "--yes"); err == nil || payload != nil || output != "" {
|
||||
t.Fatalf("readback mismatch accepted: payload=%#v output=%q err=%v", payload, output, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesSummaryProtectsImagesAndVerifiesE2E(t *testing.T) {
|
||||
missingImage := &minutesE2ECaller{responses: map[string][]string{
|
||||
"minutes/get_minutes_ai_summary": {`{"success":true,"result":{"fullSummary":"旧内容\n"}}`},
|
||||
}}
|
||||
if payload, output, err := runMinutesAlignmentCLI(t, missingImage, "minutes", "+summary", "--id", "u1", "--content", "新内容", "--yes"); err == nil || payload != nil || output != "" || missingImage.counts["minutes/update_minutes_summary"] != 0 {
|
||||
t.Fatalf("missing image accepted: payload=%#v output=%q err=%v calls=%#v", payload, output, err, missingImage.counts)
|
||||
}
|
||||
|
||||
content := "新内容\n"
|
||||
success := &minutesE2ECaller{responses: map[string][]string{
|
||||
"minutes/get_minutes_ai_summary": {`{"success":true,"result":{"fullSummary":"旧内容\n"}}`, `{"success":true,"result":{"fullSummary":"新内容\n"}}`},
|
||||
"minutes/update_minutes_summary": {`{"success":true,"result":{"updated":true}}`},
|
||||
}}
|
||||
payload, _, err := runMinutesAlignmentCLI(t, success, "minutes", "+summary", "--id", "u1", "--content", content, "--yes")
|
||||
if err != nil || payload["changed"] != true || payload["verified"] != true || payload["preservedImages"] != true {
|
||||
t.Fatalf("summary payload=%#v err=%v", payload, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesSpeakerReplacePaginatesAndVerifiesE2E(t *testing.T) {
|
||||
caller := &minutesE2ECaller{responses: map[string][]string{
|
||||
"minutes/get_minutes_transcription": {
|
||||
`{"success":true,"result":{"paragraphList":[{"paragraphId":"p1","speakerNick":"甲"}],"hasNext":true,"nextToken":"n2"}}`,
|
||||
`{"success":true,"result":{"paragraphList":[{"paragraphId":"p2","speakerNick":"甲"}],"hasNext":false}}`,
|
||||
`{"success":true,"result":{"paragraphList":[{"paragraphId":"p1","speakerNick":"乙"}],"hasNext":true,"nextToken":"n2"}}`,
|
||||
`{"success":true,"result":{"paragraphList":[{"paragraphId":"p2","speakerNick":"乙"}],"hasNext":false}}`,
|
||||
},
|
||||
"minutes/replace_speaker": {`{"success":true,"result":{"updated":true}}`},
|
||||
}}
|
||||
payload, _, err := runMinutesAlignmentCLI(t, caller, "minutes", "+speaker-replace", "--id", "u1", "--from", "甲", "--to", "乙", "--yes")
|
||||
if err != nil || payload["verified"] != true || payload["affectedParagraphs"] != float64(2) || caller.counts["minutes/get_minutes_transcription"] != 4 {
|
||||
t.Fatalf("speaker payload=%#v err=%v calls=%#v", payload, err, caller.counts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesUploadAndPermissionDryRunDoNotWriteE2E(t *testing.T) {
|
||||
work := t.TempDir()
|
||||
file := filepath.Join(work, "source.wav")
|
||||
if err := os.WriteFile(file, []byte("non-empty-audio-fixture"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
caller := &minutesE2ECaller{}
|
||||
upload, _, err := runMinutesAlignmentCLI(t, caller, "minutes", "+upload", "--file", file, "--title", "E2E", "--dry-run")
|
||||
if err != nil || upload["dryRun"] != true || upload["executed"] != false || len(caller.counts) != 0 {
|
||||
t.Fatalf("upload dry-run=%#v err=%v calls=%#v", upload, err, caller.counts)
|
||||
}
|
||||
permission, _, err := runMinutesAlignmentCLI(t, caller, "minutes", "+apply-permission", "--id", "u1", "--permission", "edit", "--dry-run")
|
||||
if err != nil || permission["policyId"] != float64(2) || permission["executed"] != false || len(caller.counts) != 0 {
|
||||
t.Fatalf("permission dry-run=%#v err=%v calls=%#v", permission, err, caller.counts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesUploadUnknownCompletionPreservesSessionE2E(t *testing.T) {
|
||||
work := t.TempDir()
|
||||
file := filepath.Join(work, "response-loss.wav")
|
||||
if err := os.WriteFile(file, []byte("non-empty-audio-fixture"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
testseam.Swap(t, &minutesPutFile, func(context.Context, string, string, int64) (localio.UploadResult, error) {
|
||||
return localio.UploadResult{SizeBytes: 23, Attempts: 1}, nil
|
||||
})
|
||||
|
||||
serverCompleted := false
|
||||
caller := &minutesE2ECaller{
|
||||
responses: map[string][]string{
|
||||
"minutes/create_upload_session": {`{"success":true,"result":{"sessionId":"session-redacted","presignedUrl":"https://upload.example.invalid/object"}}`},
|
||||
},
|
||||
failAt: map[string]int{"minutes/complete_upload_session": 1},
|
||||
beforeFail: map[string]func(){
|
||||
"minutes/complete_upload_session": func() { serverCompleted = true },
|
||||
},
|
||||
}
|
||||
payload, output, err := runMinutesAlignmentCLI(t, caller, "minutes", "+upload", "--file", file, "--yes")
|
||||
if err == nil || payload != nil || output != "" || !serverCompleted {
|
||||
t.Fatalf("response-loss outcome = payload:%#v output:%q err:%v completed:%v", payload, output, err, serverCompleted)
|
||||
}
|
||||
if caller.counts["minutes/cancel_upload_session"] != 0 {
|
||||
t.Fatalf("unknown remote completion was cancelled: calls=%#v", caller.counts)
|
||||
}
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) || typed.Reason != "minutes_upload_completion_unknown" || typed.Retryable || typed.FailureStage != "complete" {
|
||||
t.Fatalf("unknown completion error = %#v", err)
|
||||
}
|
||||
if typed.Details["sessionId"] != "session-redacted" || typed.Details["cancelled"] != false || typed.Details["remoteEffect"] != "unknown" {
|
||||
t.Fatalf("unknown completion recovery details = %#v", typed.Details)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesMindmapExplicitStatusControlsExitE2E(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
status string
|
||||
wantErr bool
|
||||
wantResult bool
|
||||
}{
|
||||
{name: "success", status: `{"success":true,"result":{"taskStatus":1,"mindGraph":"ready"}}`, wantResult: true},
|
||||
{name: "platform failed", status: `{"success":true,"result":{"taskStatus":2}}`, wantErr: true},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
caller := &minutesE2ECaller{responses: map[string][]string{
|
||||
"minutes/create_mind_graph": {`{"success":true,"result":{}}`},
|
||||
"minutes/query_mind_graph_status": {test.status},
|
||||
}}
|
||||
payload, output, err := runMinutesAlignmentCLI(t, caller, "minutes", "+mindmap", "--id", "u1", "--timeout", "2", "--interval", "1", "--yes")
|
||||
if output == "" || (err != nil) != test.wantErr || payload["complete"] != test.wantResult {
|
||||
t.Fatalf("mindmap payload=%#v output=%q err=%v", payload, output, err)
|
||||
}
|
||||
if caller.counts["minutes/create_mind_graph"] != 1 {
|
||||
t.Fatalf("mindmap create repeated: %#v", caller.counts)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesSpeakerInsightsRequiresTaskAndResultE2E(t *testing.T) {
|
||||
caller := &minutesE2ECaller{responses: map[string][]string{
|
||||
"minutes/create_speaker_summary": {`{"success":true,"result":{"taskId":"job-1","status":"processing"}}`},
|
||||
"minutes/get_speaker_summary": {
|
||||
`{"success":false,"errorMsg":"downstream query empty"}`,
|
||||
`{"success":true,"result":{"summaries":[{"speaker":"甲","summary":"结论"}]}}`,
|
||||
},
|
||||
}}
|
||||
payload, output, err := runMinutesAlignmentCLI(t, caller, "minutes", "+speaker-insights", "--id", "u1", "--timeout", "3", "--interval", "1", "--yes")
|
||||
if err != nil || output == "" || payload["complete"] != true || payload["taskId"] != "job-1" || payload["attempts"] != float64(2) {
|
||||
t.Fatalf("speaker insights payload=%#v output=%q err=%v", payload, output, err)
|
||||
}
|
||||
|
||||
missingTask := &minutesE2ECaller{responses: map[string][]string{"minutes/create_speaker_summary": {`{"success":true,"result":{"status":"processing"}}`}}}
|
||||
if payload, output, err := runMinutesAlignmentCLI(t, missingTask, "minutes", "+speaker-insights", "--id", "u1", "--timeout", "2", "--interval", "1", "--yes"); err == nil || output == "" || payload["complete"] != false {
|
||||
t.Fatalf("missing task accepted: payload=%#v output=%q err=%v", payload, output, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesPrepareASRDiffWritesAndReadbackE2E(t *testing.T) {
|
||||
caller := &minutesE2ECaller{responses: map[string][]string{
|
||||
"minutes/list_my_hotwords": {
|
||||
`{"success":true,"result":{"hotWordList":["已有"],"currentCount":1}}`,
|
||||
`{"success":true,"result":{"hotWordList":["已有","DWS"],"currentCount":2}}`,
|
||||
},
|
||||
"minutes/add_personal_hot_word": {`{"success":true,"result":{}}`},
|
||||
}}
|
||||
payload, _, err := runMinutesAlignmentCLI(t, caller, "minutes", "+prepare-asr", "--words", "已有,DWS", "--yes")
|
||||
if err != nil || payload["complete"] != true || payload["verified"] != true || caller.counts["minutes/add_personal_hot_word"] != 1 || caller.counts["minutes/delete_personal_hotword"] != 0 {
|
||||
t.Fatalf("prepare asr payload=%#v err=%v calls=%#v", payload, err, caller.counts)
|
||||
}
|
||||
|
||||
unknown := &minutesE2ECaller{responses: map[string][]string{"minutes/list_my_hotwords": {`{"success":true,"result":{}}`}}}
|
||||
if payload, output, err := runMinutesAlignmentCLI(t, unknown, "minutes", "+prepare-asr", "--words", "DWS", "--yes"); err == nil || payload != nil || output != "" {
|
||||
t.Fatalf("unknown hotword list accepted: payload=%#v output=%q err=%v", payload, output, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesRecordWrapUpStopSuccessArtifactFailureIsPartialE2E(t *testing.T) {
|
||||
caller := &minutesE2ECaller{responses: map[string][]string{
|
||||
"minutes/" + listeningNoteCmdTool: {`{"success":true,"result":{"cmd":"end","uuid":"u1"}}`},
|
||||
"minutes/get_minutes_ai_summary": {`{"success":true,"result":{}}`},
|
||||
}}
|
||||
payload, output, err := runMinutesAlignmentCLI(t, caller, "minutes", "+record-wrap-up", "--id", "u1", "--artifacts", "summary", "--wait-timeout", "1", "--poll-interval", "1", "--yes")
|
||||
if err == nil || output == "" || payload["complete"] != false || payload["taskUuid"] != "u1" || payload["recovery"] == nil || caller.counts["minutes/"+listeningNoteCmdTool] != 1 {
|
||||
t.Fatalf("wrap-up partial payload=%#v output=%q err=%v calls=%#v", payload, output, err, caller.counts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesExportPackPublishesOnlyCompleteArtifactsE2E(t *testing.T) {
|
||||
old, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
work := t.TempDir()
|
||||
if err := os.Chdir(work); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(old) })
|
||||
caller := &minutesE2ECaller{responses: map[string][]string{
|
||||
"minutes/get_minutes_basic_info": {`{"success":true,"result":{"taskUuid":"u1","title":"E2E"}}`},
|
||||
"minutes/get_minutes_ai_summary": {`{"success":true,"result":{"fullSummary":"完整纪要"}}`},
|
||||
}}
|
||||
payload, _, err := runMinutesAlignmentCLI(t, caller, "minutes", "+export-pack", "--id", "u1", "--output", "pack", "--artifacts", "basic,summary")
|
||||
if err != nil || payload["complete"] != true || payload["published"] != true {
|
||||
t.Fatalf("export payload=%#v err=%v", payload, err)
|
||||
}
|
||||
manifestRaw, err := os.ReadFile(filepath.Join(work, "pack", "manifest.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bytes.Contains(manifestRaw, []byte("https://")) || !bytes.Contains(manifestRaw, []byte(`"complete": true`)) {
|
||||
t.Fatalf("manifest contains secret URL or is incomplete: %s", manifestRaw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesSharePartialWriteIsNonZeroE2E(t *testing.T) {
|
||||
caller := &minutesE2ECaller{
|
||||
responses: map[string][]string{"minutes/add_member_permission": {`{"success":true,"result":{}}`, `{"success":false,"errorMsg":"denied"}`}},
|
||||
}
|
||||
payload, output, err := runMinutesAlignmentCLI(t, caller, "minutes", "+share", "--id", "u1", "--member-uids", "m1,m2,m3", "--permission", "view", "--yes")
|
||||
if err == nil || output == "" || payload["complete"] != false || payload["succeeded"] != float64(1) || payload["failed"] != float64(1) || len(payload["unattempted"].([]any)) != 1 {
|
||||
t.Fatalf("share partial payload=%#v output=%q err=%v", payload, output, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesUploadAndAnalyzeDryRunDoesNotWriteE2E(t *testing.T) {
|
||||
file := filepath.Join(t.TempDir(), "source.wav")
|
||||
if err := os.WriteFile(file, []byte("non-empty-audio-fixture"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
caller := &minutesE2ECaller{}
|
||||
payload, _, err := runMinutesAlignmentCLI(t, caller, "minutes", "+upload-and-analyze", "--file", file, "--artifacts", "summary,transcript", "--mindmap", "--dry-run")
|
||||
if err != nil || payload["dryRun"] != true || payload["executed"] != false || len(caller.counts) != 0 {
|
||||
t.Fatalf("upload-and-analyze dry-run=%#v err=%v calls=%#v", payload, err, caller.counts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesArtifactWaitDoesNotTreatEmptyAnalysisAsReadyE2E(t *testing.T) {
|
||||
caller := &minutesE2ECaller{responses: map[string][]string{
|
||||
"minutes/get_minutes_transcription": {`{"success":true,"result":{"paragraphList":[],"hasNext":false}}`},
|
||||
}}
|
||||
// Exercise the same readiness collector used after upload/record stop. An
|
||||
// explicit [] is a valid transport shape, but cannot prove asynchronous ASR
|
||||
// has finished when the workflow promised transcript analysis.
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
rt := shortcut.RuntimeContextForTest(&cobra.Command{Use: "+export-pack"}, ExportPack)
|
||||
bundle, failures := collectMinutesArtifactsOnce(rt, "u1", []string{"transcript"}, 10)
|
||||
if len(failures) != 1 || bundle["transcript"] != nil {
|
||||
t.Fatalf("empty transcript accepted: bundle=%#v failures=%#v", bundle, failures)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package minutes
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
func minutesContract(command, description, useWhen string, avoidWhen []string, examples []string) corecmd.ContractDecl {
|
||||
name := "shortcut_" + strings.ReplaceAll(strings.TrimPrefix(command, "+"), "-", "_")
|
||||
return corecmd.ContractDecl{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "minutes",
|
||||
Name: name,
|
||||
CanonicalPath: "minutes." + name,
|
||||
CLIPath: "minutes " + command,
|
||||
PrimaryCLIPath: "minutes " + command,
|
||||
},
|
||||
Description: description,
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: contract.InterfaceModeComposite,
|
||||
Availability: contract.InterfaceAvailable,
|
||||
Reason: "The executable Shortcut owns validation, orchestration, completeness and verification across one or more Minutes RPCs; no single RPC represents the final command contract.",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: description,
|
||||
UseWhen: []string{useWhen},
|
||||
AvoidWhen: avoidWhen,
|
||||
Examples: examples,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func withMinutesDryRun(decl corecmd.ContractDecl, kind string, remoteReads bool) corecmd.ContractDecl {
|
||||
decl.DryRun = &contract.DryRunSpec{PreviewKind: kind, RemoteReads: remoteReads}
|
||||
return decl
|
||||
}
|
||||
|
||||
func minutesDryRunPayload(kind, operation string, payload map[string]any) map[string]any {
|
||||
if payload == nil {
|
||||
payload = map[string]any{}
|
||||
}
|
||||
payload["operation"] = operation
|
||||
payload["dry_run"] = true
|
||||
payload["dryRun"] = true
|
||||
payload["preview_kind"] = kind
|
||||
payload["executed"] = false
|
||||
return payload
|
||||
}
|
||||
|
||||
// finalizeMinutesShortcuts keeps the human Shortcut declaration and the final
|
||||
// Agent contract on one source of truth. Custom validation is prose-only in the
|
||||
// Schema wire, so publish its exact evidence on every affected flag as well.
|
||||
func finalizeMinutesShortcuts(values ...shortcut.Shortcut) []shortcut.Shortcut {
|
||||
finalized := make([]shortcut.Shortcut, len(values))
|
||||
for index, value := range values {
|
||||
value.Contract.Selection.AgentSummary = value.Description
|
||||
value.Contract.Selection.UseWhen = []string{value.Intent}
|
||||
for _, constraint := range value.Constraints {
|
||||
if constraint.Kind != shortcut.ConstraintCustom {
|
||||
continue
|
||||
}
|
||||
evidence := strings.TrimSpace(constraint.Description)
|
||||
if evidence == "" {
|
||||
continue
|
||||
}
|
||||
for flagIndex := range value.Flags {
|
||||
flag := &value.Flags[flagIndex]
|
||||
if !containsString(constraint.Flags, flag.Name) || strings.Contains(flag.Desc, evidence) {
|
||||
continue
|
||||
}
|
||||
flag.Desc = strings.TrimRight(flag.Desc, ";。 ") + ";约束:" + evidence
|
||||
}
|
||||
for parameterIndex := range value.Contract.Parameters {
|
||||
parameter := &value.Contract.Parameters[parameterIndex]
|
||||
if !containsString(constraint.Flags, parameter.Name) || strings.Contains(parameter.Description, evidence) {
|
||||
continue
|
||||
}
|
||||
parameter.Description = strings.TrimRight(parameter.Description, ";。 ") + ";约束:" + evidence
|
||||
}
|
||||
}
|
||||
finalized[index] = value
|
||||
}
|
||||
return finalized
|
||||
}
|
||||
|
||||
func containsString(values []string, target string) bool {
|
||||
for _, value := range values {
|
||||
if value == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -32,7 +32,10 @@ func TestCallListProjectItemListShape(t *testing.T) {
|
||||
if err := json.Unmarshal([]byte(raw), &data); err != nil {
|
||||
t.Fatalf("unmarshal fixture: %v", err)
|
||||
}
|
||||
got := callListProject(data)
|
||||
got, err := callListProject(data)
|
||||
if err != nil {
|
||||
t.Fatalf("project itemList: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("lower/upper mismatch: itemList has 2 entries, projection returned %d", len(got))
|
||||
}
|
||||
@@ -40,3 +43,20 @@ func TestCallListProjectItemListShape(t *testing.T) {
|
||||
t.Fatalf("taskUuid not projected from taskUuid/task_uuid keys: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesUnknownListIsNotEmptySuccessE2E(t *testing.T) {
|
||||
for _, raw := range []string{
|
||||
`{}`,
|
||||
`{"success":true}`,
|
||||
`{"success":true,"result":{"itemList":null}}`,
|
||||
`{"success":false,"errorMsg":"denied"}`,
|
||||
} {
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &data); err != nil {
|
||||
t.Fatalf("unmarshal fixture: %v", err)
|
||||
}
|
||||
if got, err := callListProject(data); err == nil || got != nil {
|
||||
t.Fatalf("unknown list response accepted: got=%#v err=%v raw=%s", got, err, raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/minutesdata"
|
||||
)
|
||||
|
||||
// listeningNoteCmdTool is the gateway-registered name of the 听记指令 tool.
|
||||
@@ -173,7 +174,10 @@ func callList(rt *shortcut.RuntimeContext, belonging string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
minutes := callListProject(data)
|
||||
minutes, err := callListProject(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(map[string]any{"count": len(minutes), "minutes": minutes})
|
||||
}
|
||||
|
||||
@@ -183,83 +187,12 @@ func callList(rt *shortcut.RuntimeContext, belonging string) error {
|
||||
// The list container and field names are probed defensively across candidate
|
||||
// keys so the projection tolerates response-shape drift; unknown keys are never
|
||||
// invented.
|
||||
func callListProject(data map[string]any) []map[string]any {
|
||||
raw := callListResolveList(data)
|
||||
out := make([]map[string]any, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
row := map[string]any{}
|
||||
// Only real taskUuid spellings map to taskUuid: the downstream
|
||||
// +record-pause/resume/stop commands feed this value straight into the
|
||||
// recording-control tool as the task uuid, so a minutesId (the minutes
|
||||
// document id, a different identifier) must not be substituted here or
|
||||
// record control would fail with a wrong id. The backend list already
|
||||
// returns taskUuid.
|
||||
if v, ok := callListFirst(m, "taskUuid", "task_uuid", "uuid"); ok {
|
||||
row["taskUuid"] = v
|
||||
}
|
||||
if v, ok := callListFirst(m, "title", "name"); ok {
|
||||
row["title"] = v
|
||||
}
|
||||
if v, ok := callListFirst(m, "creator", "creatorName", "createUserName", "creatorNick"); ok {
|
||||
row["creator"] = v
|
||||
}
|
||||
if v, ok := callListFirst(m, "startTime", "gmtStart", "beginTime"); ok {
|
||||
row["startTime"] = v
|
||||
}
|
||||
if v, ok := callListFirst(m, "endTime", "gmtEnd", "deadline"); ok {
|
||||
row["endTime"] = v
|
||||
}
|
||||
if v, ok := callListFirst(m, "url", "shareUrl", "link"); ok {
|
||||
row["url"] = v
|
||||
}
|
||||
if v, ok := callListFirst(m, "status", "taskStatus", "state"); ok {
|
||||
row["status"] = v
|
||||
}
|
||||
if len(row) > 0 {
|
||||
out = append(out, row)
|
||||
}
|
||||
func callListProject(data map[string]any) ([]map[string]any, error) {
|
||||
page, err := minutesdata.ParseListPage(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// callListResolveList locates the list payload inside the response, tolerating a
|
||||
// bare top-level array or nesting under result/data/list/items/records containers.
|
||||
func callListResolveList(data map[string]any) []any {
|
||||
// list_by_keyword_and_time_range nests the minutes under result.itemList;
|
||||
// "itemList" MUST be probed or +list-all/+list-mine/+list-shared silently
|
||||
// return empty despite the backend returning minutes.
|
||||
for _, key := range []string{"result", "data", "list", "items", "itemList", "records", "dataList"} {
|
||||
v, ok := data[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if arr, ok := v.([]any); ok {
|
||||
return arr
|
||||
}
|
||||
// container may itself wrap the list one level deeper
|
||||
if inner, ok := v.(map[string]any); ok {
|
||||
for _, ik := range []string{"list", "items", "itemList", "records", "dataList", "result", "data"} {
|
||||
if arr, ok := inner[ik].([]any); ok {
|
||||
return arr
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return []any{}
|
||||
}
|
||||
|
||||
// callListFirst returns the first present candidate key's value.
|
||||
func callListFirst(m map[string]any, keys ...string) (any, bool) {
|
||||
for _, k := range keys {
|
||||
if v, ok := m[k]; ok {
|
||||
return v, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
return minutesdata.ProjectList(page)
|
||||
}
|
||||
|
||||
// ── get ─────────────────────────────────────────────────────────────────────
|
||||
@@ -273,7 +206,7 @@ var RecordStart = shortcut.Shortcut{
|
||||
Command: "+record-start",
|
||||
Product: "minutes",
|
||||
Description: "发起听记(开始录音)",
|
||||
Intent: "当你要开始一场实时会议/通话的 AI 听记、立刻启动录音并生成一条新听记任务时使用;可选传入 AI 助理会话 ID,会真实发起录音,返回新建听记的 taskUuid 供后续暂停/恢复/结束。",
|
||||
Intent: "当你要开始一场实时会议/通话的 AI 听记、立刻启动录音时使用;可选传入 AI 助理会话 ID。当前网关 create 回执不返回 taskUuid,只能证明录音指令已被接受;随后需用 +latest/+search 定位新听记。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium",
|
||||
@@ -295,7 +228,7 @@ var RecordStart = shortcut.Shortcut{
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "发起听记(开始录音)",
|
||||
UseWhen: []string{"当你要开始一场实时会议/通话的 AI 听记、立刻启动录音并生成一条新听记任务时使用;可选传入 AI 助理会话 ID,会真实发起录音,返回新建听记的 taskUuid 供后续暂停/恢复/结束。"},
|
||||
UseWhen: []string{"当你要开始一场实时会议/通话的 AI 听记、立刻启动录音时使用;网关 create 只返回已接受回执,不返回 taskUuid,随后需用 +latest/+search 定位新听记。"},
|
||||
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
|
||||
Examples: []string{"dws minutes +record-start --session-id <sessionId>"},
|
||||
},
|
||||
@@ -309,7 +242,7 @@ var RecordStart = shortcut.Shortcut{
|
||||
if rt.Changed("session-id") {
|
||||
params["sessionId"] = rt.Str("session-id")
|
||||
}
|
||||
return rt.CallMCP(listeningNoteCmdTool, params)
|
||||
return executeRecordCommand(rt, "create", "", params)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -320,6 +253,16 @@ var RecordPause = shortcut.Shortcut{
|
||||
Description: "暂停听记录音",
|
||||
Intent: "录音进行中想临时中断(如中场休息、切换话题)又不想结束整条听记时使用;传入正在录音的听记 taskUuid,会真实暂停该次录音,之后可用 +record-resume 继续。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown",
|
||||
},
|
||||
Contract: minutesContract(
|
||||
"+record-pause",
|
||||
"暂停听记录音",
|
||||
"实时听记仍在录制,但要临时中断且保留后续恢复能力时使用;必须传当前录制任务的 taskUuid。",
|
||||
[]string{"会议已经结束时使用 +record-stop;只是查看听记状态或内容时使用只读命令"},
|
||||
[]string{"dws minutes +record-pause --id <taskUuid>"},
|
||||
),
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "id", Type: shortcut.FlagString, Desc: "听记 taskUuid", Required: true},
|
||||
{Name: "session-id", Type: shortcut.FlagString, Desc: "AI 助理会话 ID (可选)"},
|
||||
@@ -330,7 +273,7 @@ var RecordPause = shortcut.Shortcut{
|
||||
if rt.Changed("session-id") {
|
||||
params["sessionId"] = rt.Str("session-id")
|
||||
}
|
||||
return rt.CallMCP(listeningNoteCmdTool, params)
|
||||
return executeRecordCommand(rt, "pause", rt.Str("id"), params)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -341,6 +284,16 @@ var RecordResume = shortcut.Shortcut{
|
||||
Description: "恢复听记录音",
|
||||
Intent: "之前用 +record-pause 暂停过的听记,现在想接着录时使用;传入该听记 taskUuid,会真实恢复录音,继续追加到同一条听记中。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown",
|
||||
},
|
||||
Contract: minutesContract(
|
||||
"+record-resume",
|
||||
"恢复听记录音",
|
||||
"实时听记此前已暂停、现在要继续向同一 taskUuid 追加录音时使用。",
|
||||
[]string{"已经结束的听记不能恢复;要新建录制时使用 +record-start"},
|
||||
[]string{"dws minutes +record-resume --id <taskUuid>"},
|
||||
),
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "id", Type: shortcut.FlagString, Desc: "听记 taskUuid", Required: true},
|
||||
{Name: "session-id", Type: shortcut.FlagString, Desc: "AI 助理会话 ID (可选)"},
|
||||
@@ -351,7 +304,7 @@ var RecordResume = shortcut.Shortcut{
|
||||
if rt.Changed("session-id") {
|
||||
params["sessionId"] = rt.Str("session-id")
|
||||
}
|
||||
return rt.CallMCP(listeningNoteCmdTool, params)
|
||||
return executeRecordCommand(rt, "resume", rt.Str("id"), params)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -362,6 +315,16 @@ var RecordStop = shortcut.Shortcut{
|
||||
Description: "结束听记录音",
|
||||
Intent: "会议开完、想彻底停止录音并让系统开始生成转写与 AI 纪要时使用;传入正在录音的听记 taskUuid,会真实结束该次录音,结束后无法再恢复到这条听记继续录。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown",
|
||||
},
|
||||
Contract: minutesContract(
|
||||
"+record-stop",
|
||||
"结束听记录音",
|
||||
"会议结束且要永久停止指定 taskUuid 的实时录制,让服务进入转写和纪要处理阶段时使用。",
|
||||
[]string{"仍计划继续录音时使用 +record-pause;结束后不能通过 +record-resume 继续同一条录制"},
|
||||
[]string{"dws minutes +record-stop --id <taskUuid>"},
|
||||
),
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "id", Type: shortcut.FlagString, Desc: "听记 taskUuid", Required: true},
|
||||
{Name: "session-id", Type: shortcut.FlagString, Desc: "AI 助理会话 ID (可选)"},
|
||||
@@ -372,10 +335,27 @@ var RecordStop = shortcut.Shortcut{
|
||||
if rt.Changed("session-id") {
|
||||
params["sessionId"] = rt.Str("session-id")
|
||||
}
|
||||
return rt.CallMCP(listeningNoteCmdTool, params)
|
||||
return executeRecordCommand(rt, "end", rt.Str("id"), params)
|
||||
},
|
||||
}
|
||||
|
||||
func executeRecordCommand(rt *shortcut.RuntimeContext, expectedCmd, taskUUID string, params map[string]any) error {
|
||||
data, err := rt.CallMCPWriteDataStrict("minutes", listeningNoteCmdTool, params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := minutesdata.RecordResult(expectedCmd, taskUUID, data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(map[string]any{
|
||||
"accepted": true,
|
||||
"command": expectedCmd,
|
||||
"taskUuid": taskUUID,
|
||||
"result": result,
|
||||
})
|
||||
}
|
||||
|
||||
// ── mind-graph ──────────────────────────────────────────────────────────────
|
||||
|
||||
// ── speaker ─────────────────────────────────────────────────────────────────
|
||||
@@ -391,7 +371,7 @@ var RecordStop = shortcut.Shortcut{
|
||||
// ── tag ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
func init() {
|
||||
shortcut.Register(
|
||||
shortcut.Register(finalizeMinutesShortcuts(
|
||||
ListMine,
|
||||
ListShared,
|
||||
ListAll,
|
||||
@@ -399,5 +379,5 @@ func init() {
|
||||
RecordPause,
|
||||
RecordResume,
|
||||
RecordStop,
|
||||
)
|
||||
)...)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,836 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package minutes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/localio"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/minutesdata"
|
||||
)
|
||||
|
||||
var minutesWorkflowArtifacts = []string{"basic", "summary", "keywords", "transcript", "todos"}
|
||||
|
||||
var (
|
||||
minutesMkdirTemp = os.MkdirTemp
|
||||
minutesRemoveAll = os.RemoveAll
|
||||
minutesWriteFile = os.WriteFile
|
||||
minutesStat = os.Stat
|
||||
minutesRename = os.Rename
|
||||
minutesGetwd = os.Getwd
|
||||
minutesEvalSymlinks = filepath.EvalSymlinks
|
||||
minutesRel = filepath.Rel
|
||||
minutesLstat = os.Lstat
|
||||
minutesMkdir = os.Mkdir
|
||||
minutesMarshalIndent = json.MarshalIndent
|
||||
)
|
||||
|
||||
var RecordWrapUp = shortcut.Shortcut{
|
||||
Service: "minutes", Command: "+record-wrap-up", Product: "minutes",
|
||||
Description: "停止实时录音并有界等待听记产物,失败时保留恢复句柄",
|
||||
Intent: "会议结束时一次完成 stop 与 basic/summary/keywords/transcript/todos 读取;停止已生效但产物未就绪时返回非零 partial 结果和 taskUuid,不会诱导重复 stop。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"},
|
||||
Contract: withMinutesDryRun(minutesContract("+record-wrap-up", "停止实时录音并有界等待听记产物,失败时保留恢复句柄",
|
||||
"已知正在录制的 taskUuid,会议结束后要停止录制并立即收口转写、纪要、关键词和行动项时使用",
|
||||
[]string{"仍要继续录音时使用 +record-pause;只停止、不等待产物时使用 +record-stop"},
|
||||
[]string{`dws minutes +record-wrap-up --id <taskUuid>`, `dws minutes +record-wrap-up --id <taskUuid> --artifacts transcript,summary --wait-timeout 120`}), contract.DryRunPreviewPlan, false),
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "id", Type: shortcut.FlagString, Desc: "正在录制的听记 taskUuid", Required: true},
|
||||
{Name: "artifacts", Type: shortcut.FlagStringSlice, Desc: "停止后等待的产物", Enum: minutesWorkflowArtifacts},
|
||||
{Name: "wait-timeout", Type: shortcut.FlagInt, Default: "90", Desc: "等待产物秒数"},
|
||||
{Name: "poll-interval", Type: shortcut.FlagInt, Default: "3", Desc: "轮询间隔秒数"},
|
||||
{Name: "page-limit", Type: shortcut.FlagInt, Default: "100", Desc: "逐字稿翻页上限"},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"wait-timeout", "poll-interval", "page-limit"}, Description: "超时、轮询间隔和逐字稿页数上限必须大于 0"}},
|
||||
Tips: []string{`dws minutes +record-wrap-up --id <taskUuid>`, `dws minutes +record-wrap-up --id <taskUuid> --artifacts transcript,summary --wait-timeout 120`},
|
||||
Validate: validateMinutesWorkflowWait,
|
||||
Execute: executeMinutesRecordWrapUp,
|
||||
}
|
||||
|
||||
var UploadAndAnalyze = shortcut.Shortcut{
|
||||
Service: "minutes", Command: "+upload-and-analyze", Product: "minutes",
|
||||
Description: "本地音视频直传听记并等待分析产物,可选思维导图和发言人洞察",
|
||||
Intent: "从本地媒体一次完成 upload create/PUT/complete/read-back,再等待选定听记产物;后续分析失败仍返回已创建 taskUuid 和恢复动作。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"},
|
||||
Contract: withMinutesDryRun(minutesContract("+upload-and-analyze", "本地音视频直传听记并等待分析产物,可选思维导图和发言人洞察",
|
||||
"有本地音视频且希望一次创建听记、等待基础分析,并可继续生成思维导图或发言人洞察时使用",
|
||||
[]string{"只需上传时使用 +upload;已有 taskUuid 时分别使用 +detail/+mindmap/+speaker-insights"},
|
||||
[]string{`dws minutes +upload-and-analyze --file ./meeting.mp3 --title "项目周会"`, `dws minutes +upload-and-analyze --file ./meeting.mp4 --artifacts transcript,summary --mindmap`}), contract.DryRunPreviewPlan, false),
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "file", Type: shortcut.FlagString, Desc: "本地音视频文件;与 --resume-id 二选一"},
|
||||
{Name: "resume-id", Type: shortcut.FlagString, Desc: "先前已成功上传的 taskUuid;只恢复分析、不重复上传"},
|
||||
{Name: "title", Type: shortcut.FlagString, Desc: "听记标题"},
|
||||
{Name: "template-id", Type: shortcut.FlagString, Desc: "纪要模板 ID"},
|
||||
{Name: "input-language", Type: shortcut.FlagString, Desc: "ASR 输入语言"},
|
||||
{Name: "enable-message-card", Type: shortcut.FlagBool, Desc: "生成后推送闪记卡片"},
|
||||
{Name: "complete-timeout", Type: shortcut.FlagInt, Default: "90", Desc: "上传 complete 超时秒数"},
|
||||
{Name: "poll-interval", Type: shortcut.FlagInt, Default: "3", Desc: "轮询间隔秒数"},
|
||||
{Name: "wait-timeout", Type: shortcut.FlagInt, Default: "180", Desc: "等待分析产物秒数"},
|
||||
{Name: "artifacts", Type: shortcut.FlagStringSlice, Desc: "等待的分析产物", Enum: minutesWorkflowArtifacts},
|
||||
{Name: "page-limit", Type: shortcut.FlagInt, Default: "100", Desc: "逐字稿翻页上限"},
|
||||
{Name: "mindmap", Type: shortcut.FlagBool, Desc: "继续创建并等待思维导图"},
|
||||
{Name: "speaker-insights", Type: shortcut.FlagBool, Desc: "继续创建并等待发言人总结"},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{
|
||||
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"file", "resume-id"}},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"complete-timeout", "wait-timeout", "poll-interval", "page-limit"}, Description: "上传超时、分析超时、轮询间隔和逐字稿页数上限必须大于 0"},
|
||||
},
|
||||
Tips: []string{`dws minutes +upload-and-analyze --file ./meeting.mp3 --title "项目周会"`, `dws minutes +upload-and-analyze --file ./meeting.mp4 --artifacts transcript,summary --mindmap`},
|
||||
Validate: func(rt *shortcut.RuntimeContext) error {
|
||||
if rt.Int("complete-timeout") <= 0 {
|
||||
return apperrors.NewValidation("--complete-timeout 必须大于 0")
|
||||
}
|
||||
return validateMinutesWorkflowWait(rt)
|
||||
},
|
||||
Execute: executeMinutesUploadAndAnalyze,
|
||||
}
|
||||
|
||||
var Mindmap = shortcut.Shortcut{
|
||||
Service: "minutes", Command: "+mindmap", Product: "minutes",
|
||||
Description: "创建听记思维导图并轮询到明确成功、失败或超时",
|
||||
Intent: "已有 taskUuid 且需要思维导图时使用;创建只执行一次,随后只轮询 taskStatus,失败/超时均返回非零和可继续查询的 taskUuid。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"},
|
||||
Contract: withMinutesDryRun(minutesContract("+mindmap", "创建听记思维导图并轮询到明确成功、失败或超时",
|
||||
"听记内容已就绪,需要触发并等待平台思维导图产物时使用",
|
||||
[]string{"只查询已经触发的任务状态时使用原子 mind-graph status;短音频或无有效内容可能明确失败"},
|
||||
[]string{`dws minutes +mindmap --id <taskUuid>`, `dws minutes +mindmap --id <taskUuid> --timeout 120 --interval 3`}), contract.DryRunPreviewPlan, false),
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "id", Type: shortcut.FlagString, Desc: "听记 taskUuid", Required: true},
|
||||
{Name: "timeout", Type: shortcut.FlagInt, Default: "90", Desc: "等待秒数"},
|
||||
{Name: "interval", Type: shortcut.FlagInt, Default: "3", Desc: "轮询间隔秒数"},
|
||||
{Name: "resume", Type: shortcut.FlagBool, Desc: "只继续轮询,不重复创建任务"},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"timeout", "interval"}, Description: "--timeout 和 --interval 必须大于 0"}},
|
||||
Tips: []string{`dws minutes +mindmap --id <taskUuid>`, `dws minutes +mindmap --id <taskUuid> --timeout 120 --interval 3`},
|
||||
Validate: validateMinutesPolling,
|
||||
Execute: executeMinutesMindmap,
|
||||
}
|
||||
|
||||
var SpeakerInsights = shortcut.Shortcut{
|
||||
Service: "minutes", Command: "+speaker-insights", Product: "minutes",
|
||||
Description: "创建发言人段落总结并轮询结果,保留异步任务恢复句柄",
|
||||
Intent: "需要按发言人汇总听记内容时使用;严格要求 create 返回 taskId,读取未就绪时有界重试,失败或超时返回 taskId/taskUuid。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"},
|
||||
Contract: withMinutesDryRun(minutesContract("+speaker-insights", "创建发言人段落总结并轮询结果,保留异步任务恢复句柄",
|
||||
"逐字稿已有多位发言人,需要触发并读取平台发言人段落总结时使用",
|
||||
[]string{"只改发言人昵称时使用 +speaker-replace;无有效发言内容时平台可能不生成结果"},
|
||||
[]string{`dws minutes +speaker-insights --id <taskUuid>`, `dws minutes +speaker-insights --id <taskUuid> --timeout 180`}), contract.DryRunPreviewPlan, false),
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "id", Type: shortcut.FlagString, Desc: "听记 taskUuid", Required: true},
|
||||
{Name: "timeout", Type: shortcut.FlagInt, Default: "180", Desc: "等待秒数"},
|
||||
{Name: "interval", Type: shortcut.FlagInt, Default: "3", Desc: "轮询间隔秒数"},
|
||||
{Name: "resume", Type: shortcut.FlagBool, Desc: "只继续轮询,不重复创建任务"},
|
||||
{Name: "task-id", Type: shortcut.FlagString, Desc: "先前 create 返回的异步 taskId,恢复时用于输出追踪"},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"timeout", "interval"}, Description: "--timeout 和 --interval 必须大于 0"}},
|
||||
Tips: []string{`dws minutes +speaker-insights --id <taskUuid>`, `dws minutes +speaker-insights --id <taskUuid> --timeout 180`},
|
||||
Validate: validateMinutesPolling,
|
||||
Execute: executeMinutesSpeakerInsights,
|
||||
}
|
||||
|
||||
var PrepareASR = shortcut.Shortcut{
|
||||
Service: "minutes", Command: "+prepare-asr", Product: "minutes",
|
||||
Description: "读取个人热词、计算差异、按需新增并可显式同步删除后读回验证",
|
||||
Intent: "录音/上传前准备 ASR 专有词表;默认只增加缺失热词,只有 --sync 才删除未出现在目标集合中的现有热词。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "idempotent"},
|
||||
Contract: withMinutesDryRun(minutesContract("+prepare-asr", "按需新增个人热词并可显式同步删除后读回验证;dry-run 不访问远端",
|
||||
"录音或上传前要补充人名、项目名等 ASR 热词,并希望看到差异和最终读回验证时使用",
|
||||
[]string{"只查看现有热词时使用原子 hot-word list;不明确接受删除时不要使用 --sync"},
|
||||
[]string{`dws minutes +prepare-asr --words "DWS,听记"`, `dws minutes +prepare-asr --words "DWS,听记" --sync`}), contract.DryRunPreviewPlan, false),
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "words", Type: shortcut.FlagStringSlice, Desc: "目标热词,逗号分隔", Required: true},
|
||||
{Name: "sync", Type: shortcut.FlagBool, Desc: "删除目标集合之外的现有热词"},
|
||||
},
|
||||
Tips: []string{`dws minutes +prepare-asr --words "DWS,听记"`, `dws minutes +prepare-asr --words "DWS,听记" --sync`},
|
||||
Execute: executeMinutesPrepareASR,
|
||||
}
|
||||
|
||||
var ExportPack = shortcut.Shortcut{
|
||||
Service: "minutes", Command: "+export-pack", Product: "minutes",
|
||||
Description: "把完整听记产物写入受控目录并生成不含签名 URL 的 manifest",
|
||||
Intent: "需要离线归档 basic/summary/keywords/transcript/todos,可选媒体文件时使用;全部必需产物验证通过后才原子发布目录。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
|
||||
Contract: minutesContract("+export-pack", "把完整听记产物写入受控目录并生成不含签名 URL 的 manifest",
|
||||
"已知 taskUuid,需要把多个已验证产物和完整性 manifest 安全归档到工作目录时使用",
|
||||
[]string{"只下载媒体时使用 +download;目标目录已存在时本命令拒绝覆盖"},
|
||||
[]string{`dws minutes +export-pack --id <taskUuid> --output ./minutes-export`, `dws minutes +export-pack --id <taskUuid> --output ./minutes-export --include-media`}),
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "id", Type: shortcut.FlagString, Desc: "听记 taskUuid", Required: true},
|
||||
{Name: "output", Type: shortcut.FlagString, Desc: "工作目录内的新归档目录", Required: true},
|
||||
{Name: "artifacts", Type: shortcut.FlagStringSlice, Desc: "要导出的产物", Enum: minutesWorkflowArtifacts},
|
||||
{Name: "include-media", Type: shortcut.FlagBool, Desc: "同时下载音视频"},
|
||||
{Name: "page-limit", Type: shortcut.FlagInt, Default: "100", Desc: "逐字稿翻页上限"},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"output"}, Description: "输出必须是安全的工作目录相对路径"},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-limit"}, Description: "--page-limit 必须大于 0"},
|
||||
},
|
||||
Tips: []string{`dws minutes +export-pack --id <taskUuid> --output ./minutes-export`, `dws minutes +export-pack --id <taskUuid> --output ./minutes-export --include-media`},
|
||||
Validate: func(rt *shortcut.RuntimeContext) error {
|
||||
if rt.Int("page-limit") <= 0 {
|
||||
return apperrors.NewValidation("--page-limit 必须大于 0")
|
||||
}
|
||||
return localio.ValidateOutput(rt.Str("output"))
|
||||
},
|
||||
Execute: executeMinutesExportPack,
|
||||
}
|
||||
|
||||
var Share = shortcut.Shortcut{
|
||||
Service: "minutes", Command: "+share", Product: "minutes",
|
||||
Description: "按成员逐项授予一个或多个听记权限,输出可审计的部分写入 ledger",
|
||||
Intent: "所有者已确认成员钉钉 UID 和权限,需批量授权时使用;逐成员调用以区分成功/失败,默认首错停止。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"},
|
||||
Contract: withMinutesDryRun(minutesContract("+share", "按成员逐项授予一个或多个听记权限,输出可审计的部分写入 ledger",
|
||||
"听记所有者已确认稳定 member UID,需要授予 view/download/edit 权限并审计每个成员结果时使用",
|
||||
[]string{"当前用户自己申请权限时使用 +apply-permission;只有姓名而无稳定 UID 时先用 contact 命令消歧"},
|
||||
[]string{`dws minutes +share --ids <uuid1,uuid2> --member-uids <uid1,uid2> --permission view`, `dws minutes +share --id <uuid> --member-uids <uid> --permission edit --cover`}), contract.DryRunPreviewPlan, false),
|
||||
Flags: minutesShareFlags(true),
|
||||
Constraints: []shortcut.Constraint{
|
||||
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"id", "ids"}},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"id", "ids"}, Description: "听记去重后必须为 1..50 个"},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"member-uids"}, Description: "成员 UID 去重后必须为 1..50 个"},
|
||||
},
|
||||
Tips: []string{`dws minutes +share --ids <uuid1,uuid2> --member-uids <uid1,uid2> --permission view`, `dws minutes +share --id <uuid> --member-uids <uid> --permission edit --cover`},
|
||||
Validate: validateMinutesShare,
|
||||
Execute: executeMinutesShare,
|
||||
}
|
||||
|
||||
var Unshare = shortcut.Shortcut{
|
||||
Service: "minutes", Command: "+unshare", Product: "minutes",
|
||||
Description: "按成员逐项移除一个或多个听记权限,输出可审计的部分写入 ledger",
|
||||
Intent: "所有者明确要撤销稳定成员 UID 的听记访问时使用;默认首错停止,任何部分失败都返回非零。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"},
|
||||
Contract: withMinutesDryRun(minutesContract("+unshare", "按成员逐项移除一个或多个听记权限,输出可审计的部分写入 ledger",
|
||||
"听记所有者已确认稳定 member UID,需要撤销其对一个或多个听记的访问权限时使用",
|
||||
[]string{"要授权时使用 +share;成员或听记 ID 未确认时不要撤销"},
|
||||
[]string{`dws minutes +unshare --ids <uuid1,uuid2> --member-uids <uid1,uid2>`, `dws minutes +unshare --id <uuid> --member-uids <uid> --failure-policy continue`}), contract.DryRunPreviewPlan, false),
|
||||
Flags: minutesShareFlags(false),
|
||||
Constraints: []shortcut.Constraint{
|
||||
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"id", "ids"}},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"id", "ids"}, Description: "听记去重后必须为 1..50 个"},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"member-uids"}, Description: "成员 UID 去重后必须为 1..50 个"},
|
||||
},
|
||||
Tips: []string{`dws minutes +unshare --ids <uuid1,uuid2> --member-uids <uid1,uid2>`, `dws minutes +unshare --id <uuid> --member-uids <uid> --failure-policy continue`},
|
||||
Validate: validateMinutesShare,
|
||||
Execute: executeMinutesUnshare,
|
||||
}
|
||||
|
||||
func validateMinutesWorkflowWait(rt *shortcut.RuntimeContext) error {
|
||||
if rt.Int("wait-timeout") <= 0 || rt.Int("poll-interval") <= 0 || rt.Int("page-limit") <= 0 {
|
||||
return apperrors.NewValidation("等待超时、轮询间隔和逐字稿页数上限必须大于 0")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateMinutesPolling(rt *shortcut.RuntimeContext) error {
|
||||
if rt.Int("timeout") <= 0 || rt.Int("interval") <= 0 {
|
||||
return apperrors.NewValidation("--timeout 和 --interval 必须大于 0")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func selectedWorkflowArtifacts(rt *shortcut.RuntimeContext) []string {
|
||||
selected := rt.StrSlice("artifacts")
|
||||
if len(selected) == 0 {
|
||||
return append([]string(nil), minutesWorkflowArtifacts...)
|
||||
}
|
||||
return uniqueStrings(selected)
|
||||
}
|
||||
|
||||
func executeMinutesRecordWrapUp(rt *shortcut.RuntimeContext) error {
|
||||
id := rt.Str("id")
|
||||
artifacts := selectedWorkflowArtifacts(rt)
|
||||
if rt.DryRun() {
|
||||
return rt.Output(minutesDryRunPayload(contract.DryRunPreviewPlan, "minutes.record_wrap_up", map[string]any{"taskUuid": id, "stages": []string{"record.stop", "artifacts.wait"}, "artifacts": artifacts}))
|
||||
}
|
||||
stopData, err := rt.CallMCPWriteDataStrict("minutes", listeningNoteCmdTool, map[string]any{"cmd": "end", "uuid": id})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := minutesdata.RecordResult("end", id, stopData); err != nil {
|
||||
return err
|
||||
}
|
||||
bundle, failures, attempts := waitMinutesArtifacts(rt, id, artifacts, rt.Int("page-limit"), time.Duration(rt.Int("wait-timeout"))*time.Second, time.Duration(rt.Int("poll-interval"))*time.Second)
|
||||
payload := map[string]any{
|
||||
"operation": "minutes.record_wrap_up", "complete": len(failures) == 0, "taskUuid": id,
|
||||
"stages": []map[string]any{{"name": "record.stop", "complete": true}, {"name": "artifacts.wait", "complete": len(failures) == 0, "attempts": attempts}},
|
||||
"artifacts": bundle, "failures": failures,
|
||||
}
|
||||
if len(failures) > 0 {
|
||||
payload["recovery"] = map[string]any{"taskUuid": id, "nextAction": "dws minutes +detail --id <taskUuid>"}
|
||||
}
|
||||
return outputWorkflowResult(rt, payload, len(failures) > 0, "minutes_record_wrap_up_incomplete", "artifacts")
|
||||
}
|
||||
|
||||
func executeMinutesUploadAndAnalyze(rt *shortcut.RuntimeContext) error {
|
||||
artifacts := selectedWorkflowArtifacts(rt)
|
||||
if rt.DryRun() {
|
||||
upload := map[string]any{"operation": "minutes.upload", "resume": rt.Str("resume-id") != "", "taskUuid": rt.Str("resume-id"), "executed": false}
|
||||
if rt.Str("resume-id") == "" {
|
||||
var err error
|
||||
upload, err = performMinutesUpload(rt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return rt.Output(minutesDryRunPayload(contract.DryRunPreviewPlan, "minutes.upload_and_analyze", map[string]any{"upload": upload, "artifacts": artifacts, "mindmap": rt.Bool("mindmap"), "speakerInsights": rt.Bool("speaker-insights")}))
|
||||
}
|
||||
id := strings.TrimSpace(rt.Str("resume-id"))
|
||||
upload := map[string]any{"operation": "minutes.upload", "complete": true, "resumed": true, "taskUuid": id, "verified": true}
|
||||
if id == "" {
|
||||
var err error
|
||||
upload, err = performMinutesUpload(rt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id, _ = upload["taskUuid"].(string)
|
||||
}
|
||||
bundle, failures, attempts := waitMinutesArtifacts(rt, id, artifacts, rt.Int("page-limit"), time.Duration(rt.Int("wait-timeout"))*time.Second, time.Duration(rt.Int("poll-interval"))*time.Second)
|
||||
stages := []map[string]any{{"name": "upload", "complete": true, "result": upload}, {"name": "artifacts.wait", "complete": len(failures) == 0, "attempts": attempts}}
|
||||
if rt.Bool("mindmap") {
|
||||
result, runErr := runMinutesMindmap(rt, id, time.Duration(rt.Int("wait-timeout"))*time.Second, time.Duration(rt.Int("poll-interval"))*time.Second, true)
|
||||
stage := map[string]any{"name": "mindmap", "complete": runErr == nil, "result": result}
|
||||
if runErr != nil {
|
||||
stage["error"] = runErr.Error()
|
||||
failures = append(failures, map[string]any{"artifact": "mindmap", "error": runErr.Error()})
|
||||
}
|
||||
stages = append(stages, stage)
|
||||
}
|
||||
if rt.Bool("speaker-insights") {
|
||||
result, runErr := runMinutesSpeakerInsights(rt, id, time.Duration(rt.Int("wait-timeout"))*time.Second, time.Duration(rt.Int("poll-interval"))*time.Second, true, "")
|
||||
stage := map[string]any{"name": "speaker-insights", "complete": runErr == nil, "result": result}
|
||||
if runErr != nil {
|
||||
stage["error"] = runErr.Error()
|
||||
failures = append(failures, map[string]any{"artifact": "speaker-insights", "error": runErr.Error()})
|
||||
}
|
||||
stages = append(stages, stage)
|
||||
}
|
||||
payload := map[string]any{"operation": "minutes.upload_and_analyze", "complete": len(failures) == 0, "taskUuid": id, "stages": stages, "artifacts": bundle, "failures": failures}
|
||||
if len(failures) > 0 {
|
||||
payload["recovery"] = map[string]any{"taskUuid": id, "nextAction": "resume analysis with +detail/+mindmap/+speaker-insights; do not upload again"}
|
||||
}
|
||||
return outputWorkflowResult(rt, payload, len(failures) > 0, "minutes_upload_analysis_incomplete", "analysis")
|
||||
}
|
||||
|
||||
func executeMinutesMindmap(rt *shortcut.RuntimeContext) error {
|
||||
if rt.DryRun() {
|
||||
return rt.Output(minutesDryRunPayload(contract.DryRunPreviewPlan, "minutes.mindmap", map[string]any{"taskUuid": rt.Str("id"), "stages": []string{"create", "poll"}}))
|
||||
}
|
||||
payload, err := runMinutesMindmap(rt, rt.Str("id"), time.Duration(rt.Int("timeout"))*time.Second, time.Duration(rt.Int("interval"))*time.Second, !rt.Bool("resume"))
|
||||
if err := rt.Output(payload); err != nil {
|
||||
return err
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func runMinutesMindmap(rt *shortcut.RuntimeContext, id string, timeout, interval time.Duration, create bool) (map[string]any, error) {
|
||||
createAcknowledged := false
|
||||
if create {
|
||||
// create_mind_graph is observed to return either an explicit success
|
||||
// envelope or an empty acknowledgement. Empty alone is never success;
|
||||
// continue only so the status read-back can prove the final effect.
|
||||
created, err := rt.CallMCPWriteData("minutes", "create_mind_graph", map[string]any{"taskUuid": id})
|
||||
if err != nil {
|
||||
return map[string]any{"operation": "minutes.mindmap", "complete": false, "taskUuid": id, "stage": "create"}, err
|
||||
}
|
||||
createAcknowledged = minutesdata.RequireWriteAcknowledgement("mind graph create", created) == nil
|
||||
}
|
||||
deadline := time.Now().Add(timeout)
|
||||
attempts := 0
|
||||
for {
|
||||
attempts++
|
||||
data, callErr := rt.CallMCPData("minutes", "query_mind_graph_status", map[string]any{"taskUuid": id})
|
||||
if callErr != nil {
|
||||
return map[string]any{"operation": "minutes.mindmap", "complete": false, "taskUuid": id, "stage": "poll", "attempts": attempts, "recovery": map[string]any{"taskUuid": id, "nextAction": "dws minutes mind-graph status --id <taskUuid>"}}, callErr
|
||||
}
|
||||
status, result, parseErr := minutesdata.MindGraphStatus(data)
|
||||
if parseErr != nil {
|
||||
return map[string]any{"operation": "minutes.mindmap", "complete": false, "taskUuid": id, "stage": "poll", "attempts": attempts}, parseErr
|
||||
}
|
||||
if status == 1 {
|
||||
return map[string]any{"operation": "minutes.mindmap", "complete": true, "taskUuid": id, "taskStatus": status, "attempts": attempts, "createAttempted": create, "createAcknowledged": createAcknowledged, "verified": true, "result": result}, nil
|
||||
}
|
||||
if status == 2 {
|
||||
payload := map[string]any{"operation": "minutes.mindmap", "complete": false, "taskUuid": id, "taskStatus": status, "attempts": attempts, "result": result, "recovery": map[string]any{"taskUuid": id, "nextAction": "inspect source transcript; do not assume an empty mind map"}}
|
||||
return payload, minutesCompositeError("minutes_mindmap_failed", "poll", payload)
|
||||
}
|
||||
if time.Now().Add(interval).After(deadline) {
|
||||
payload := map[string]any{"operation": "minutes.mindmap", "complete": false, "taskUuid": id, "taskStatus": status, "attempts": attempts, "recovery": map[string]any{"taskUuid": id, "nextAction": "dws minutes mind-graph status --id <taskUuid>"}}
|
||||
return payload, minutesCompositeError("minutes_mindmap_timeout", "poll", payload)
|
||||
}
|
||||
if err := waitMinutesInterval(rt, interval); err != nil {
|
||||
return map[string]any{"operation": "minutes.mindmap", "complete": false, "taskUuid": id, "attempts": attempts}, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func executeMinutesSpeakerInsights(rt *shortcut.RuntimeContext) error {
|
||||
if rt.DryRun() {
|
||||
return rt.Output(minutesDryRunPayload(contract.DryRunPreviewPlan, "minutes.speaker_insights", map[string]any{"taskUuid": rt.Str("id"), "stages": []string{"create", "poll"}}))
|
||||
}
|
||||
payload, err := runMinutesSpeakerInsights(rt, rt.Str("id"), time.Duration(rt.Int("timeout"))*time.Second, time.Duration(rt.Int("interval"))*time.Second, !rt.Bool("resume"), rt.Str("task-id"))
|
||||
if outputErr := rt.Output(payload); outputErr != nil {
|
||||
return outputErr
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func runMinutesSpeakerInsights(rt *shortcut.RuntimeContext, id string, timeout, interval time.Duration, create bool, taskID string) (map[string]any, error) {
|
||||
status := "resume"
|
||||
if create {
|
||||
created, err := rt.CallMCPWriteDataStrict("minutes", "create_speaker_summary", map[string]any{"uuids": []string{id}})
|
||||
if err != nil {
|
||||
return map[string]any{"operation": "minutes.speaker_insights", "complete": false, "taskUuid": id, "stage": "create"}, err
|
||||
}
|
||||
var parseErr error
|
||||
taskID, status, parseErr = minutesdata.SpeakerSummaryTask(created)
|
||||
if parseErr != nil {
|
||||
return map[string]any{"operation": "minutes.speaker_insights", "complete": false, "taskUuid": id, "stage": "create"}, parseErr
|
||||
}
|
||||
}
|
||||
deadline := time.Now().Add(timeout)
|
||||
attempts := 0
|
||||
for {
|
||||
attempts++
|
||||
data, callErr := rt.CallMCPData("minutes", "get_speaker_summary", map[string]any{"uuids": []string{id}})
|
||||
if callErr == nil {
|
||||
result, resultErr := minutesdata.SpeakerSummaryResult(data)
|
||||
if resultErr == nil {
|
||||
return map[string]any{"operation": "minutes.speaker_insights", "complete": true, "taskUuid": id, "taskId": taskID, "createStatus": status, "attempts": attempts, "result": result}, nil
|
||||
}
|
||||
callErr = resultErr
|
||||
}
|
||||
if !speakerSummaryPending(callErr) {
|
||||
payload := map[string]any{"operation": "minutes.speaker_insights", "complete": false, "taskUuid": id, "taskId": taskID, "attempts": attempts, "stage": "poll", "recovery": map[string]any{"taskUuid": id, "taskId": taskID, "nextAction": "dws minutes speaker summary get --ids <taskUuid>"}}
|
||||
return payload, callErr
|
||||
}
|
||||
if time.Now().Add(interval).After(deadline) {
|
||||
payload := map[string]any{"operation": "minutes.speaker_insights", "complete": false, "taskUuid": id, "taskId": taskID, "attempts": attempts, "stage": "poll", "recovery": map[string]any{"taskUuid": id, "taskId": taskID, "nextAction": "dws minutes speaker summary get --ids <taskUuid>"}}
|
||||
return payload, minutesCompositeError("minutes_speaker_insights_timeout", "poll", payload)
|
||||
}
|
||||
if err := waitMinutesInterval(rt, interval); err != nil {
|
||||
return map[string]any{"operation": "minutes.speaker_insights", "complete": false, "taskUuid": id, "taskId": taskID, "attempts": attempts}, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func speakerSummaryPending(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
message := strings.ToLower(err.Error())
|
||||
return strings.Contains(message, "query empty") || strings.Contains(message, "processing") || strings.Contains(message, "not ready") || strings.Contains(message, "result is empty") || strings.Contains(message, "business error: code 000") || strings.Contains(message, "暂无")
|
||||
}
|
||||
|
||||
func executeMinutesPrepareASR(rt *shortcut.RuntimeContext) error {
|
||||
desired := uniqueStrings(rt.StrSlice("words"))
|
||||
if rt.DryRun() {
|
||||
return rt.Output(minutesDryRunPayload(contract.DryRunPreviewPlan, "minutes.prepare_asr", map[string]any{
|
||||
"desired": desired, "sync": rt.Bool("sync"), "remotePreconditions": []string{"read current hot words", "compute additions and deletions", "apply writes", "read back exact set"},
|
||||
}))
|
||||
}
|
||||
currentData, err := rt.CallMCPData("minutes", "list_my_hotwords", map[string]any{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
current, err := minutesdata.HotWords(currentData)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toAdd := stringDifference(desired, current)
|
||||
toDelete := []string{}
|
||||
if rt.Bool("sync") {
|
||||
toDelete = stringDifference(current, desired)
|
||||
}
|
||||
plan := map[string]any{"operation": "minutes.prepare_asr", "before": current, "desired": desired, "add": toAdd, "delete": toDelete, "sync": rt.Bool("sync")}
|
||||
stages := []map[string]any{}
|
||||
if len(toAdd) > 0 {
|
||||
data, writeErr := rt.CallMCPWriteDataStrict("minutes", "add_personal_hot_word", map[string]any{"hotWordList": toAdd})
|
||||
if writeErr == nil {
|
||||
writeErr = minutesdata.RequireWriteAcknowledgement("hot-word add", data)
|
||||
}
|
||||
stages = append(stages, map[string]any{"name": "add", "complete": writeErr == nil, "words": toAdd})
|
||||
if writeErr != nil {
|
||||
plan["complete"], plan["stages"], plan["recovery"] = false, stages, map[string]any{"nextAction": "review current hot words before retrying"}
|
||||
return outputWorkflowResult(rt, plan, true, "minutes_prepare_asr_add_failed", "add")
|
||||
}
|
||||
}
|
||||
if len(toDelete) > 0 {
|
||||
data, writeErr := rt.CallMCPWriteDataStrict("minutes", "delete_personal_hotword", map[string]any{"hotWordList": toDelete})
|
||||
if writeErr == nil {
|
||||
writeErr = minutesdata.RequireWriteAcknowledgement("hot-word delete", data)
|
||||
}
|
||||
stages = append(stages, map[string]any{"name": "delete", "complete": writeErr == nil, "words": toDelete})
|
||||
if writeErr != nil {
|
||||
plan["complete"], plan["stages"], plan["recovery"] = false, stages, map[string]any{"nextAction": "list hot words; additions may already be applied"}
|
||||
return outputWorkflowResult(rt, plan, true, "minutes_prepare_asr_delete_failed", "delete")
|
||||
}
|
||||
}
|
||||
verifiedData, err := rt.CallMCPData("minutes", "list_my_hotwords", map[string]any{})
|
||||
if err != nil {
|
||||
plan["complete"], plan["stages"] = false, stages
|
||||
if outputErr := rt.Output(plan); outputErr != nil {
|
||||
return outputErr
|
||||
}
|
||||
return err
|
||||
}
|
||||
verified, err := minutesdata.HotWords(verifiedData)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
missing := stringDifference(desired, verified)
|
||||
unexpected := []string{}
|
||||
if rt.Bool("sync") {
|
||||
unexpected = stringDifference(verified, desired)
|
||||
}
|
||||
plan["complete"], plan["verified"], plan["after"], plan["stages"] = len(missing) == 0 && len(unexpected) == 0, len(missing) == 0 && len(unexpected) == 0, verified, stages
|
||||
if len(missing) > 0 || len(unexpected) > 0 {
|
||||
plan["missing"], plan["unexpected"] = missing, unexpected
|
||||
return outputWorkflowResult(rt, plan, true, "minutes_prepare_asr_verification_failed", "verify")
|
||||
}
|
||||
return rt.Output(plan)
|
||||
}
|
||||
|
||||
func executeMinutesExportPack(rt *shortcut.RuntimeContext) error {
|
||||
id := rt.Str("id")
|
||||
artifacts := selectedWorkflowArtifacts(rt)
|
||||
bundle, failures := collectMinutesArtifactsOnce(rt, id, artifacts, rt.Int("page-limit"))
|
||||
if len(failures) > 0 {
|
||||
payload := map[string]any{"operation": "minutes.export_pack", "complete": false, "taskUuid": id, "failures": failures, "published": false}
|
||||
return outputWorkflowResult(rt, payload, true, "minutes_export_pack_incomplete", "collect")
|
||||
}
|
||||
target, relative, parent, err := prepareExportTarget(rt.Str("output"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tempDir, err := minutesMkdirTemp(parent, ".dws-minutes-export-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cleanup := true
|
||||
defer func() {
|
||||
if cleanup {
|
||||
_ = minutesRemoveAll(tempDir)
|
||||
}
|
||||
}()
|
||||
files := map[string]map[string]any{}
|
||||
for _, name := range artifacts {
|
||||
filename := name + ".json"
|
||||
value := bundle[name]
|
||||
if name == "summary" {
|
||||
filename = "summary.md"
|
||||
if err := minutesWriteFile(filepath.Join(tempDir, filename), []byte(value.(string)), 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if err := writeJSONFile(filepath.Join(tempDir, filename), value); err != nil {
|
||||
return err
|
||||
}
|
||||
info, err := minutesStat(filepath.Join(tempDir, filename))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
files[name] = map[string]any{"file": filename, "sizeBytes": info.Size(), "complete": true}
|
||||
}
|
||||
if rt.Bool("include-media") {
|
||||
mediaData, callErr := rt.CallMCPData("minutes", "query_minutes_audio_url", map[string]any{"taskUuid": id})
|
||||
if callErr != nil {
|
||||
return callErr
|
||||
}
|
||||
mediaURL, parseErr := minutesdata.MediaURL(mediaData)
|
||||
if parseErr != nil {
|
||||
return parseErr
|
||||
}
|
||||
download, downloadErr := minutesDownload(rt.Command().Context(), mediaURL, localio.DownloadOptions{BaseDir: tempDir, Output: "media/", PreferredName: id + mediaExtension(mediaURL)})
|
||||
if downloadErr != nil {
|
||||
return downloadErr
|
||||
}
|
||||
files["media"] = map[string]any{"file": download.RelativePath, "sizeBytes": download.SizeBytes, "complete": true}
|
||||
}
|
||||
manifest := map[string]any{"version": 1, "operation": "minutes.export_pack", "taskUuid": id, "complete": true, "generatedAt": time.Now().UTC().Format(time.RFC3339), "files": files}
|
||||
if err := writeJSONFile(filepath.Join(tempDir, "manifest.json"), manifest); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := minutesRename(tempDir, target); err != nil {
|
||||
return fmt.Errorf("发布听记归档目录失败: %w", err)
|
||||
}
|
||||
cleanup = false
|
||||
return rt.Output(map[string]any{"operation": "minutes.export_pack", "complete": true, "taskUuid": id, "published": true, "path": filepath.ToSlash(relative), "manifest": filepath.ToSlash(filepath.Join(relative, "manifest.json")), "files": files})
|
||||
}
|
||||
|
||||
func minutesShareFlags(includePermission bool) []shortcut.Flag {
|
||||
flags := []shortcut.Flag{
|
||||
{Name: "id", Type: shortcut.FlagString, Desc: "单个听记 taskUuid"},
|
||||
{Name: "ids", Type: shortcut.FlagStringSlice, Desc: "多个听记 taskUuid,最多 50 个"},
|
||||
{Name: "member-uids", Type: shortcut.FlagStringSlice, Desc: "稳定成员钉钉 UID,最多 50 个", Required: true},
|
||||
}
|
||||
if includePermission {
|
||||
flags = append(flags,
|
||||
shortcut.Flag{Name: "permission", Type: shortcut.FlagString, Desc: "授予权限", Required: true, Enum: []string{"view", "download", "edit"}},
|
||||
shortcut.Flag{Name: "cover", Type: shortcut.FlagBool, Desc: "覆盖已有权限"},
|
||||
shortcut.Flag{Name: "sub-resources", Type: shortcut.FlagStringSlice, Desc: "可选子资源", Enum: []string{"OrigContent", "Summary", "Analysis", "Note"}},
|
||||
)
|
||||
}
|
||||
return append(flags, shortcut.Flag{Name: "failure-policy", Type: shortcut.FlagString, Default: "stop", Desc: "成员失败策略", Enum: []string{"stop", "continue"}})
|
||||
}
|
||||
|
||||
func validateMinutesShare(rt *shortcut.RuntimeContext) error {
|
||||
ids, members := minutesIDs(rt), uniqueStrings(rt.StrSlice("member-uids"))
|
||||
if len(ids) == 0 || len(ids) > 50 || len(members) == 0 || len(members) > 50 {
|
||||
return apperrors.NewValidation("听记和成员 UID 数量必须各为 1..50")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func executeMinutesShare(rt *shortcut.RuntimeContext) error {
|
||||
policy := map[string]float64{"edit": 2, "download": 3, "view": 4}[rt.Str("permission")]
|
||||
return executeMinutesPermissionLedger(rt, "share", "add_member_permission", func(member string) map[string]any {
|
||||
params := map[string]any{"uuids": minutesIDs(rt), "memberUids": []string{member}, "policyId": policy}
|
||||
if rt.Changed("cover") {
|
||||
params["coverPermission"] = fmt.Sprintf("%t", rt.Bool("cover"))
|
||||
}
|
||||
if values := rt.StrSlice("sub-resources"); len(values) > 0 {
|
||||
params["roleSubResourceIds"] = values
|
||||
}
|
||||
return params
|
||||
})
|
||||
}
|
||||
|
||||
func executeMinutesUnshare(rt *shortcut.RuntimeContext) error {
|
||||
return executeMinutesPermissionLedger(rt, "unshare", "remove_member_permission", func(member string) map[string]any {
|
||||
return map[string]any{"uuids": minutesIDs(rt), "memberUids": []string{member}}
|
||||
})
|
||||
}
|
||||
|
||||
func executeMinutesPermissionLedger(rt *shortcut.RuntimeContext, operation, tool string, params func(string) map[string]any) error {
|
||||
members := uniqueStrings(rt.StrSlice("member-uids"))
|
||||
plan := map[string]any{"operation": "minutes." + operation, "taskUuids": minutesIDs(rt), "memberCount": len(members), "members": members}
|
||||
if rt.DryRun() {
|
||||
return rt.Output(minutesDryRunPayload(contract.DryRunPreviewPlan, "minutes."+operation, plan))
|
||||
}
|
||||
results := []map[string]any{}
|
||||
failures := []map[string]any{}
|
||||
unattempted := []string{}
|
||||
for index, member := range members {
|
||||
data, err := rt.CallMCPWriteDataStrict("minutes", tool, params(member))
|
||||
if err == nil {
|
||||
err = minutesdata.RequireWriteAcknowledgement(operation, data)
|
||||
}
|
||||
if err != nil {
|
||||
failures = append(failures, map[string]any{"memberUid": member, "error": err.Error()})
|
||||
if rt.Str("failure-policy") == "stop" {
|
||||
unattempted = append(unattempted, members[index+1:]...)
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
results = append(results, map[string]any{"memberUid": member, "complete": true})
|
||||
}
|
||||
plan["complete"], plan["succeeded"], plan["failed"], plan["unattempted"], plan["results"], plan["failures"] = len(failures) == 0, len(results), len(failures), unattempted, results, failures
|
||||
return outputWorkflowResult(rt, plan, len(failures) > 0, "minutes_permission_partial", operation)
|
||||
}
|
||||
|
||||
func collectMinutesArtifactsOnce(rt *shortcut.RuntimeContext, id string, artifacts []string, pageLimit int) (map[string]any, []map[string]any) {
|
||||
bundle := map[string]any{}
|
||||
failures := []map[string]any{}
|
||||
for _, artifact := range artifacts {
|
||||
var value any
|
||||
var err error
|
||||
switch artifact {
|
||||
case "basic":
|
||||
var data map[string]any
|
||||
data, err = rt.CallMCPData("minutes", "get_minutes_basic_info", map[string]any{"taskUuid": id})
|
||||
if err == nil {
|
||||
value, err = minutesdata.Basic(id, data)
|
||||
}
|
||||
case "summary":
|
||||
var data map[string]any
|
||||
data, err = rt.CallMCPData("minutes", "get_minutes_ai_summary", map[string]any{"taskUuid": id})
|
||||
if err == nil {
|
||||
value, err = minutesdata.SummaryText(data)
|
||||
if err == nil && strings.TrimSpace(value.(string)) == "" {
|
||||
err = fmt.Errorf("minutes summary is explicitly empty; analysis readiness is not proven")
|
||||
}
|
||||
}
|
||||
case "keywords":
|
||||
var data map[string]any
|
||||
data, err = rt.CallMCPData("minutes", "get_minutes_keywords", map[string]any{"taskUuid": id})
|
||||
if err == nil {
|
||||
err = minutesdata.ValidateArtifact("keywords", id, data)
|
||||
value = data["result"]
|
||||
}
|
||||
case "transcript":
|
||||
var transcript minutesdata.TranscriptResult
|
||||
transcript, err = collectTranscriptForMinutes(rt, id, pageLimit)
|
||||
value = minutesdata.TranscriptPayload(id, "0", transcript)
|
||||
if err == nil && len(transcript.Paragraphs) == 0 {
|
||||
err = fmt.Errorf("minutes transcript is explicitly empty; analysis readiness is not proven")
|
||||
}
|
||||
case "todos":
|
||||
var data map[string]any
|
||||
data, err = rt.CallMCPData("minutes", "list_minutes_todos", map[string]any{"taskUuid": id})
|
||||
if err == nil {
|
||||
err = minutesdata.ValidateArtifact("todos", id, data)
|
||||
value = data["result"]
|
||||
}
|
||||
default:
|
||||
err = fmt.Errorf("unsupported artifact %q", artifact)
|
||||
}
|
||||
if err != nil {
|
||||
failures = append(failures, map[string]any{"artifact": artifact, "error": err.Error()})
|
||||
continue
|
||||
}
|
||||
bundle[artifact] = value
|
||||
}
|
||||
return bundle, failures
|
||||
}
|
||||
|
||||
func waitMinutesArtifacts(rt *shortcut.RuntimeContext, id string, artifacts []string, pageLimit int, timeout, interval time.Duration) (map[string]any, []map[string]any, int) {
|
||||
deadline := time.Now().Add(timeout)
|
||||
attempts := 0
|
||||
for {
|
||||
attempts++
|
||||
bundle, failures := collectMinutesArtifactsOnce(rt, id, artifacts, pageLimit)
|
||||
if len(failures) == 0 || time.Now().Add(interval).After(deadline) {
|
||||
return bundle, failures, attempts
|
||||
}
|
||||
if err := waitMinutesInterval(rt, interval); err != nil {
|
||||
return bundle, append(failures, map[string]any{"artifact": "wait", "error": err.Error()}), attempts
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func waitMinutesInterval(rt *shortcut.RuntimeContext, interval time.Duration) error {
|
||||
timer := time.NewTimer(interval)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-rt.Command().Context().Done():
|
||||
return rt.Command().Context().Err()
|
||||
case <-timer.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func outputWorkflowResult(rt *shortcut.RuntimeContext, payload map[string]any, failed bool, reason, stage string) error {
|
||||
if err := rt.Output(payload); err != nil {
|
||||
return err
|
||||
}
|
||||
if failed {
|
||||
return minutesCompositeError(reason, stage, payload)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func uniqueStrings(values []string) []string {
|
||||
seen := map[string]bool{}
|
||||
out := []string{}
|
||||
for _, value := range values {
|
||||
for _, item := range strings.Split(value, ",") {
|
||||
item = strings.TrimSpace(item)
|
||||
if item != "" && !seen[item] {
|
||||
seen[item] = true
|
||||
out = append(out, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func stringDifference(left, right []string) []string {
|
||||
other := map[string]bool{}
|
||||
for _, value := range right {
|
||||
other[value] = true
|
||||
}
|
||||
out := []string{}
|
||||
for _, value := range left {
|
||||
if !other[value] {
|
||||
out = append(out, value)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func prepareExportTarget(output string) (target, relative, parent string, err error) {
|
||||
if err := localio.ValidateOutput(output); err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
cwd, err := minutesGetwd()
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
realBase, err := minutesEvalSymlinks(cwd)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
relative = filepath.Clean(output)
|
||||
target = filepath.Join(realBase, relative)
|
||||
parent = filepath.Dir(target)
|
||||
rel, err := minutesRel(realBase, target)
|
||||
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||
return "", "", "", fmt.Errorf("LOCAL_PATH_UNSAFE: 输出目录逃逸工作目录")
|
||||
}
|
||||
if err := ensureExportParent(realBase, filepath.Dir(rel)); err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
if _, err := minutesLstat(target); err == nil {
|
||||
return "", "", "", fmt.Errorf("LOCAL_FILE_EXISTS: 目标目录已存在;请选择新的输出路径")
|
||||
} else if !os.IsNotExist(err) {
|
||||
return "", "", "", err
|
||||
}
|
||||
return target, relative, parent, nil
|
||||
}
|
||||
|
||||
func ensureExportParent(base, relative string) error {
|
||||
current := base
|
||||
if relative == "." || relative == "" {
|
||||
return nil
|
||||
}
|
||||
for _, component := range strings.Split(filepath.Clean(relative), string(filepath.Separator)) {
|
||||
current = filepath.Join(current, component)
|
||||
info, err := minutesLstat(current)
|
||||
if os.IsNotExist(err) {
|
||||
if err := minutesMkdir(current, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
|
||||
return fmt.Errorf("LOCAL_PATH_UNSAFE: 输出父路径不是安全目录")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeJSONFile(path string, value any) error {
|
||||
raw, err := minutesMarshalIndent(value, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw = append(raw, '\n')
|
||||
return minutesWriteFile(path, raw, 0o600)
|
||||
}
|
||||
|
||||
func init() {
|
||||
shortcut.Register(finalizeMinutesShortcuts(RecordWrapUp, UploadAndAnalyze, Mindmap, SpeakerInsights, PrepareASR, ExportPack, Share, Unshare)...)
|
||||
}
|
||||
@@ -0,0 +1,667 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package minutes
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/localio"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageMinutesWorkflowValidationAndDefaults(t *testing.T) {
|
||||
file := filepath.Join(t.TempDir(), "source.wav")
|
||||
if err := os.WriteFile(file, []byte("audio"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
invalid := [][]string{
|
||||
{"minutes", "+record-wrap-up", "--id", "u1", "--wait-timeout", "0"},
|
||||
{"minutes", "+upload-and-analyze", "--file", file, "--complete-timeout", "0"},
|
||||
{"minutes", "+mindmap", "--id", "u1", "--timeout", "0"},
|
||||
{"minutes", "+speaker-insights", "--id", "u1", "--interval", "0"},
|
||||
{"minutes", "+export-pack", "--id", "u1", "--output", "pack", "--page-limit", "0"},
|
||||
{"minutes", "+share", "--id", "u1", "--member-uids", strings.Repeat("m,", 51), "--permission", "view"},
|
||||
}
|
||||
for _, args := range invalid {
|
||||
if payload, output, err := runMinutesAlignmentCLI(t, &minutesE2ECaller{}, args...); err == nil || payload != nil || output != "" {
|
||||
t.Fatalf("invalid workflow accepted: %v payload=%#v output=%q err=%v", args, payload, output, err)
|
||||
}
|
||||
}
|
||||
|
||||
caller := &minutesE2ECaller{}
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
cmd := &cobra.Command{Use: "workflow"}
|
||||
cmd.Flags().StringSlice("artifacts", nil, "")
|
||||
rt := shortcut.RuntimeContextForTest(cmd, RecordWrapUp)
|
||||
selected := selectedWorkflowArtifacts(rt)
|
||||
selected[0] = "mutated"
|
||||
if minutesWorkflowArtifacts[0] == "mutated" || len(selected) != len(minutesWorkflowArtifacts) {
|
||||
t.Fatalf("default artifacts alias global: %#v", selected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesRecordWrapUpBranchesE2E(t *testing.T) {
|
||||
if err := runMinutesAlignmentCLIWithWriter(t, &minutesE2ECaller{}, minutesFailWriter{}, "minutes", "+record-wrap-up", "--id", "u1", "--dry-run"); err == nil {
|
||||
t.Fatal("record dry-run output failure accepted")
|
||||
}
|
||||
stopFailure := &minutesE2ECaller{failAt: map[string]int{"minutes/" + listeningNoteCmdTool: 1}}
|
||||
if _, _, err := runMinutesAlignmentCLI(t, stopFailure, "minutes", "+record-wrap-up", "--id", "u1", "--yes"); err == nil {
|
||||
t.Fatal("record stop failure accepted")
|
||||
}
|
||||
badStop := &minutesE2ECaller{responses: map[string][]string{"minutes/" + listeningNoteCmdTool: {`{"success":true,"result":{"cmd":"pause","uuid":"u1"}}`}}}
|
||||
if _, _, err := runMinutesAlignmentCLI(t, badStop, "minutes", "+record-wrap-up", "--id", "u1", "--yes"); err == nil {
|
||||
t.Fatal("record mismatched acknowledgement accepted")
|
||||
}
|
||||
success := &minutesE2ECaller{responses: map[string][]string{
|
||||
"minutes/" + listeningNoteCmdTool: {`{"success":true,"result":{"cmd":"end","uuid":"u1"}}`},
|
||||
"minutes/get_minutes_basic_info": {`{"success":true,"result":{"taskUuid":"u1","title":"done"}}`},
|
||||
}}
|
||||
payload, output, err := runMinutesAlignmentCLI(t, success, "minutes", "+record-wrap-up", "--id", "u1", "--artifacts", "basic", "--yes")
|
||||
if err != nil || output == "" || payload["complete"] != true {
|
||||
t.Fatalf("record success payload=%#v err=%v", payload, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesUploadAndAnalyzeBranchesE2E(t *testing.T) {
|
||||
if err := runMinutesAlignmentCLIWithWriter(t, &minutesE2ECaller{}, minutesFailWriter{}, "minutes", "+upload-and-analyze", "--resume-id", "u1", "--dry-run"); err == nil {
|
||||
t.Fatal("resume dry-run output failure accepted")
|
||||
}
|
||||
if _, _, err := runMinutesAlignmentCLI(t, &minutesE2ECaller{}, "minutes", "+upload-and-analyze", "--file", filepath.Join(t.TempDir(), "missing"), "--yes"); err == nil {
|
||||
t.Fatal("upload-and-analyze invalid file accepted")
|
||||
}
|
||||
if _, _, err := runMinutesAlignmentCLI(t, &minutesE2ECaller{}, "minutes", "+upload-and-analyze", "--file", filepath.Join(t.TempDir(), "missing"), "--dry-run"); err == nil {
|
||||
t.Fatal("upload-and-analyze invalid dry-run file accepted")
|
||||
}
|
||||
|
||||
resume := &minutesE2ECaller{responses: map[string][]string{
|
||||
"minutes/get_minutes_basic_info": {`{"success":true,"result":{"taskUuid":"u1","title":"done"}}`},
|
||||
"minutes/create_mind_graph": {`{"success":true,"result":{}}`},
|
||||
"minutes/query_mind_graph_status": {`{"success":true,"result":{"taskStatus":1,"mindGraph":"ready"}}`},
|
||||
"minutes/create_speaker_summary": {`{"success":true,"result":{"taskId":"job","status":"processing"}}`},
|
||||
"minutes/get_speaker_summary": {`{"success":true,"result":{"summaries":[{"speaker":"a","summary":"b"}]}}`},
|
||||
}}
|
||||
payload, _, err := runMinutesAlignmentCLI(t, resume, "minutes", "+upload-and-analyze", "--resume-id", "u1", "--artifacts", "basic", "--mindmap", "--speaker-insights", "--yes")
|
||||
if err != nil || payload["complete"] != true || payload["taskUuid"] != "u1" {
|
||||
t.Fatalf("resume payload=%#v err=%v", payload, err)
|
||||
}
|
||||
|
||||
partial := &minutesE2ECaller{responses: map[string][]string{
|
||||
"minutes/get_minutes_basic_info": {`{"success":true,"result":{"taskUuid":"u1","title":"done"}}`},
|
||||
"minutes/create_mind_graph": {`{"success":true,"result":{}}`},
|
||||
"minutes/query_mind_graph_status": {`{"success":true,"result":{"taskStatus":2}}`},
|
||||
"minutes/create_speaker_summary": {`{"success":true,"result":{"taskId":"job","status":"processing"}}`},
|
||||
"minutes/get_speaker_summary": {`{"success":false,"errorMsg":"denied"}`},
|
||||
}}
|
||||
payload, output, err := runMinutesAlignmentCLI(t, partial, "minutes", "+upload-and-analyze", "--resume-id", "u1", "--artifacts", "basic", "--mindmap", "--speaker-insights", "--yes")
|
||||
if err == nil || output == "" || payload["complete"] != false || payload["recovery"] == nil {
|
||||
t.Fatalf("partial payload=%#v err=%v", payload, err)
|
||||
}
|
||||
|
||||
file := filepath.Join(t.TempDir(), "source.wav")
|
||||
if err := os.WriteFile(file, []byte("audio"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
testseam.Swap(t, &minutesPutFile, func(context.Context, string, string, int64) (localio.UploadResult, error) {
|
||||
return localio.UploadResult{SizeBytes: 5, Attempts: 1}, nil
|
||||
})
|
||||
upload := &minutesE2ECaller{responses: map[string][]string{
|
||||
"minutes/create_upload_session": {`{"success":true,"result":{"sessionId":"s1","presignedUrl":"https://example.invalid/u"}}`},
|
||||
"minutes/complete_upload_session": {`{"success":true,"result":{"taskUuid":"u2"}}`},
|
||||
"minutes/get_minutes_basic_info": {
|
||||
`{"success":true,"result":{"taskUuid":"u2","title":"uploaded"}}`,
|
||||
`{"success":true,"result":{"taskUuid":"u2","title":"uploaded"}}`,
|
||||
},
|
||||
}}
|
||||
payload, _, err = runMinutesAlignmentCLI(t, upload, "minutes", "+upload-and-analyze", "--file", file, "--artifacts", "basic", "--yes")
|
||||
if err != nil || payload["taskUuid"] != "u2" || payload["complete"] != true {
|
||||
t.Fatalf("fresh upload payload=%#v err=%v", payload, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesMindmapBranchesE2E(t *testing.T) {
|
||||
if err := runMinutesAlignmentCLIWithWriter(t, &minutesE2ECaller{}, minutesFailWriter{}, "minutes", "+mindmap", "--id", "u1", "--dry-run"); err == nil {
|
||||
t.Fatal("mindmap dry-run output failure accepted")
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
responses map[string][]string
|
||||
failAt map[string]int
|
||||
args []string
|
||||
}{
|
||||
{name: "create call", failAt: map[string]int{"minutes/create_mind_graph": 1}},
|
||||
{name: "poll call", responses: map[string][]string{"minutes/create_mind_graph": {`{}`}}, failAt: map[string]int{"minutes/query_mind_graph_status": 1}},
|
||||
{name: "poll parse", responses: map[string][]string{"minutes/create_mind_graph": {`{}`}, "minutes/query_mind_graph_status": {`{"success":true,"result":{}}`}}},
|
||||
{name: "timeout", responses: map[string][]string{"minutes/create_mind_graph": {`{}`}, "minutes/query_mind_graph_status": {`{"success":true,"result":{"taskStatus":0}}`}}},
|
||||
{name: "resume", responses: map[string][]string{"minutes/query_mind_graph_status": {`{"success":true,"result":{"taskStatus":1}}`}}, args: []string{"--resume"}},
|
||||
}
|
||||
for _, test := range cases {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
args := []string{"minutes", "+mindmap", "--id", "u1", "--timeout", "1", "--interval", "1", "--yes"}
|
||||
args = append(args, test.args...)
|
||||
payload, output, err := runMinutesAlignmentCLI(t, &minutesE2ECaller{responses: test.responses, failAt: test.failAt}, args...)
|
||||
if test.name == "resume" {
|
||||
if err != nil || payload["complete"] != true {
|
||||
t.Fatalf("resume payload=%#v err=%v", payload, err)
|
||||
}
|
||||
} else if err == nil || output == "" {
|
||||
t.Fatalf("failure accepted payload=%#v err=%v", payload, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
outputFail := &minutesE2ECaller{responses: map[string][]string{
|
||||
"minutes/query_mind_graph_status": {`{"success":true,"result":{"taskStatus":1}}`},
|
||||
}}
|
||||
if err := runMinutesAlignmentCLIWithWriter(t, outputFail, minutesFailWriter{}, "minutes", "+mindmap", "--id", "u1", "--resume", "--yes"); err == nil {
|
||||
t.Fatal("mindmap output failure accepted")
|
||||
}
|
||||
|
||||
caller := &minutesE2ECaller{responses: map[string][]string{"minutes/query_mind_graph_status": {`{"success":true,"result":{"taskStatus":0}}`}}}
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
cmd := &cobra.Command{Use: "mindmap"}
|
||||
cmd.SetContext(ctx)
|
||||
rt := shortcut.RuntimeContextForTest(cmd, Mindmap)
|
||||
if _, err := runMinutesMindmap(rt, "u1", 2*time.Hour, time.Hour, false); err == nil {
|
||||
t.Fatal("mindmap cancelled wait accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesSpeakerInsightsBranchesE2E(t *testing.T) {
|
||||
if err := runMinutesAlignmentCLIWithWriter(t, &minutesE2ECaller{}, minutesFailWriter{}, "minutes", "+speaker-insights", "--id", "u1", "--dry-run"); err == nil {
|
||||
t.Fatal("speaker dry-run output failure accepted")
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
responses map[string][]string
|
||||
failAt map[string]int
|
||||
args []string
|
||||
}{
|
||||
{name: "create call", failAt: map[string]int{"minutes/create_speaker_summary": 1}},
|
||||
{name: "create parse", responses: map[string][]string{"minutes/create_speaker_summary": {`{"success":true,"result":{}}`}}},
|
||||
{name: "poll nonpending", responses: map[string][]string{"minutes/create_speaker_summary": {`{"success":true,"result":{"taskId":"job","status":"processing"}}`}, "minutes/get_speaker_summary": {`{"success":false,"errorMsg":"denied"}`}}},
|
||||
{name: "poll parse", responses: map[string][]string{"minutes/create_speaker_summary": {`{"success":true,"result":{"taskId":"job","status":"processing"}}`}, "minutes/get_speaker_summary": {`{"success":true,"result":{}}`}}},
|
||||
{name: "timeout", responses: map[string][]string{"minutes/create_speaker_summary": {`{"success":true,"result":{"taskId":"job","status":"processing"}}`}, "minutes/get_speaker_summary": {`{"success":false,"errorMsg":"processing"}`}}},
|
||||
{name: "resume", responses: map[string][]string{"minutes/get_speaker_summary": {`{"success":true,"result":{"summaries":[{"speaker":"a","summary":"b"}]}}`}}, args: []string{"--resume", "--task-id", "job"}},
|
||||
}
|
||||
for _, test := range cases {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
args := []string{"minutes", "+speaker-insights", "--id", "u1", "--timeout", "1", "--interval", "1", "--yes"}
|
||||
args = append(args, test.args...)
|
||||
payload, output, err := runMinutesAlignmentCLI(t, &minutesE2ECaller{responses: test.responses, failAt: test.failAt}, args...)
|
||||
if test.name == "resume" {
|
||||
if err != nil || payload["complete"] != true || payload["taskId"] != "job" {
|
||||
t.Fatalf("resume payload=%#v err=%v", payload, err)
|
||||
}
|
||||
} else if err == nil || output == "" {
|
||||
t.Fatalf("failure accepted payload=%#v err=%v", payload, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
outputFail := &minutesE2ECaller{responses: map[string][]string{"minutes/get_speaker_summary": {`{"success":true,"result":{"summaries":[{"speaker":"a","summary":"b"}]}}`}}}
|
||||
if err := runMinutesAlignmentCLIWithWriter(t, outputFail, minutesFailWriter{}, "minutes", "+speaker-insights", "--id", "u1", "--resume", "--yes"); err == nil {
|
||||
t.Fatal("speaker output failure accepted")
|
||||
}
|
||||
|
||||
for _, message := range []string{"query empty", "processing", "not ready", "result is empty", "business error: code 000", "暂无"} {
|
||||
if !speakerSummaryPending(errors.New(message)) {
|
||||
t.Fatalf("pending message rejected: %q", message)
|
||||
}
|
||||
}
|
||||
if speakerSummaryPending(nil) || speakerSummaryPending(errors.New("denied")) {
|
||||
t.Fatal("non-pending speaker error accepted")
|
||||
}
|
||||
|
||||
caller := &minutesE2ECaller{responses: map[string][]string{"minutes/get_speaker_summary": {`{"success":false,"errorMsg":"processing"}`}}}
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
cmd := &cobra.Command{Use: "speaker"}
|
||||
cmd.SetContext(ctx)
|
||||
rt := shortcut.RuntimeContextForTest(cmd, SpeakerInsights)
|
||||
if _, err := runMinutesSpeakerInsights(rt, "u1", 2*time.Hour, time.Hour, false, "job"); err == nil {
|
||||
t.Fatal("speaker cancelled wait accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesPrepareASRBranchesE2E(t *testing.T) {
|
||||
if err := runMinutesAlignmentCLIWithWriter(t, &minutesE2ECaller{}, minutesFailWriter{}, "minutes", "+prepare-asr", "--words", "a", "--dry-run"); err == nil {
|
||||
t.Fatal("ASR dry-run output failure accepted")
|
||||
}
|
||||
readFailure := &minutesE2ECaller{failAt: map[string]int{"minutes/list_my_hotwords": 1}}
|
||||
if _, _, err := runMinutesAlignmentCLI(t, readFailure, "minutes", "+prepare-asr", "--words", "a", "--yes"); err == nil {
|
||||
t.Fatal("ASR current read failure accepted")
|
||||
}
|
||||
parseFailure := &minutesE2ECaller{responses: map[string][]string{"minutes/list_my_hotwords": {`{"success":true,"result":{}}`}}}
|
||||
if _, _, err := runMinutesAlignmentCLI(t, parseFailure, "minutes", "+prepare-asr", "--words", "a", "--yes"); err == nil {
|
||||
t.Fatal("ASR current parse failure accepted")
|
||||
}
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
responses map[string][]string
|
||||
failAt map[string]int
|
||||
}{
|
||||
{name: "add call", responses: map[string][]string{"minutes/list_my_hotwords": {`{"success":true,"result":{"hotWordList":[]}}`}}, failAt: map[string]int{"minutes/add_personal_hot_word": 1}},
|
||||
{name: "add ack", responses: map[string][]string{"minutes/list_my_hotwords": {`{"success":true,"result":{"hotWordList":[]}}`}, "minutes/add_personal_hot_word": {`{"result":{}}`}}},
|
||||
{name: "delete call", responses: map[string][]string{"minutes/list_my_hotwords": {`{"success":true,"result":{"hotWordList":["old"]}}`}}, failAt: map[string]int{"minutes/delete_personal_hotword": 1}},
|
||||
{name: "delete ack", responses: map[string][]string{"minutes/list_my_hotwords": {`{"success":true,"result":{"hotWordList":["old"]}}`}, "minutes/delete_personal_hotword": {`{"result":{}}`}}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
args := []string{"minutes", "+prepare-asr", "--words", "a", "--yes"}
|
||||
if strings.HasPrefix(test.name, "delete") {
|
||||
args = []string{"minutes", "+prepare-asr", "--words", "a", "--sync", "--yes"}
|
||||
test.responses["minutes/add_personal_hot_word"] = []string{`{"success":true,"result":{}}`}
|
||||
}
|
||||
payload, output, err := runMinutesAlignmentCLI(t, &minutesE2ECaller{responses: test.responses, failAt: test.failAt}, args...)
|
||||
if err == nil || output == "" || payload["complete"] != false {
|
||||
t.Fatalf("ASR failure accepted payload=%#v err=%v", payload, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
verifyCall := &minutesE2ECaller{
|
||||
responses: map[string][]string{"minutes/list_my_hotwords": {`{"success":true,"result":{"hotWordList":["a"]}}`}},
|
||||
failAt: map[string]int{"minutes/list_my_hotwords": 2},
|
||||
}
|
||||
payload, output, err := runMinutesAlignmentCLI(t, verifyCall, "minutes", "+prepare-asr", "--words", "a", "--yes")
|
||||
if err == nil || output == "" || payload["complete"] != false {
|
||||
t.Fatalf("ASR verify call payload=%#v err=%v", payload, err)
|
||||
}
|
||||
verifyCallOutput := &minutesE2ECaller{
|
||||
responses: map[string][]string{"minutes/list_my_hotwords": {`{"success":true,"result":{"hotWordList":["a"]}}`}},
|
||||
failAt: map[string]int{"minutes/list_my_hotwords": 2},
|
||||
}
|
||||
if err := runMinutesAlignmentCLIWithWriter(t, verifyCallOutput, minutesFailWriter{}, "minutes", "+prepare-asr", "--words", "a", "--yes"); err == nil {
|
||||
t.Fatal("ASR verify read output failure accepted")
|
||||
}
|
||||
verifyParse := &minutesE2ECaller{responses: map[string][]string{"minutes/list_my_hotwords": {
|
||||
`{"success":true,"result":{"hotWordList":["a"]}}`, `{"success":true,"result":{}}`,
|
||||
}}}
|
||||
if _, _, err := runMinutesAlignmentCLI(t, verifyParse, "minutes", "+prepare-asr", "--words", "a", "--yes"); err == nil {
|
||||
t.Fatal("ASR verify parse failure accepted")
|
||||
}
|
||||
verifyMismatch := &minutesE2ECaller{responses: map[string][]string{"minutes/list_my_hotwords": {
|
||||
`{"success":true,"result":{"hotWordList":["a","old"]}}`, `{"success":true,"result":{"hotWordList":["old"]}}`,
|
||||
}, "minutes/delete_personal_hotword": {`{"success":true,"result":{}}`}}}
|
||||
payload, output, err = runMinutesAlignmentCLI(t, verifyMismatch, "minutes", "+prepare-asr", "--words", "a", "--sync", "--yes")
|
||||
if err == nil || output == "" || payload["verified"] != false {
|
||||
t.Fatalf("ASR mismatch payload=%#v err=%v", payload, err)
|
||||
}
|
||||
syncSuccess := &minutesE2ECaller{responses: map[string][]string{
|
||||
"minutes/list_my_hotwords": {`{"success":true,"result":{"hotWordList":["old"]}}`, `{"success":true,"result":{"hotWordList":["a"]}}`},
|
||||
"minutes/add_personal_hot_word": {`{"success":true,"result":{}}`},
|
||||
"minutes/delete_personal_hotword": {`{"success":true,"result":{}}`},
|
||||
}}
|
||||
payload, _, err = runMinutesAlignmentCLI(t, syncSuccess, "minutes", "+prepare-asr", "--words", "a", "--sync", "--yes")
|
||||
if err != nil || payload["complete"] != true {
|
||||
t.Fatalf("ASR sync payload=%#v err=%v", payload, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesPermissionLedgerBranchesE2E(t *testing.T) {
|
||||
many := make([]string, 51)
|
||||
for index := range many {
|
||||
many[index] = fmt.Sprintf("m%d", index)
|
||||
}
|
||||
if payload, output, err := runMinutesAlignmentCLI(t, &minutesE2ECaller{}, "minutes", "+share", "--id", "u1", "--member-uids", strings.Join(many, ","), "--permission", "view", "--yes"); err == nil || payload != nil || output != "" {
|
||||
t.Fatalf("too many members accepted payload=%#v err=%v", payload, err)
|
||||
}
|
||||
if err := runMinutesAlignmentCLIWithWriter(t, &minutesE2ECaller{}, minutesFailWriter{}, "minutes", "+unshare", "--id", "u1", "--member-uids", "m1", "--dry-run"); err == nil {
|
||||
t.Fatal("unshare dry-run output failure accepted")
|
||||
}
|
||||
unshare := &minutesE2ECaller{responses: map[string][]string{"minutes/remove_member_permission": {`{"success":true,"result":{}}`}}}
|
||||
payload, _, err := runMinutesAlignmentCLI(t, unshare, "minutes", "+unshare", "--id", "u1", "--member-uids", "m1", "--yes")
|
||||
if err != nil || payload["complete"] != true || payload["succeeded"] != float64(1) {
|
||||
t.Fatalf("unshare payload=%#v err=%v", payload, err)
|
||||
}
|
||||
share := &minutesE2ECaller{responses: map[string][]string{"minutes/add_member_permission": {`{"success":true,"result":{}}`}}}
|
||||
payload, _, err = runMinutesAlignmentCLI(t, share, "minutes", "+share", "--id", "u1", "--member-uids", "m1", "--permission", "edit", "--cover", "--sub-resources", "Summary,Note", "--yes")
|
||||
if err != nil || payload["complete"] != true {
|
||||
t.Fatalf("share payload=%#v err=%v", payload, err)
|
||||
}
|
||||
args := share.arguments["minutes/add_member_permission"][0]
|
||||
if args["coverPermission"] != "true" || len(args["roleSubResourceIds"].([]string)) != 2 {
|
||||
t.Fatalf("share args=%#v", args)
|
||||
}
|
||||
continueFailure := &minutesE2ECaller{responses: map[string][]string{"minutes/remove_member_permission": {`{"result":{}}`, `{"success":true,"result":{}}`}}}
|
||||
payload, output, err := runMinutesAlignmentCLI(t, continueFailure, "minutes", "+unshare", "--id", "u1", "--member-uids", "m1,m2", "--failure-policy", "continue", "--yes")
|
||||
if err == nil || output == "" || payload["failed"] != float64(1) || payload["succeeded"] != float64(1) {
|
||||
t.Fatalf("continue ledger payload=%#v err=%v", payload, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesArtifactCollectorBranches(t *testing.T) {
|
||||
success := &minutesE2ECaller{responses: map[string][]string{
|
||||
"minutes/get_minutes_basic_info": {`{"success":true,"result":{"taskUuid":"u1","title":"ok"}}`},
|
||||
"minutes/get_minutes_ai_summary": {`{"success":true,"result":{"fullSummary":"summary"}}`},
|
||||
"minutes/get_minutes_keywords": {`{"success":true,"result":{"keywords":[]}}`},
|
||||
"minutes/get_minutes_transcription": {`{"success":true,"result":{"paragraphList":[{"paragraphId":"p1"}],"hasNext":false}}`},
|
||||
"minutes/list_minutes_todos": {`{"success":true,"result":{"actions":[]}}`},
|
||||
}}
|
||||
helpers.InitDepsForTest(t, success)
|
||||
rt := shortcut.RuntimeContextForTest(&cobra.Command{Use: "collect"}, ExportPack)
|
||||
bundle, failures := collectMinutesArtifactsOnce(rt, "u1", append(minutesWorkflowArtifacts, "unknown"), 10)
|
||||
if len(bundle) != 5 || len(failures) != 1 {
|
||||
t.Fatalf("collector bundle=%#v failures=%#v", bundle, failures)
|
||||
}
|
||||
|
||||
for _, artifact := range []string{"basic", "summary", "keywords", "transcript", "todos"} {
|
||||
t.Run("call "+artifact, func(t *testing.T) {
|
||||
tool := map[string]string{
|
||||
"basic": "get_minutes_basic_info", "summary": "get_minutes_ai_summary", "keywords": "get_minutes_keywords",
|
||||
"transcript": "get_minutes_transcription", "todos": "list_minutes_todos",
|
||||
}[artifact]
|
||||
caller := &minutesE2ECaller{failAt: map[string]int{"minutes/" + tool: 1}}
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
rt := shortcut.RuntimeContextForTest(&cobra.Command{Use: "collect"}, ExportPack)
|
||||
got, failed := collectMinutesArtifactsOnce(rt, "u1", []string{artifact}, 1)
|
||||
if len(got) != 0 || len(failed) != 1 {
|
||||
t.Fatalf("got=%#v failed=%#v", got, failed)
|
||||
}
|
||||
})
|
||||
t.Run("parse "+artifact, func(t *testing.T) {
|
||||
tool := map[string]string{
|
||||
"basic": "get_minutes_basic_info", "summary": "get_minutes_ai_summary", "keywords": "get_minutes_keywords",
|
||||
"transcript": "get_minutes_transcription", "todos": "list_minutes_todos",
|
||||
}[artifact]
|
||||
caller := &minutesE2ECaller{responses: map[string][]string{"minutes/" + tool: {`{"success":true,"result":{}}`}}}
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
rt := shortcut.RuntimeContextForTest(&cobra.Command{Use: "collect"}, ExportPack)
|
||||
got, failed := collectMinutesArtifactsOnce(rt, "u1", []string{artifact}, 1)
|
||||
if len(got) != 0 || len(failed) != 1 {
|
||||
t.Fatalf("got=%#v failed=%#v", got, failed)
|
||||
}
|
||||
})
|
||||
}
|
||||
emptySummary := &minutesE2ECaller{responses: map[string][]string{"minutes/get_minutes_ai_summary": {`{"success":true,"result":{"fullSummary":""}}`}}}
|
||||
helpers.InitDepsForTest(t, emptySummary)
|
||||
rt = shortcut.RuntimeContextForTest(&cobra.Command{Use: "collect"}, ExportPack)
|
||||
if _, failures = collectMinutesArtifactsOnce(rt, "u1", []string{"summary"}, 1); len(failures) != 1 {
|
||||
t.Fatalf("empty summary failures=%#v", failures)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesArtifactWaitAndOutput(t *testing.T) {
|
||||
success := &minutesE2ECaller{responses: map[string][]string{"minutes/get_minutes_basic_info": {`{"success":true,"result":{"taskUuid":"u1"}}`}}}
|
||||
helpers.InitDepsForTest(t, success)
|
||||
rt := shortcut.RuntimeContextForTest(&cobra.Command{Use: "wait"}, ExportPack)
|
||||
if _, failures, attempts := waitMinutesArtifacts(rt, "u1", []string{"basic"}, 1, time.Second, 0); len(failures) != 0 || attempts != 1 {
|
||||
t.Fatalf("success failures=%#v attempts=%d", failures, attempts)
|
||||
}
|
||||
failing := &minutesE2ECaller{failAt: map[string]int{"minutes/get_minutes_basic_info": 1}}
|
||||
helpers.InitDepsForTest(t, failing)
|
||||
rt = shortcut.RuntimeContextForTest(&cobra.Command{Use: "wait"}, ExportPack)
|
||||
if _, failures, attempts := waitMinutesArtifacts(rt, "u1", []string{"basic"}, 1, 0, 0); len(failures) != 1 || attempts != 1 {
|
||||
t.Fatalf("timeout failures=%#v attempts=%d", failures, attempts)
|
||||
}
|
||||
cancelled := &minutesE2ECaller{responses: map[string][]string{"minutes/get_minutes_basic_info": {`{"success":true,"result":{}}`}}}
|
||||
helpers.InitDepsForTest(t, cancelled)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
cmd := &cobra.Command{Use: "wait"}
|
||||
cmd.SetContext(ctx)
|
||||
rt = shortcut.RuntimeContextForTest(cmd, ExportPack)
|
||||
if _, failures, _ := waitMinutesArtifacts(rt, "u1", []string{"basic"}, 1, 2*time.Hour, time.Hour); len(failures) != 2 || failures[1]["artifact"] != "wait" {
|
||||
t.Fatalf("cancel failures=%#v", failures)
|
||||
}
|
||||
cmd = &cobra.Command{Use: "wait"}
|
||||
cmd.SetContext(context.Background())
|
||||
rt = shortcut.RuntimeContextForTest(cmd, ExportPack)
|
||||
if err := waitMinutesInterval(rt, 0); err != nil {
|
||||
t.Fatalf("timer wait: %v", err)
|
||||
}
|
||||
|
||||
cmd.SetOut(minutesFailWriter{})
|
||||
if err := outputWorkflowResult(rt, map[string]any{"complete": true}, false, "", ""); err == nil {
|
||||
t.Fatal("workflow output failure accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesExportPackBranchesE2E(t *testing.T) {
|
||||
withCWD := func(t *testing.T) string {
|
||||
t.Helper()
|
||||
old, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
work := t.TempDir()
|
||||
if err := os.Chdir(work); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(old) })
|
||||
return work
|
||||
}
|
||||
basicCaller := func() *minutesE2ECaller {
|
||||
return &minutesE2ECaller{responses: map[string][]string{"minutes/get_minutes_basic_info": {`{"success":true,"result":{"taskUuid":"u1","title":"ok"}}`}}}
|
||||
}
|
||||
|
||||
t.Run("collect failure", func(t *testing.T) {
|
||||
withCWD(t)
|
||||
payload, output, err := runMinutesAlignmentCLI(t, &minutesE2ECaller{responses: map[string][]string{"minutes/get_minutes_basic_info": {`{"success":true,"result":{}}`}}}, "minutes", "+export-pack", "--id", "u1", "--output", "pack", "--artifacts", "basic")
|
||||
if err == nil || output == "" || payload["published"] != false {
|
||||
t.Fatalf("collect payload=%#v err=%v", payload, err)
|
||||
}
|
||||
})
|
||||
t.Run("target exists", func(t *testing.T) {
|
||||
work := withCWD(t)
|
||||
if err := os.Mkdir(filepath.Join(work, "pack"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := runMinutesAlignmentCLI(t, basicCaller(), "minutes", "+export-pack", "--id", "u1", "--output", "pack", "--artifacts", "basic"); err == nil {
|
||||
t.Fatal("existing export accepted")
|
||||
}
|
||||
})
|
||||
t.Run("mkdir temp", func(t *testing.T) {
|
||||
withCWD(t)
|
||||
testseam.Swap(t, &minutesMkdirTemp, func(string, string) (string, error) { return "", errors.New("mkdir temp") })
|
||||
if _, _, err := runMinutesAlignmentCLI(t, basicCaller(), "minutes", "+export-pack", "--id", "u1", "--output", "pack", "--artifacts", "basic"); err == nil {
|
||||
t.Fatal("mkdir temp failure accepted")
|
||||
}
|
||||
})
|
||||
t.Run("summary write", func(t *testing.T) {
|
||||
withCWD(t)
|
||||
testseam.Swap(t, &minutesWriteFile, func(string, []byte, os.FileMode) error { return errors.New("write") })
|
||||
caller := &minutesE2ECaller{responses: map[string][]string{"minutes/get_minutes_ai_summary": {`{"success":true,"result":{"fullSummary":"summary"}}`}}}
|
||||
if _, _, err := runMinutesAlignmentCLI(t, caller, "minutes", "+export-pack", "--id", "u1", "--output", "pack", "--artifacts", "summary"); err == nil {
|
||||
t.Fatal("summary write failure accepted")
|
||||
}
|
||||
})
|
||||
t.Run("json write", func(t *testing.T) {
|
||||
withCWD(t)
|
||||
testseam.Swap(t, &minutesWriteFile, func(string, []byte, os.FileMode) error { return errors.New("write") })
|
||||
if _, _, err := runMinutesAlignmentCLI(t, basicCaller(), "minutes", "+export-pack", "--id", "u1", "--output", "pack", "--artifacts", "basic"); err == nil {
|
||||
t.Fatal("JSON write failure accepted")
|
||||
}
|
||||
})
|
||||
t.Run("stat", func(t *testing.T) {
|
||||
withCWD(t)
|
||||
testseam.Swap(t, &minutesStat, func(string) (os.FileInfo, error) { return nil, errors.New("stat") })
|
||||
if _, _, err := runMinutesAlignmentCLI(t, basicCaller(), "minutes", "+export-pack", "--id", "u1", "--output", "pack", "--artifacts", "basic"); err == nil {
|
||||
t.Fatal("stat failure accepted")
|
||||
}
|
||||
})
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
responses map[string][]string
|
||||
failAt map[string]int
|
||||
download func(context.Context, string, localio.DownloadOptions) (localio.DownloadResult, error)
|
||||
wantError bool
|
||||
}{
|
||||
{name: "media call", failAt: map[string]int{"minutes/query_minutes_audio_url": 1}, wantError: true},
|
||||
{name: "media parse", responses: map[string][]string{"minutes/query_minutes_audio_url": {`{"success":true,"result":{}}`}}, wantError: true},
|
||||
{name: "media download", responses: map[string][]string{"minutes/query_minutes_audio_url": {`{"success":true,"result":{"downloadUrl":"https://example.invalid/a.mp3"}}`}}, download: func(context.Context, string, localio.DownloadOptions) (localio.DownloadResult, error) {
|
||||
return localio.DownloadResult{}, errors.New("download")
|
||||
}, wantError: true},
|
||||
{name: "media success", responses: map[string][]string{"minutes/query_minutes_audio_url": {`{"success":true,"result":{"downloadUrl":"https://example.invalid/a.mp3"}}`}}, download: func(_ context.Context, _ string, options localio.DownloadOptions) (localio.DownloadResult, error) {
|
||||
path := filepath.Join(options.BaseDir, "media", "u1.mp3")
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return localio.DownloadResult{}, err
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("media"), 0o600); err != nil {
|
||||
return localio.DownloadResult{}, err
|
||||
}
|
||||
return localio.DownloadResult{RelativePath: "media/u1.mp3", SizeBytes: 5}, nil
|
||||
}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
withCWD(t)
|
||||
if test.download != nil {
|
||||
testseam.Swap(t, &minutesDownload, test.download)
|
||||
}
|
||||
responses := map[string][]string{"minutes/get_minutes_basic_info": {`{"success":true,"result":{"taskUuid":"u1"}}`}}
|
||||
for key, values := range test.responses {
|
||||
responses[key] = values
|
||||
}
|
||||
payload, _, err := runMinutesAlignmentCLI(t, &minutesE2ECaller{responses: responses, failAt: test.failAt}, "minutes", "+export-pack", "--id", "u1", "--output", "pack", "--artifacts", "basic", "--include-media")
|
||||
if (err != nil) != test.wantError {
|
||||
t.Fatalf("payload=%#v err=%v", payload, err)
|
||||
}
|
||||
if !test.wantError && payload["published"] != true {
|
||||
t.Fatalf("payload=%#v", payload)
|
||||
}
|
||||
})
|
||||
}
|
||||
t.Run("manifest write", func(t *testing.T) {
|
||||
withCWD(t)
|
||||
realWrite := minutesWriteFile
|
||||
testseam.Swap(t, &minutesWriteFile, func(path string, data []byte, mode os.FileMode) error {
|
||||
if filepath.Base(path) == "manifest.json" {
|
||||
return errors.New("manifest")
|
||||
}
|
||||
return realWrite(path, data, mode)
|
||||
})
|
||||
if _, _, err := runMinutesAlignmentCLI(t, basicCaller(), "minutes", "+export-pack", "--id", "u1", "--output", "pack", "--artifacts", "basic"); err == nil {
|
||||
t.Fatal("manifest failure accepted")
|
||||
}
|
||||
})
|
||||
t.Run("rename", func(t *testing.T) {
|
||||
withCWD(t)
|
||||
testseam.Swap(t, &minutesRename, func(string, string) error { return errors.New("rename") })
|
||||
if _, _, err := runMinutesAlignmentCLI(t, basicCaller(), "minutes", "+export-pack", "--id", "u1", "--output", "pack", "--artifacts", "basic"); err == nil {
|
||||
t.Fatal("rename failure accepted")
|
||||
}
|
||||
})
|
||||
t.Run("output", func(t *testing.T) {
|
||||
withCWD(t)
|
||||
if err := runMinutesAlignmentCLIWithWriter(t, basicCaller(), minutesFailWriter{}, "minutes", "+export-pack", "--id", "u1", "--output", "pack", "--artifacts", "basic"); err == nil {
|
||||
t.Fatal("export output failure accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesExportPathAndJSONFaults(t *testing.T) {
|
||||
if _, _, _, err := prepareExportTarget("../escape"); err == nil {
|
||||
t.Fatal("unsafe target accepted")
|
||||
}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
run func(*testing.T)
|
||||
}{
|
||||
{name: "getwd", run: func(t *testing.T) {
|
||||
testseam.Swap(t, &minutesGetwd, func() (string, error) { return "", errors.New("getwd") })
|
||||
if _, _, _, err := prepareExportTarget("pack"); err == nil {
|
||||
t.Fatal("getwd failure accepted")
|
||||
}
|
||||
}},
|
||||
{name: "eval", run: func(t *testing.T) {
|
||||
testseam.Swap(t, &minutesEvalSymlinks, func(string) (string, error) { return "", errors.New("eval") })
|
||||
if _, _, _, err := prepareExportTarget("pack"); err == nil {
|
||||
t.Fatal("eval failure accepted")
|
||||
}
|
||||
}},
|
||||
{name: "rel", run: func(t *testing.T) {
|
||||
testseam.Swap(t, &minutesRel, func(string, string) (string, error) { return "", errors.New("rel") })
|
||||
if _, _, _, err := prepareExportTarget("pack"); err == nil {
|
||||
t.Fatal("rel failure accepted")
|
||||
}
|
||||
}},
|
||||
{name: "escape", run: func(t *testing.T) {
|
||||
testseam.Swap(t, &minutesRel, func(string, string) (string, error) { return "../escape", nil })
|
||||
if _, _, _, err := prepareExportTarget("pack"); err == nil {
|
||||
t.Fatal("rel escape accepted")
|
||||
}
|
||||
}},
|
||||
{name: "target lstat", run: func(t *testing.T) {
|
||||
testseam.Swap(t, &minutesLstat, func(string) (os.FileInfo, error) { return nil, errors.New("lstat") })
|
||||
if _, _, _, err := prepareExportTarget("pack"); err == nil {
|
||||
t.Fatal("lstat failure accepted")
|
||||
}
|
||||
}},
|
||||
{name: "parent", run: func(t *testing.T) {
|
||||
testseam.Swap(t, &minutesRel, func(string, string) (string, error) { return "parent/pack", nil })
|
||||
testseam.Swap(t, &minutesLstat, func(string) (os.FileInfo, error) { return nil, errors.New("parent") })
|
||||
if _, _, _, err := prepareExportTarget("pack"); err == nil {
|
||||
t.Fatal("unsafe parent accepted")
|
||||
}
|
||||
}},
|
||||
} {
|
||||
t.Run(test.name, test.run)
|
||||
}
|
||||
|
||||
base := t.TempDir()
|
||||
if err := ensureExportParent(base, "."); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Run("mkdir", func(t *testing.T) {
|
||||
testseam.Swap(t, &minutesLstat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist })
|
||||
testseam.Swap(t, &minutesMkdir, func(string, os.FileMode) error { return errors.New("mkdir") })
|
||||
if err := ensureExportParent(base, "a"); err == nil {
|
||||
t.Fatal("mkdir failure accepted")
|
||||
}
|
||||
})
|
||||
t.Run("lstat", func(t *testing.T) {
|
||||
testseam.Swap(t, &minutesLstat, func(string) (os.FileInfo, error) { return nil, errors.New("lstat") })
|
||||
if err := ensureExportParent(base, "a"); err == nil {
|
||||
t.Fatal("lstat failure accepted")
|
||||
}
|
||||
})
|
||||
t.Run("file", func(t *testing.T) {
|
||||
path := filepath.Join(base, "file")
|
||||
if err := os.WriteFile(path, []byte("x"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ensureExportParent(base, "file/child"); err == nil {
|
||||
t.Fatal("file parent accepted")
|
||||
}
|
||||
})
|
||||
t.Run("create", func(t *testing.T) {
|
||||
if err := ensureExportParent(base, "created/nested"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("marshal", func(t *testing.T) {
|
||||
testseam.Swap(t, &minutesMarshalIndent, func(any, string, string) ([]byte, error) { return nil, errors.New("marshal") })
|
||||
if err := writeJSONFile(filepath.Join(t.TempDir(), "x.json"), map[string]any{}); err == nil {
|
||||
t.Fatal("marshal failure accepted")
|
||||
}
|
||||
})
|
||||
t.Run("write", func(t *testing.T) {
|
||||
testseam.Swap(t, &minutesWriteFile, func(string, []byte, os.FileMode) error { return errors.New("write") })
|
||||
if err := writeJSONFile(filepath.Join(t.TempDir(), "x.json"), map[string]any{}); err == nil {
|
||||
t.Fatal("write failure accepted")
|
||||
}
|
||||
})
|
||||
path := filepath.Join(t.TempDir(), "ok.json")
|
||||
if err := writeJSONFile(path, map[string]any{"ok": true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil || !bytes.HasSuffix(raw, []byte("\n")) || !json.Valid(raw) {
|
||||
t.Fatalf("JSON output=%q err=%v", raw, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package minutesdata
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageMinutesResponseCompletion(t *testing.T) {
|
||||
for _, data := range []map[string]any{
|
||||
{},
|
||||
{"success": false, "message": "denied"},
|
||||
{"errorCode": "E", "msg": "bad"},
|
||||
{"dingOpenErrcode": float64(1)},
|
||||
} {
|
||||
if err := ValidateEnvelope(data); err == nil {
|
||||
t.Fatalf("invalid envelope accepted: %#v", data)
|
||||
}
|
||||
}
|
||||
for _, value := range []any{nil, "", "0", float64(0), 0, int64(0), json.Number("0")} {
|
||||
if !isZeroCode(value) {
|
||||
t.Fatalf("zero code rejected: %#v", value)
|
||||
}
|
||||
}
|
||||
if isZeroCode(true) {
|
||||
t.Fatal("unsupported code treated as zero")
|
||||
}
|
||||
if got := envelopeMessage(map[string]any{}); got != "no backend error message" {
|
||||
t.Fatalf("fallback message = %q", got)
|
||||
}
|
||||
|
||||
list, err := ParseListPage(map[string]any{
|
||||
"hasNext": true, "nextCursor": "outer",
|
||||
"data": map[string]any{"result": map[string]any{"records": []any{map[string]any{"taskUUID": "u1"}}}},
|
||||
})
|
||||
if err != nil || !list.HasMoreKnown || list.NextToken != "outer" || len(list.Items) != 1 {
|
||||
t.Fatalf("nested list = %#v, %v", list, err)
|
||||
}
|
||||
if _, err := ParseListPage(map[string]any{"result": map[string]any{"items": []any{map[string]any{}, "bad"}}}); err == nil {
|
||||
t.Fatal("non-object list item accepted")
|
||||
}
|
||||
transcript, err := ParseTranscriptPage(map[string]any{
|
||||
"hasMore": false,
|
||||
"data": map[string]any{"data": map[string]any{"paragraphList": []any{map[string]any{"sentenceId": "p"}}}},
|
||||
})
|
||||
if err != nil || !transcript.HasMoreKnown || transcript.HasMore || len(transcript.Items) != 1 {
|
||||
t.Fatalf("nested transcript = %#v, %v", transcript, err)
|
||||
}
|
||||
if _, err := ParseTranscriptPage(map[string]any{"result": map[string]any{"paragraphList": []any{"bad"}}}); err == nil {
|
||||
t.Fatal("non-object paragraph accepted")
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
data map[string]any
|
||||
}{
|
||||
{"no result", map[string]any{"success": true}},
|
||||
{"basic missing", map[string]any{"result": map[string]any{}}},
|
||||
{"basic mismatch", map[string]any{"result": map[string]any{"taskUuid": "other"}}},
|
||||
{"summary missing", map[string]any{"result": map[string]any{}}},
|
||||
{"keywords wrong", map[string]any{"result": map[string]any{"keywords": "bad"}}},
|
||||
{"todos wrong", map[string]any{"result": map[string]any{"actions": "bad", "dingtalkTodoList": "bad"}}},
|
||||
{"other empty", map[string]any{"result": map[string]any{}}},
|
||||
} {
|
||||
artifact := map[string]string{"no result": "basic", "basic missing": "basic", "basic mismatch": "basic", "summary missing": "summary", "keywords wrong": "keywords", "todos wrong": "todos", "other empty": "custom"}[tc.name]
|
||||
if err := ValidateArtifact(artifact, "u1", tc.data); err == nil {
|
||||
t.Fatalf("%s accepted", tc.name)
|
||||
}
|
||||
}
|
||||
if err := ValidateArtifact("basic", "u1", map[string]any{}); err == nil {
|
||||
t.Fatal("artifact envelope failure ignored")
|
||||
}
|
||||
if err := ValidateArtifact("todos", "", map[string]any{"result": map[string]any{"dingtalkTodoList": []any{}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ValidateArtifact("custom", "", map[string]any{"result": map[string]any{"value": true}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Basic("u1", map[string]any{"result": map[string]any{}}); err == nil {
|
||||
t.Fatal("invalid basic accepted")
|
||||
}
|
||||
if basic, err := Basic("u1", map[string]any{"result": map[string]any{"uuid": "u1"}}); err != nil || basic["uuid"] != "u1" {
|
||||
t.Fatalf("basic = %#v, %v", basic, err)
|
||||
}
|
||||
if _, err := SummaryText(map[string]any{"result": map[string]any{}}); err == nil {
|
||||
t.Fatal("invalid summary accepted")
|
||||
}
|
||||
if _, err := SummaryText(map[string]any{"result": map[string]any{"fullSummary": 1}}); err == nil {
|
||||
t.Fatal("non-string summary accepted")
|
||||
}
|
||||
if text, err := SummaryText(map[string]any{"result": map[string]any{"fullSummary": "ok"}}); err != nil || text != "ok" {
|
||||
t.Fatalf("summary = %q, %v", text, err)
|
||||
}
|
||||
|
||||
for _, data := range []map[string]any{{}, {"result": "bad"}, {"result": map[string]any{}}} {
|
||||
if _, err := MediaURL(data); err == nil {
|
||||
t.Fatalf("invalid media accepted: %#v", data)
|
||||
}
|
||||
}
|
||||
if value, err := MediaURL(map[string]any{"result": map[string]any{"audioUrl": " https://example.invalid/a "}}); err != nil || value == "" {
|
||||
t.Fatalf("media = %q, %v", value, err)
|
||||
}
|
||||
for _, data := range []map[string]any{{}, {"result": "bad"}, {"result": map[string]any{"sessionId": "s"}}} {
|
||||
if _, _, err := UploadSession(data); err == nil {
|
||||
t.Fatalf("invalid upload session accepted: %#v", data)
|
||||
}
|
||||
}
|
||||
if session, url, err := UploadSession(map[string]any{"result": map[string]any{"uploadId": 7, "uploadUrl": "https://example.invalid"}}); err != nil || session != "7" || url == "" {
|
||||
t.Fatalf("upload session = %q/%q, %v", session, url, err)
|
||||
}
|
||||
if _, err := CompletedTaskUUID(map[string]any{}); err == nil {
|
||||
t.Fatal("invalid completed envelope accepted")
|
||||
}
|
||||
if _, err := CompletedTaskUUID(map[string]any{"result": map[string]any{}}); err == nil {
|
||||
t.Fatal("missing completed UUID accepted")
|
||||
}
|
||||
if id, err := CompletedTaskUUID(map[string]any{"data": map[string]any{"result": map[string]any{"task_uuid": "done"}}}); err != nil || id != "done" {
|
||||
t.Fatalf("completed UUID = %q, %v", id, err)
|
||||
}
|
||||
|
||||
rows, err := ProjectList(Page{Items: []map[string]any{{
|
||||
"uuid": "u", "name": "n", "creatorNick": "c", "createTime": 1,
|
||||
"deadline": 2, "link": "l", "state": "ready",
|
||||
}}})
|
||||
if err != nil || rows[0]["title"] != "n" || rows[0]["creator"] != "c" || rows[0]["url"] != "l" {
|
||||
t.Fatalf("projected fallback fields = %#v, %v", rows, err)
|
||||
}
|
||||
if got := TaskUUID(map[string]any{"taskUUID": json.Number("9")}); got != "9" {
|
||||
t.Fatalf("numeric task UUID = %q", got)
|
||||
}
|
||||
if id, err := LatestTaskUUID(Page{}); err != nil || id != "" {
|
||||
t.Fatalf("empty latest = %q, %v", id, err)
|
||||
}
|
||||
if _, err := LatestTaskUUID(Page{Items: []map[string]any{{"startTime": 1}}}); err == nil {
|
||||
t.Fatal("latest missing UUID accepted")
|
||||
}
|
||||
latest, err := LatestTaskUUID(Page{Items: []map[string]any{
|
||||
{"uuid": "skip", "createTime": "bad"},
|
||||
{"uuid": "int", "createTime": 1},
|
||||
{"uuid": "int64", "gmtCreate": int64(2)},
|
||||
{"uuid": "number", "startTime": json.Number("3")},
|
||||
{"uuid": "rfc", "createTimeStart": time.Now().UTC().Format(time.RFC3339Nano)},
|
||||
}})
|
||||
if err != nil || latest != "rfc" {
|
||||
t.Fatalf("typed latest = %q, %v", latest, err)
|
||||
}
|
||||
if key, err := StableItemKey(map[string]any{"text": "same"}); err != nil || !strings.HasPrefix(key, "json:") {
|
||||
t.Fatalf("JSON stable key = %q, %v", key, err)
|
||||
}
|
||||
if _, err := StableItemKey(map[string]any{"bad": func() {}}); err == nil {
|
||||
t.Fatal("unencodable stable key accepted")
|
||||
}
|
||||
|
||||
for _, value := range []any{"value", json.Number("4"), float64(5), 6, int64(7)} {
|
||||
if got := stringField(map[string]any{"x": value}, "missing", "x"); got == "" {
|
||||
t.Fatalf("stringField(%#v) empty", value)
|
||||
}
|
||||
}
|
||||
if got := stringField(map[string]any{"x": true}, "x"); got != "" {
|
||||
t.Fatalf("unsupported string field = %q", got)
|
||||
}
|
||||
for _, value := range []any{float64(1), 2, int64(3), json.Number("4"), "5", "2026-08-11T00:00:00Z"} {
|
||||
if _, ok := timestamp(map[string]any{"x": value}, "x"); !ok {
|
||||
t.Fatalf("timestamp(%#v) rejected", value)
|
||||
}
|
||||
}
|
||||
if _, ok := timestamp(map[string]any{"x": json.Number("bad")}, "x"); ok {
|
||||
t.Fatal("invalid timestamp number accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesPagerCompletion(t *testing.T) {
|
||||
if _, err := CollectTranscript(nil, "", false, 1); err == nil {
|
||||
t.Fatal("nil transcript caller accepted")
|
||||
}
|
||||
defaulted, err := CollectTranscript(func(string) (map[string]any, error) {
|
||||
return map[string]any{"result": map[string]any{"paragraphList": []any{}}}, nil
|
||||
}, "", false, 0)
|
||||
if err != nil || !defaulted.Complete {
|
||||
t.Fatalf("default max pages = %#v, %v", defaulted, err)
|
||||
}
|
||||
invalid, err := CollectTranscript(func(string) (map[string]any, error) { return map[string]any{}, nil }, "", false, 1)
|
||||
if err == nil || invalid.FailurePage != 1 || invalid.FailureMessage == "" {
|
||||
t.Fatalf("invalid page = %#v, %v", invalid, err)
|
||||
}
|
||||
badKey, err := CollectTranscript(func(string) (map[string]any, error) {
|
||||
return map[string]any{"result": map[string]any{"paragraphList": []any{map[string]any{"bad": func() {}}}}}, nil
|
||||
}, "", false, 1)
|
||||
if err == nil || badKey.FailurePage != 1 {
|
||||
t.Fatalf("bad key = %#v, %v", badKey, err)
|
||||
}
|
||||
one, err := CollectTranscript(func(string) (map[string]any, error) {
|
||||
return map[string]any{"result": map[string]any{"paragraphList": []any{}, "hasNext": false}}, nil
|
||||
}, "", true, 1)
|
||||
if err != nil || !one.Complete {
|
||||
t.Fatalf("complete single page = %#v, %v", one, err)
|
||||
}
|
||||
limited, err := CollectTranscript(func(token string) (map[string]any, error) {
|
||||
return map[string]any{"result": map[string]any{"paragraphList": []any{}, "hasNext": true, "nextToken": token + "n"}}, nil
|
||||
}, "", false, 1)
|
||||
if err == nil || limited.FailurePage != 2 || limited.FailureMessage == "" {
|
||||
t.Fatalf("page limit = %#v, %v", limited, err)
|
||||
}
|
||||
payload := TranscriptPayload("u", "1", TranscriptResult{
|
||||
Paragraphs: []map[string]any{{"id": "p"}}, Pages: 1, Duplicates: 2,
|
||||
NextToken: "n", FailurePage: 2, FailureMessage: "failed",
|
||||
})
|
||||
if payload["nextToken"] != "n" || payload["failurePage"] != 2 || payload["failure"] != "failed" {
|
||||
t.Fatalf("transcript payload = %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesWorkflowCompletion(t *testing.T) {
|
||||
for _, data := range []map[string]any{{}, {"other": true}, {"success": "yes"}, {"success": false}} {
|
||||
if err := RequireWriteAcknowledgement("write", data); err == nil {
|
||||
t.Fatalf("invalid acknowledgement accepted: %#v", data)
|
||||
}
|
||||
}
|
||||
if err := RequireWriteAcknowledgement("write", map[string]any{"result": map[string]any{"updated": true}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
cmd, id string
|
||||
data map[string]any
|
||||
}{
|
||||
{"pause", "u", map[string]any{}},
|
||||
{"pause", "u", map[string]any{"success": true}},
|
||||
{"pause", "u", map[string]any{"success": true, "result": map[string]any{"cmd": "resume", "uuid": "u"}}},
|
||||
{"pause", "u", map[string]any{"success": true, "result": map[string]any{"cmd": "pause", "uuid": "other"}}},
|
||||
} {
|
||||
if _, err := RecordResult(tc.cmd, tc.id, tc.data); err == nil {
|
||||
t.Fatalf("invalid record accepted: %#v", tc)
|
||||
}
|
||||
}
|
||||
if _, err := RecordResult("create", "", map[string]any{"success": true, "result": map[string]any{"cmd": "create"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, data := range []map[string]any{{}, {"result": "bad"}, {"result": map[string]any{}}, {"result": map[string]any{"taskStatus": 3}}} {
|
||||
if _, _, err := MindGraphStatus(data); err == nil {
|
||||
t.Fatalf("invalid mind graph accepted: %#v", data)
|
||||
}
|
||||
}
|
||||
for _, value := range []any{0, "1", json.Number("2")} {
|
||||
status, _, err := MindGraphStatus(map[string]any{"result": map[string]any{"taskStatus": value}})
|
||||
if err != nil || status < 0 || status > 2 {
|
||||
t.Fatalf("mind status %#v = %d, %v", value, status, err)
|
||||
}
|
||||
}
|
||||
for _, data := range []map[string]any{{}, {"success": true}, {"success": true, "result": map[string]any{}}} {
|
||||
if _, _, err := SpeakerSummaryTask(data); err == nil {
|
||||
t.Fatalf("invalid speaker task accepted: %#v", data)
|
||||
}
|
||||
}
|
||||
if id, status, err := SpeakerSummaryTask(map[string]any{"result": map[string]any{"taskID": int64(7), "status": "READY"}}); err != nil || id != "7" || status != "ready" {
|
||||
t.Fatalf("speaker task = %q/%q, %v", id, status, err)
|
||||
}
|
||||
for _, data := range []map[string]any{{}, {"result": nil}, {"result": []any(nil)}, {"result": "bad"}} {
|
||||
if _, err := SpeakerSummaryResult(data); err == nil {
|
||||
t.Fatalf("invalid speaker result accepted: %#v", data)
|
||||
}
|
||||
}
|
||||
if value, err := SpeakerSummaryResult(map[string]any{"result": []any{}}); err != nil || value == nil {
|
||||
t.Fatalf("empty speaker list = %#v, %v", value, err)
|
||||
}
|
||||
if value, err := SpeakerSummaryResult(map[string]any{"result": map[string]any{"summary": "ok"}}); err != nil || value == nil {
|
||||
t.Fatalf("speaker map = %#v, %v", value, err)
|
||||
}
|
||||
for _, data := range []map[string]any{{}, {"result": "bad"}, {"result": map[string]any{}}, {"result": map[string]any{"hotWordList": "bad"}}, {"result": map[string]any{"hotWordList": []any{true}}}} {
|
||||
if _, err := HotWords(data); err == nil {
|
||||
t.Fatalf("invalid hot words accepted: %#v", data)
|
||||
}
|
||||
}
|
||||
words, err := HotWords(map[string]any{"result": map[string]any{"hotWordList": []any{" A ", "A", map[string]any{"content": "B"}}}})
|
||||
if err != nil || len(words) != 2 {
|
||||
t.Fatalf("deduplicated hot words = %#v, %v", words, err)
|
||||
}
|
||||
for _, value := range []any{math.NaN(), math.Inf(1), 1.5, float64(-1), float64(3), json.Number("bad"), "bad", true} {
|
||||
if _, err := intValue(value); err == nil {
|
||||
t.Fatalf("invalid int value accepted: %#v", value)
|
||||
}
|
||||
}
|
||||
if _, err := intValue(int64(1)); err == nil || !strings.Contains(err.Error(), "unsupported") {
|
||||
t.Fatalf("unsupported int64 result = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package minutesdata
|
||||
|
||||
import "fmt"
|
||||
|
||||
// TranscriptCaller fetches one transcript page for the supplied nextToken.
|
||||
// The empty token means the first page.
|
||||
type TranscriptCaller func(nextToken string) (map[string]any, error)
|
||||
|
||||
// TranscriptResult is the complete or partial result of transcript pagination.
|
||||
type TranscriptResult struct {
|
||||
Paragraphs []map[string]any
|
||||
Pages int
|
||||
Duplicates int
|
||||
Complete bool
|
||||
NextToken string
|
||||
FailurePage int
|
||||
FailureMessage string
|
||||
}
|
||||
|
||||
// CollectTranscript follows hasNext/nextToken, de-duplicates paragraphs and
|
||||
// fails closed on cursor drift. When a later call fails it returns the partial
|
||||
// paragraphs plus the error so callers can publish an honest recovery ledger.
|
||||
func CollectTranscript(call TranscriptCaller, initialToken string, singlePage bool, maxPages int) (TranscriptResult, error) {
|
||||
if call == nil {
|
||||
return TranscriptResult{}, fmt.Errorf("minutes transcript caller is nil")
|
||||
}
|
||||
if maxPages <= 0 {
|
||||
maxPages = 1000
|
||||
}
|
||||
result := TranscriptResult{Complete: false, NextToken: initialToken}
|
||||
seenTokens := map[string]bool{}
|
||||
seenItems := map[string]bool{}
|
||||
token := initialToken
|
||||
for pageIndex := 1; pageIndex <= maxPages; pageIndex++ {
|
||||
if seenTokens[token] {
|
||||
result.NextToken = token
|
||||
result.FailurePage = pageIndex
|
||||
result.FailureMessage = "cursor stalled or cycled"
|
||||
return result, fmt.Errorf("minutes transcript cursor stalled or cycled at page %d", pageIndex)
|
||||
}
|
||||
seenTokens[token] = true
|
||||
data, err := call(token)
|
||||
if err != nil {
|
||||
result.NextToken = token
|
||||
result.FailurePage = pageIndex
|
||||
result.FailureMessage = err.Error()
|
||||
return result, fmt.Errorf("fetch minutes transcript page %d: %w", pageIndex, err)
|
||||
}
|
||||
page, err := ParseTranscriptPage(data)
|
||||
if err != nil {
|
||||
result.NextToken = token
|
||||
result.FailurePage = pageIndex
|
||||
result.FailureMessage = err.Error()
|
||||
return result, fmt.Errorf("validate minutes transcript page %d: %w", pageIndex, err)
|
||||
}
|
||||
result.Pages++
|
||||
for _, item := range page.Items {
|
||||
key, err := StableItemKey(item)
|
||||
if err != nil {
|
||||
result.FailurePage = pageIndex
|
||||
result.FailureMessage = err.Error()
|
||||
return result, err
|
||||
}
|
||||
if seenItems[key] {
|
||||
result.Duplicates++
|
||||
continue
|
||||
}
|
||||
seenItems[key] = true
|
||||
result.Paragraphs = append(result.Paragraphs, item)
|
||||
}
|
||||
result.NextToken = page.NextToken
|
||||
if singlePage {
|
||||
result.Complete = !page.HasMore
|
||||
return result, nil
|
||||
}
|
||||
if !page.HasMore {
|
||||
result.Complete = true
|
||||
result.NextToken = ""
|
||||
return result, nil
|
||||
}
|
||||
token = page.NextToken
|
||||
}
|
||||
result.FailurePage = maxPages + 1
|
||||
result.FailureMessage = "page safety limit exceeded"
|
||||
return result, fmt.Errorf("minutes transcript exceeded page safety limit %d", maxPages)
|
||||
}
|
||||
|
||||
// TranscriptPayload converts a pagination result into the stable public view.
|
||||
func TranscriptPayload(taskUUID, direction string, result TranscriptResult) map[string]any {
|
||||
paragraphs := make([]any, 0, len(result.Paragraphs))
|
||||
for _, paragraph := range result.Paragraphs {
|
||||
paragraphs = append(paragraphs, paragraph)
|
||||
}
|
||||
payload := map[string]any{
|
||||
"taskUuid": taskUUID,
|
||||
"direction": direction,
|
||||
"complete": result.Complete,
|
||||
"pages": result.Pages,
|
||||
"paragraphCount": len(paragraphs),
|
||||
"duplicateCount": result.Duplicates,
|
||||
"paragraphList": paragraphs,
|
||||
}
|
||||
if result.NextToken != "" {
|
||||
payload["nextToken"] = result.NextToken
|
||||
}
|
||||
if result.FailurePage > 0 {
|
||||
payload["failurePage"] = result.FailurePage
|
||||
}
|
||||
if result.FailureMessage != "" {
|
||||
payload["failure"] = result.FailureMessage
|
||||
}
|
||||
return payload
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package minutesdata
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageTranscriptCollectsPagesAndDeduplicatesE2E(t *testing.T) {
|
||||
calls := []string{}
|
||||
result, err := CollectTranscript(func(token string) (map[string]any, error) {
|
||||
calls = append(calls, token)
|
||||
if token == "" {
|
||||
return decode(t, `{"result":{"paragraphList":[{"paragraphId":"p1"},{"paragraphId":"p2"}],"hasNext":true,"nextToken":"n2"}}`), nil
|
||||
}
|
||||
return decode(t, `{"result":{"paragraphList":[{"paragraphId":"p2"},{"paragraphId":"p3"}],"hasNext":false,"nextToken":""}}`), nil
|
||||
}, "", false, 10)
|
||||
if err != nil || !result.Complete || result.Pages != 2 || len(result.Paragraphs) != 3 || result.Duplicates != 1 || fmt.Sprint(calls) != "[ n2]" {
|
||||
t.Fatalf("collect = %#v calls=%v err=%v", result, calls, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageTranscriptPartialReadNeverBecomesSuccessE2E(t *testing.T) {
|
||||
result, err := CollectTranscript(func(token string) (map[string]any, error) {
|
||||
if token == "" {
|
||||
return decode(t, `{"result":{"paragraphList":[{"paragraphId":"p1"}],"hasNext":true,"nextToken":"n2"}}`), nil
|
||||
}
|
||||
return nil, errors.New("backend failed")
|
||||
}, "", false, 10)
|
||||
if err == nil || result.Complete || result.Pages != 1 || len(result.Paragraphs) != 1 || result.FailurePage != 2 {
|
||||
t.Fatalf("partial read = %#v err=%v", result, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageTranscriptCursorCycleAndSinglePageE2E(t *testing.T) {
|
||||
cycle, err := CollectTranscript(func(string) (map[string]any, error) {
|
||||
return decode(t, `{"result":{"paragraphList":[],"hasNext":true,"nextToken":"same"}}`), nil
|
||||
}, "same", false, 10)
|
||||
if err == nil || cycle.Complete {
|
||||
t.Fatalf("cursor cycle = %#v err=%v", cycle, err)
|
||||
}
|
||||
one, err := CollectTranscript(func(string) (map[string]any, error) {
|
||||
return decode(t, `{"result":{"paragraphList":[{"paragraphId":"p1"}],"hasNext":true,"nextToken":"n2"}}`), nil
|
||||
}, "", true, 10)
|
||||
if err != nil || one.Complete || one.Pages != 1 || one.NextToken != "n2" {
|
||||
t.Fatalf("single page = %#v err=%v", one, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
// Package minutesdata owns response validation and pagination facts shared by
|
||||
// Minutes shortcuts. A missing or malformed collection is never treated as an
|
||||
// empty successful result.
|
||||
package minutesdata
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Page is one validated Minutes collection page.
|
||||
type Page struct {
|
||||
Items []map[string]any
|
||||
HasMore bool
|
||||
HasMoreKnown bool
|
||||
NextToken string
|
||||
}
|
||||
|
||||
var listKeys = []string{"itemList", "minutesList", "items", "list", "records", "dataList"}
|
||||
|
||||
// ParseListPage validates list_by_keyword_and_time_range and returns its
|
||||
// explicit collection. An explicit [] is success; an absent/null/wrongly typed
|
||||
// collection is a contract error.
|
||||
func ParseListPage(data map[string]any) (Page, error) {
|
||||
if err := validateEnvelope(data); err != nil {
|
||||
return Page{}, err
|
||||
}
|
||||
containers := collectionContainers(data)
|
||||
for _, container := range containers {
|
||||
for _, key := range listKeys {
|
||||
value, exists := container[key]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
items, err := mapItems(value, key)
|
||||
if err != nil {
|
||||
return Page{}, err
|
||||
}
|
||||
hasMore, known := boolField(container, "hasMore", "hasNext")
|
||||
if !known {
|
||||
hasMore, known = boolField(data, "hasMore", "hasNext")
|
||||
}
|
||||
next := stringField(container, "nextToken", "nextCursor", "pageToken")
|
||||
if next == "" {
|
||||
next = stringField(data, "nextToken", "nextCursor", "pageToken")
|
||||
}
|
||||
if hasMore && next == "" {
|
||||
return Page{}, fmt.Errorf("minutes list declares another page but omits nextToken")
|
||||
}
|
||||
return Page{Items: items, HasMore: hasMore, HasMoreKnown: known, NextToken: next}, nil
|
||||
}
|
||||
}
|
||||
return Page{}, fmt.Errorf("minutes list response has no recognized collection (want result.itemList)")
|
||||
}
|
||||
|
||||
// ParseTranscriptPage validates get_minutes_transcription. The real response
|
||||
// collection is result.paragraphList and pagination uses hasNext/nextToken.
|
||||
func ParseTranscriptPage(data map[string]any) (Page, error) {
|
||||
if err := validateEnvelope(data); err != nil {
|
||||
return Page{}, err
|
||||
}
|
||||
containers := collectionContainers(data)
|
||||
for _, container := range containers {
|
||||
value, exists := container["paragraphList"]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
items, err := mapItems(value, "paragraphList")
|
||||
if err != nil {
|
||||
return Page{}, err
|
||||
}
|
||||
hasMore, known := boolField(container, "hasNext", "hasMore")
|
||||
if !known {
|
||||
hasMore, known = boolField(data, "hasNext", "hasMore")
|
||||
}
|
||||
next := stringField(container, "nextToken", "nextCursor")
|
||||
if next == "" {
|
||||
next = stringField(data, "nextToken", "nextCursor")
|
||||
}
|
||||
if hasMore && next == "" {
|
||||
return Page{}, fmt.Errorf("minutes transcript declares another page but omits nextToken")
|
||||
}
|
||||
return Page{Items: items, HasMore: hasMore, HasMoreKnown: known, NextToken: next}, nil
|
||||
}
|
||||
return Page{}, fmt.Errorf("minutes transcript response has no result.paragraphList collection")
|
||||
}
|
||||
|
||||
// ValidateArtifact rejects successful-looking but structurally empty artifact
|
||||
// responses. This is intentionally artifact-specific: a generic {} cannot
|
||||
// prove that the requested Minutes business data was returned.
|
||||
func ValidateArtifact(name, taskUUID string, data map[string]any) error {
|
||||
if err := validateEnvelope(data); err != nil {
|
||||
return err
|
||||
}
|
||||
result, ok := data["result"].(map[string]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("minutes %s response has no result object", name)
|
||||
}
|
||||
switch name {
|
||||
case "basic":
|
||||
actual := TaskUUID(result)
|
||||
if actual == "" {
|
||||
return fmt.Errorf("minutes basic response has no task UUID")
|
||||
}
|
||||
if taskUUID != "" && actual != taskUUID {
|
||||
return fmt.Errorf("minutes basic response task UUID mismatch")
|
||||
}
|
||||
case "summary":
|
||||
if _, exists := result["fullSummary"]; !exists {
|
||||
return fmt.Errorf("minutes summary response has no fullSummary field")
|
||||
}
|
||||
case "keywords":
|
||||
if _, err := mapSliceField(result, "keywords"); err != nil {
|
||||
return fmt.Errorf("minutes keywords response: %w", err)
|
||||
}
|
||||
case "todos":
|
||||
if _, actionsErr := mapSliceField(result, "actions"); actionsErr != nil {
|
||||
if _, todosErr := mapSliceField(result, "dingtalkTodoList"); todosErr != nil {
|
||||
return fmt.Errorf("minutes todos response has neither actions nor dingtalkTodoList array")
|
||||
}
|
||||
}
|
||||
default:
|
||||
if len(result) == 0 {
|
||||
return fmt.Errorf("minutes %s response result is empty", name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateEnvelope exposes the common backend failure check for composite
|
||||
// write/read steps that apply their own operation-specific shape validation.
|
||||
func ValidateEnvelope(data map[string]any) error {
|
||||
return validateEnvelope(data)
|
||||
}
|
||||
|
||||
// Basic returns the validated basic-info result.
|
||||
func Basic(taskUUID string, data map[string]any) (map[string]any, error) {
|
||||
if err := ValidateArtifact("basic", taskUUID, data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return data["result"].(map[string]any), nil
|
||||
}
|
||||
|
||||
// SummaryText returns the full summary, including an explicitly empty string.
|
||||
func SummaryText(data map[string]any) (string, error) {
|
||||
if err := ValidateArtifact("summary", "", data); err != nil {
|
||||
return "", err
|
||||
}
|
||||
value, ok := data["result"].(map[string]any)["fullSummary"].(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("minutes summary fullSummary has type %T, want string", data["result"].(map[string]any)["fullSummary"])
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// MediaURL returns the actual audio/video download URL. An empty result is not
|
||||
// accepted as "no media" because it cannot distinguish processing, expiry and
|
||||
// permission failures.
|
||||
func MediaURL(data map[string]any) (string, error) {
|
||||
if err := validateEnvelope(data); err != nil {
|
||||
return "", err
|
||||
}
|
||||
result, ok := data["result"].(map[string]any)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("minutes media response has no result object")
|
||||
}
|
||||
value := stringField(result, "videoUrl", "audioUrl", "downloadUrl", "url")
|
||||
if value == "" {
|
||||
return "", fmt.Errorf("minutes media response has no audio/video URL")
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// UploadSession extracts the two facts required to continue a local upload.
|
||||
func UploadSession(data map[string]any) (sessionID, presignedURL string, err error) {
|
||||
if err := validateEnvelope(data); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
result, ok := data["result"].(map[string]any)
|
||||
if !ok {
|
||||
return "", "", fmt.Errorf("minutes upload create response has no result object")
|
||||
}
|
||||
sessionID = stringField(result, "sessionId", "uploadId")
|
||||
presignedURL = stringField(result, "presignedUrl", "uploadUrl")
|
||||
if sessionID == "" || presignedURL == "" {
|
||||
return "", "", fmt.Errorf("minutes upload create response is missing sessionId or presignedUrl")
|
||||
}
|
||||
return sessionID, presignedURL, nil
|
||||
}
|
||||
|
||||
// CompletedTaskUUID extracts the created Minutes task UUID from upload complete.
|
||||
func CompletedTaskUUID(data map[string]any) (string, error) {
|
||||
if err := validateEnvelope(data); err != nil {
|
||||
return "", err
|
||||
}
|
||||
containers := collectionContainers(data)
|
||||
for _, container := range containers {
|
||||
if uuid := TaskUUID(container); uuid != "" {
|
||||
return uuid, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("minutes upload complete response has no task UUID")
|
||||
}
|
||||
|
||||
// ProjectList converts validated list items into the stable Shortcut view.
|
||||
// Every row must carry a real task UUID; malformed rows fail the entire page
|
||||
// instead of disappearing and masquerading as an empty result.
|
||||
func ProjectList(page Page) ([]map[string]any, error) {
|
||||
out := make([]map[string]any, 0, len(page.Items))
|
||||
for index, item := range page.Items {
|
||||
uuid := TaskUUID(item)
|
||||
if uuid == "" {
|
||||
return nil, fmt.Errorf("minutes list item %d has no task UUID", index)
|
||||
}
|
||||
row := map[string]any{"taskUuid": uuid}
|
||||
copyFirst(row, "title", item, "title", "name")
|
||||
copyFirst(row, "creator", item, "creator", "creatorName", "createUserName", "creatorNick")
|
||||
copyFirst(row, "startTime", item, "startTime", "gmtStart", "beginTime", "createTime")
|
||||
copyFirst(row, "endTime", item, "endTime", "gmtEnd", "deadline")
|
||||
copyFirst(row, "url", item, "url", "shareUrl", "link")
|
||||
copyFirst(row, "status", item, "status", "taskStatus", "state")
|
||||
out = append(out, row)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// TaskUUID returns only spellings known to identify a Minutes task. A generic
|
||||
// id/minutesId is deliberately not accepted because record-control consumes
|
||||
// this value directly as the task UUID.
|
||||
func TaskUUID(item map[string]any) string {
|
||||
return stringField(item, "taskUuid", "taskUUID", "task_uuid", "uuid")
|
||||
}
|
||||
|
||||
// LatestTaskUUID selects the newest item by an explicit comparable timestamp.
|
||||
// It never silently assumes that an undocumented response order is newest-first.
|
||||
func LatestTaskUUID(page Page) (string, error) {
|
||||
if len(page.Items) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
bestUUID := ""
|
||||
var bestTime float64
|
||||
haveTime := false
|
||||
for index, item := range page.Items {
|
||||
uuid := TaskUUID(item)
|
||||
if uuid == "" {
|
||||
return "", fmt.Errorf("minutes list item %d has no task UUID", index)
|
||||
}
|
||||
value, ok := timestamp(item, "createTime", "gmtCreate", "startTime", "createTimeStart")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if !haveTime || value > bestTime {
|
||||
haveTime = true
|
||||
bestTime = value
|
||||
bestUUID = uuid
|
||||
}
|
||||
}
|
||||
if !haveTime {
|
||||
return "", fmt.Errorf("minutes list is non-empty but no item has a comparable creation/start time")
|
||||
}
|
||||
return bestUUID, nil
|
||||
}
|
||||
|
||||
// StableItemKey returns a deterministic de-duplication key for a transcript
|
||||
// paragraph. Stable IDs win; otherwise the canonical JSON object is used.
|
||||
func StableItemKey(item map[string]any) (string, error) {
|
||||
if id := stringField(item, "paragraphId", "sentenceId", "segmentId", "id"); id != "" {
|
||||
return "id:" + id, nil
|
||||
}
|
||||
raw, err := json.Marshal(item)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("encode transcript paragraph key: %w", err)
|
||||
}
|
||||
return "json:" + string(raw), nil
|
||||
}
|
||||
|
||||
func validateEnvelope(data map[string]any) error {
|
||||
if len(data) == 0 {
|
||||
return fmt.Errorf("minutes response is empty")
|
||||
}
|
||||
if success, ok := data["success"].(bool); ok && !success {
|
||||
return fmt.Errorf("minutes backend reported failure: %s", envelopeMessage(data))
|
||||
}
|
||||
for _, key := range []string{"errorCode", "dingOpenErrcode"} {
|
||||
value, exists := data[key]
|
||||
if !exists || isZeroCode(value) {
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("minutes backend reported %s=%v: %s", key, value, envelopeMessage(data))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func collectionContainers(data map[string]any) []map[string]any {
|
||||
containers := []map[string]any{data}
|
||||
for _, key := range []string{"result", "data"} {
|
||||
if inner, ok := data[key].(map[string]any); ok {
|
||||
containers = append(containers, inner)
|
||||
for _, nestedKey := range []string{"result", "data"} {
|
||||
if nested, ok := inner[nestedKey].(map[string]any); ok {
|
||||
containers = append(containers, nested)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return containers
|
||||
}
|
||||
|
||||
func mapItems(value any, field string) ([]map[string]any, error) {
|
||||
raw, ok := value.([]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("minutes response field %s has type %T, want array", field, value)
|
||||
}
|
||||
items := make([]map[string]any, 0, len(raw))
|
||||
for index, value := range raw {
|
||||
item, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("minutes response field %s item %d has type %T, want object", field, index, value)
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func mapSliceField(data map[string]any, field string) ([]any, error) {
|
||||
value, exists := data[field]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("missing %s", field)
|
||||
}
|
||||
items, ok := value.([]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("field %s has type %T, want array", field, value)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func copyFirst(dst map[string]any, dstKey string, src map[string]any, keys ...string) {
|
||||
for _, key := range keys {
|
||||
if value, ok := src[key]; ok && value != nil {
|
||||
dst[dstKey] = value
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func boolField(data map[string]any, keys ...string) (bool, bool) {
|
||||
for _, key := range keys {
|
||||
if value, ok := data[key].(bool); ok {
|
||||
return value, true
|
||||
}
|
||||
}
|
||||
return false, false
|
||||
}
|
||||
|
||||
func stringField(data map[string]any, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
value, exists := data[key]
|
||||
if !exists || value == nil {
|
||||
continue
|
||||
}
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
if strings.TrimSpace(typed) != "" {
|
||||
return strings.TrimSpace(typed)
|
||||
}
|
||||
case json.Number:
|
||||
return typed.String()
|
||||
case float64:
|
||||
return strconv.FormatFloat(typed, 'f', -1, 64)
|
||||
case int:
|
||||
return strconv.Itoa(typed)
|
||||
case int64:
|
||||
return strconv.FormatInt(typed, 10)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func timestamp(data map[string]any, keys ...string) (float64, bool) {
|
||||
for _, key := range keys {
|
||||
value, exists := data[key]
|
||||
if !exists || value == nil {
|
||||
continue
|
||||
}
|
||||
switch typed := value.(type) {
|
||||
case float64:
|
||||
return typed, true
|
||||
case int:
|
||||
return float64(typed), true
|
||||
case int64:
|
||||
return float64(typed), true
|
||||
case json.Number:
|
||||
if parsed, err := typed.Float64(); err == nil {
|
||||
return parsed, true
|
||||
}
|
||||
case string:
|
||||
if parsed, err := strconv.ParseFloat(strings.TrimSpace(typed), 64); err == nil {
|
||||
return parsed, true
|
||||
}
|
||||
if parsed, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(typed)); err == nil {
|
||||
return float64(parsed.UnixNano()), true
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func isZeroCode(value any) bool {
|
||||
switch typed := value.(type) {
|
||||
case nil:
|
||||
return true
|
||||
case string:
|
||||
trimmed := strings.TrimSpace(typed)
|
||||
return trimmed == "" || trimmed == "0"
|
||||
case float64:
|
||||
return typed == 0
|
||||
case int:
|
||||
return typed == 0
|
||||
case int64:
|
||||
return typed == 0
|
||||
case json.Number:
|
||||
return typed.String() == "0"
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func envelopeMessage(data map[string]any) string {
|
||||
for _, key := range []string{"errorMsg", "message", "msg"} {
|
||||
if value := stringField(data, key); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return "no backend error message"
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package minutesdata
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func decode(t *testing.T, raw string) map[string]any {
|
||||
t.Helper()
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &data); err != nil {
|
||||
t.Fatalf("decode fixture: %v", err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageListExplicitEmptyAndRealItemListE2E(t *testing.T) {
|
||||
empty, err := ParseListPage(decode(t, `{"success":true,"result":{"itemList":[],"hasMore":false,"nextToken":""}}`))
|
||||
if err != nil || len(empty.Items) != 0 || empty.HasMore {
|
||||
t.Fatalf("explicit empty = %#v, %v", empty, err)
|
||||
}
|
||||
page, err := ParseListPage(decode(t, `{"success":true,"result":{"itemList":[{"uuid":"u1","title":"n","startTime":"2026-08-10T01:00:00Z"}],"hasMore":true,"nextToken":"next"}}`))
|
||||
if err != nil || len(page.Items) != 1 || !page.HasMore || page.NextToken != "next" {
|
||||
t.Fatalf("real itemList = %#v, %v", page, err)
|
||||
}
|
||||
rows, err := ProjectList(page)
|
||||
if err != nil || len(rows) != 1 || rows[0]["taskUuid"] != "u1" {
|
||||
t.Fatalf("projection = %#v, %v", rows, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageListUnknownOrMalformedIsNotEmptySuccessE2E(t *testing.T) {
|
||||
tests := []string{
|
||||
`{}`,
|
||||
`{"success":true}`,
|
||||
`{"success":false,"errorMsg":"denied"}`,
|
||||
`{"success":true,"result":{"itemList":null}}`,
|
||||
`{"success":true,"result":{"itemList":{}}}`,
|
||||
`{"success":true,"result":{"itemList":["bad"]}}`,
|
||||
`{"success":true,"result":{"hasMore":true,"itemList":[]}}`,
|
||||
}
|
||||
for _, raw := range tests {
|
||||
if page, err := ParseListPage(decode(t, raw)); err == nil {
|
||||
t.Fatalf("unknown response accepted as %#v: %s", page, raw)
|
||||
}
|
||||
}
|
||||
page, err := ParseListPage(decode(t, `{"success":true,"result":{"itemList":[{"title":"missing uuid"}]}}`))
|
||||
if err != nil {
|
||||
t.Fatalf("parse list envelope: %v", err)
|
||||
}
|
||||
if rows, err := ProjectList(page); err == nil || rows != nil {
|
||||
t.Fatalf("malformed item projected: %#v, %v", rows, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageTranscriptShapeAndPaginationContractE2E(t *testing.T) {
|
||||
page, err := ParseTranscriptPage(decode(t, `{"success":true,"result":{"paragraphList":[{"paragraphId":"p1"}],"hasNext":true,"nextToken":"n2"}}`))
|
||||
if err != nil || len(page.Items) != 1 || !page.HasMore || page.NextToken != "n2" {
|
||||
t.Fatalf("transcript page = %#v, %v", page, err)
|
||||
}
|
||||
if key, err := StableItemKey(page.Items[0]); err != nil || key != "id:p1" {
|
||||
t.Fatalf("stable key = %q, %v", key, err)
|
||||
}
|
||||
for _, raw := range []string{
|
||||
`{"success":true,"result":{"paragraphList":null}}`,
|
||||
`{"success":true,"result":{"paragraphList":[],"hasNext":true}}`,
|
||||
`{"success":true,"result":{}}`,
|
||||
} {
|
||||
if _, err := ParseTranscriptPage(decode(t, raw)); err == nil {
|
||||
t.Fatalf("invalid transcript accepted: %s", raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLatestRequiresComparableTimestampE2E(t *testing.T) {
|
||||
page, err := ParseListPage(decode(t, `{"result":{"itemList":[{"uuid":"old","startTime":1},{"uuid":"new","startTime":"2"}]}}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, err := LatestTaskUUID(page); err != nil || got != "new" {
|
||||
t.Fatalf("latest = %q, %v", got, err)
|
||||
}
|
||||
page, err = ParseListPage(decode(t, `{"result":{"itemList":[{"uuid":"u"}]}}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := LatestTaskUUID(page); err == nil || !strings.Contains(err.Error(), "comparable") {
|
||||
t.Fatalf("missing time accepted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageArtifactEmptyResultIsNotSuccessE2E(t *testing.T) {
|
||||
valid := map[string]string{
|
||||
"basic": `{"success":true,"result":{"taskUuid":"u"}}`,
|
||||
"summary": `{"success":true,"result":{"fullSummary":""}}`,
|
||||
"keywords": `{"success":true,"result":{"keywords":[]}}`,
|
||||
"todos": `{"success":true,"result":{"actions":[]}}`,
|
||||
}
|
||||
for name, raw := range valid {
|
||||
if err := ValidateArtifact(name, "u", decode(t, raw)); err != nil {
|
||||
t.Fatalf("valid %s rejected: %v", name, err)
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"basic", "summary", "keywords", "todos"} {
|
||||
if err := ValidateArtifact(name, "u", decode(t, `{"success":true,"result":{}}`)); err == nil {
|
||||
t.Fatalf("empty %s result accepted", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package minutesdata
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// RequireWriteAcknowledgement accepts only an explicit successful envelope or
|
||||
// a non-empty result object. A bare {} cannot prove a remote write succeeded.
|
||||
func RequireWriteAcknowledgement(operation string, data map[string]any) error {
|
||||
if err := validateEnvelope(data); err != nil {
|
||||
return err
|
||||
}
|
||||
if success, exists := data["success"]; exists {
|
||||
value, ok := success.(bool)
|
||||
if !ok || !value {
|
||||
return fmt.Errorf("minutes %s response has invalid success acknowledgement", operation)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if result, ok := data["result"].(map[string]any); ok && len(result) > 0 {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("minutes %s response has no explicit successful acknowledgement", operation)
|
||||
}
|
||||
|
||||
// RecordResult validates the gateway's observed listening-note command result.
|
||||
func RecordResult(expectedCmd, taskUUID string, data map[string]any) (map[string]any, error) {
|
||||
if err := RequireWriteAcknowledgement("record "+expectedCmd, data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result, ok := data["result"].(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("minutes record %s response has no result object", expectedCmd)
|
||||
}
|
||||
cmd := stringField(result, "cmd")
|
||||
if cmd != expectedCmd {
|
||||
return nil, fmt.Errorf("minutes record response cmd %q does not match %q", cmd, expectedCmd)
|
||||
}
|
||||
if expectedCmd != "create" {
|
||||
actual := TaskUUID(result)
|
||||
if actual == "" || actual != taskUUID {
|
||||
return nil, fmt.Errorf("minutes record %s response task UUID mismatch", expectedCmd)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// MindGraphStatus extracts the documented 0=processing, 1=success, 2=failed
|
||||
// state and rejects empty status responses.
|
||||
func MindGraphStatus(data map[string]any) (int, map[string]any, error) {
|
||||
if err := validateEnvelope(data); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
result, ok := data["result"].(map[string]any)
|
||||
if !ok {
|
||||
return 0, nil, fmt.Errorf("minutes mind graph response has no result object")
|
||||
}
|
||||
value, exists := result["taskStatus"]
|
||||
if !exists {
|
||||
return 0, nil, fmt.Errorf("minutes mind graph response has no taskStatus")
|
||||
}
|
||||
status, err := intValue(value)
|
||||
if err != nil || status < 0 || status > 2 {
|
||||
return 0, nil, fmt.Errorf("minutes mind graph taskStatus is invalid: %v", value)
|
||||
}
|
||||
return status, result, nil
|
||||
}
|
||||
|
||||
// SpeakerSummaryTask extracts the recovery handle returned by task creation.
|
||||
func SpeakerSummaryTask(data map[string]any) (taskID, status string, err error) {
|
||||
if err := RequireWriteAcknowledgement("speaker summary create", data); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
result, ok := data["result"].(map[string]any)
|
||||
if !ok {
|
||||
return "", "", fmt.Errorf("minutes speaker summary create response has no result object")
|
||||
}
|
||||
taskID = stringField(result, "taskId", "taskID")
|
||||
status = strings.ToLower(stringField(result, "status"))
|
||||
if taskID == "" {
|
||||
return "", "", fmt.Errorf("minutes speaker summary create response has no taskId")
|
||||
}
|
||||
return taskID, status, nil
|
||||
}
|
||||
|
||||
// SpeakerSummaryResult requires a concrete result payload. The API returns a
|
||||
// business error while processing, so an empty object is never ready.
|
||||
func SpeakerSummaryResult(data map[string]any) (any, error) {
|
||||
if err := validateEnvelope(data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result, exists := data["result"]
|
||||
if !exists || result == nil {
|
||||
return nil, fmt.Errorf("minutes speaker summary response has no result")
|
||||
}
|
||||
switch value := result.(type) {
|
||||
case map[string]any:
|
||||
if len(value) == 0 {
|
||||
return nil, fmt.Errorf("minutes speaker summary result is empty")
|
||||
}
|
||||
case []any:
|
||||
if value == nil {
|
||||
return nil, fmt.Errorf("minutes speaker summary result is null")
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("minutes speaker summary result has type %T", result)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// HotWords extracts the explicit current personal hot-word set. [] is valid;
|
||||
// a missing/null/wrongly typed list is not.
|
||||
func HotWords(data map[string]any) ([]string, error) {
|
||||
if err := validateEnvelope(data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result, ok := data["result"].(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("minutes hot-word response has no result object")
|
||||
}
|
||||
raw, exists := result["hotWordList"]
|
||||
if !exists || raw == nil {
|
||||
return nil, fmt.Errorf("minutes hot-word response has no hotWordList array")
|
||||
}
|
||||
items, ok := raw.([]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("minutes hotWordList has type %T, want array", raw)
|
||||
}
|
||||
out := make([]string, 0, len(items))
|
||||
seen := map[string]bool{}
|
||||
for index, item := range items {
|
||||
word := ""
|
||||
switch value := item.(type) {
|
||||
case string:
|
||||
word = value
|
||||
case map[string]any:
|
||||
word = stringField(value, "hotWord", "word", "name", "content")
|
||||
}
|
||||
word = strings.TrimSpace(word)
|
||||
if word == "" {
|
||||
return nil, fmt.Errorf("minutes hotWordList item %d has no word", index)
|
||||
}
|
||||
if !seen[word] {
|
||||
seen[word] = true
|
||||
out = append(out, word)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func intValue(value any) (int, error) {
|
||||
switch typed := value.(type) {
|
||||
case float64:
|
||||
if math.IsNaN(typed) || math.IsInf(typed, 0) || math.Trunc(typed) != typed || typed < 0 || typed > 2 {
|
||||
return 0, fmt.Errorf("invalid status number %v", typed)
|
||||
}
|
||||
return int(typed), nil
|
||||
case int:
|
||||
return typed, nil
|
||||
case json.Number:
|
||||
parsed, err := strconv.Atoi(string(typed))
|
||||
return parsed, err
|
||||
case string:
|
||||
return strconv.Atoi(strings.TrimSpace(typed))
|
||||
default:
|
||||
return 0, fmt.Errorf("unsupported number type %T", value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package minutesdata
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageMinutesWorkflowShapesRejectUnknownE2E(t *testing.T) {
|
||||
if err := RequireWriteAcknowledgement("write", map[string]any{}); err == nil {
|
||||
t.Fatal("bare write response accepted")
|
||||
}
|
||||
if _, err := HotWords(map[string]any{"success": true, "result": map[string]any{}}); err == nil {
|
||||
t.Fatal("missing hot-word list accepted")
|
||||
}
|
||||
if _, _, err := MindGraphStatus(map[string]any{"success": true, "result": map[string]any{}}); err == nil {
|
||||
t.Fatal("missing mind graph status accepted")
|
||||
}
|
||||
if _, _, err := MindGraphStatus(map[string]any{"success": true, "result": map[string]any{"taskStatus": 1.5}}); err == nil {
|
||||
t.Fatal("fractional mind graph status accepted")
|
||||
}
|
||||
if _, err := SpeakerSummaryResult(map[string]any{"success": true, "result": map[string]any{}}); err == nil {
|
||||
t.Fatal("empty speaker summary accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesWorkflowObservedShapesE2E(t *testing.T) {
|
||||
record, err := RecordResult("pause", "u1", map[string]any{"success": true, "result": map[string]any{"cmd": "pause", "uuid": "u1"}})
|
||||
if err != nil || record["cmd"] != "pause" {
|
||||
t.Fatalf("record=%#v err=%v", record, err)
|
||||
}
|
||||
status, _, err := MindGraphStatus(map[string]any{"success": true, "result": map[string]any{"taskStatus": float64(1)}})
|
||||
if err != nil || status != 1 {
|
||||
t.Fatalf("mind status=%d err=%v", status, err)
|
||||
}
|
||||
status, _, err = MindGraphStatus(map[string]any{"success": true, "result": map[string]any{"taskStatus": json.Number("1")}})
|
||||
if err != nil || status != 1 {
|
||||
t.Fatalf("number-preserving mind status=%d err=%v", status, err)
|
||||
}
|
||||
taskID, state, err := SpeakerSummaryTask(map[string]any{"success": true, "result": map[string]any{"taskId": "job-1", "status": "processing"}})
|
||||
if err != nil || taskID != "job-1" || state != "processing" {
|
||||
t.Fatalf("speaker task=%q state=%q err=%v", taskID, state, err)
|
||||
}
|
||||
words, err := HotWords(map[string]any{"success": true, "result": map[string]any{"hotWordList": []any{"DWS", map[string]any{"hotWord": "听记"}}}})
|
||||
if err != nil || len(words) != 2 || words[1] != "听记" {
|
||||
t.Fatalf("words=%#v err=%v", words, err)
|
||||
}
|
||||
}
|
||||
@@ -334,12 +334,33 @@ func generatedPublicShortcutCatalog() map[string]struct{} {
|
||||
"mail\u0000+thread-list": {},
|
||||
"mail\u0000+unread-mail": {},
|
||||
"mail\u0000+user-search": {},
|
||||
"minutes\u0000+action-items": {},
|
||||
"minutes\u0000+apply-permission": {},
|
||||
"minutes\u0000+detail": {},
|
||||
"minutes\u0000+download": {},
|
||||
"minutes\u0000+export-pack": {},
|
||||
"minutes\u0000+latest": {},
|
||||
"minutes\u0000+list-all": {},
|
||||
"minutes\u0000+list-mine": {},
|
||||
"minutes\u0000+list-shared": {},
|
||||
"minutes\u0000+mindmap": {},
|
||||
"minutes\u0000+prepare-asr": {},
|
||||
"minutes\u0000+record-pause": {},
|
||||
"minutes\u0000+record-resume": {},
|
||||
"minutes\u0000+record-start": {},
|
||||
"minutes\u0000+record-stop": {},
|
||||
"minutes\u0000+record-wrap-up": {},
|
||||
"minutes\u0000+replace-batch": {},
|
||||
"minutes\u0000+search": {},
|
||||
"minutes\u0000+share": {},
|
||||
"minutes\u0000+speaker-insights": {},
|
||||
"minutes\u0000+speaker-replace": {},
|
||||
"minutes\u0000+summary": {},
|
||||
"minutes\u0000+transcript": {},
|
||||
"minutes\u0000+unshare": {},
|
||||
"minutes\u0000+update": {},
|
||||
"minutes\u0000+upload": {},
|
||||
"minutes\u0000+upload-and-analyze": {},
|
||||
"oa\u0000+list-cc": {},
|
||||
"oa\u0000+list-executed": {},
|
||||
"oa\u0000+list-forms": {},
|
||||
|
||||
@@ -19,6 +19,9 @@ var docSemanticCatalogJSON []byte
|
||||
//go:embed semantic_catalog_aitable.json
|
||||
var aitableSemanticCatalogJSON []byte
|
||||
|
||||
//go:embed semantic_catalog_minutes.json
|
||||
var minutesSemanticCatalogJSON []byte
|
||||
|
||||
type semanticCatalogFile struct {
|
||||
Version int `json:"version"`
|
||||
Service string `json:"service"`
|
||||
@@ -40,6 +43,7 @@ var reviewedSemanticCatalog = mustLoadSemanticCatalogs(
|
||||
semanticCatalogJSON,
|
||||
docSemanticCatalogJSON,
|
||||
aitableSemanticCatalogJSON,
|
||||
minutesSemanticCatalogJSON,
|
||||
)
|
||||
|
||||
func mustLoadSemanticCatalogs(sources ...[]byte) map[string]semanticCatalogRecord {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"version": 1,
|
||||
"service": "minutes",
|
||||
"default_availability": "available",
|
||||
"shortcuts": {
|
||||
"+search": {"disposition":"primary_smart","semantic_delta":"Validated itemList parsing, deterministic title filtering, bounded cursor pagination, de-duplication and completeness ledger; replaces the deprecated +minutes-search discovery route.","risk":"read","public":true,"reviewed":true},
|
||||
"+minutes-search": {"disposition":"alias_internal","semantic_delta":"Hidden compatibility leaf preserving the historical Schema identity and argv while routing new discovery to +search.","risk":"read","primary":"+search","public":false,"reviewed":true},
|
||||
"+download": {"disposition":"primary_smart","semantic_delta":"Resolves real audio/video URLs, validates response shape and performs batch safe atomic local downloads with an explicit failure ledger.","risk":"read","public":true,"reviewed":true},
|
||||
"+upload": {"disposition":"primary_smart","semantic_delta":"Owns local file validation, create-PUT-complete polling, pre-complete transfer cancellation compensation, unknown-completion recovery and final task read-back verification.","risk":"write","public":true,"reviewed":true},
|
||||
"+update": {"disposition":"semantic_adapter","semantic_delta":"Reads the current title, previews the diff, avoids no-op writes and verifies the final title by read-back.","risk":"write","public":true,"reviewed":true},
|
||||
"+apply-permission": {"disposition":"semantic_adapter","semantic_delta":"Maps view/download/edit intent to policy 4/3/2 and validates the permission-request response.","risk":"write","public":true,"reviewed":true},
|
||||
"+summary": {"disposition":"primary_smart","semantic_delta":"Reads current summary, supports literal/file/stdin, preserves Markdown images, previews the change and verifies full read-back.","risk":"write","public":true,"reviewed":true},
|
||||
"+speaker-replace": {"disposition":"primary_smart","semantic_delta":"Fully paginates the transcript to preflight source speaker presence and verifies the nickname replacement after writing.","risk":"write","public":true,"reviewed":true},
|
||||
"+record-wrap-up": {"disposition":"primary_smart","semantic_delta":"Stops recording once, then boundedly waits for selected validated artifacts and preserves taskUuid recovery on partial completion.","risk":"write","public":true,"reviewed":true},
|
||||
"+upload-and-analyze": {"disposition":"primary_smart","semantic_delta":"Completes local upload with compensation, waits for validated artifacts, and optionally orchestrates mind map and speaker insights without re-upload recovery hazards.","risk":"write","public":true,"reviewed":true},
|
||||
"+mindmap": {"disposition":"primary_smart","semantic_delta":"Creates the asynchronous mind-graph task exactly once and polls the explicit 0/1/2 taskStatus to success, failure or timeout.","risk":"write","public":true,"reviewed":true},
|
||||
"+speaker-insights": {"disposition":"primary_smart","semantic_delta":"Requires a real async taskId, boundedly polls speaker summaries and returns task recovery handles when content is not ready.","risk":"write","public":true,"reviewed":true},
|
||||
"+prepare-asr": {"disposition":"primary_smart","semantic_delta":"Reads the real personal hot-word set, computes deterministic add/delete differences, defaults to additive changes and verifies final state.","risk":"write","public":true,"reviewed":true},
|
||||
"+export-pack": {"disposition":"primary_smart","semantic_delta":"Validates selected artifacts, safely publishes a no-clobber local directory and emits a manifest without signed URLs or credentials.","risk":"read","public":true,"reviewed":true},
|
||||
"+share": {"disposition":"primary_smart","semantic_delta":"Grants semantic view/download/edit permissions per stable member UID with stop/continue partial-write ledgers and explicit acknowledgements.","risk":"write","public":true,"reviewed":true},
|
||||
"+unshare": {"disposition":"primary_smart","semantic_delta":"Removes permission per stable member UID with stop/continue partial-write ledgers and explicit acknowledgements.","risk":"write","public":true,"reviewed":true},
|
||||
"+list-mine": {"disposition":"schema_leaf","semantic_delta":"Stable projection of current-user Minutes with strict itemList response validation.","risk":"read","public":true,"reviewed":true},
|
||||
"+list-shared": {"disposition":"schema_leaf","semantic_delta":"Stable projection of shared Minutes with strict itemList response validation.","risk":"read","public":true,"reviewed":true},
|
||||
"+list-all": {"disposition":"schema_leaf","semantic_delta":"Stable projection of all accessible Minutes with strict itemList response validation.","risk":"read","public":true,"reviewed":true},
|
||||
"+record-start": {"disposition":"semantic_adapter","semantic_delta":"Friendly recording-create facade with explicit confirmation and stable session parameter.","risk":"write","public":true,"reviewed":true},
|
||||
"+record-pause": {"disposition":"semantic_adapter","semantic_delta":"Friendly recording-pause facade with explicit task UUID validation and confirmation.","risk":"write","public":true,"reviewed":true},
|
||||
"+record-resume": {"disposition":"semantic_adapter","semantic_delta":"Friendly recording-resume facade with explicit task UUID validation and confirmation.","risk":"write","public":true,"reviewed":true},
|
||||
"+record-stop": {"disposition":"semantic_adapter","semantic_delta":"Friendly recording-stop facade with explicit task UUID validation and confirmation.","risk":"write","public":true,"reviewed":true},
|
||||
"+latest": {"disposition":"primary_smart","semantic_delta":"Validates the real itemList response and chooses latest only from an explicit comparable timestamp before reading details; +latest-minutes remains a compatibility alias.","risk":"read","public":true,"reviewed":true},
|
||||
"+transcript": {"disposition":"primary_smart","semantic_delta":"Resolves an explicit or latest task, follows every transcript cursor, de-duplicates paragraphs and publishes completeness.","risk":"read","public":true,"reviewed":true},
|
||||
"+action-items": {"disposition":"primary_smart","semantic_delta":"Resolves the latest task through strict itemList parsing and retrieves extracted Minutes actions without pretending to write Todo objects.","risk":"read","public":true,"reviewed":true},
|
||||
"+detail": {"disposition":"primary_smart","semantic_delta":"Fans out selected artifacts, fully paginates transcript data, validates artifact-specific shapes and returns non-zero on partial reads.","risk":"read","public":true,"reviewed":true},
|
||||
"+replace-batch": {"disposition":"primary_smart","semantic_delta":"Validates multi-rule input, supports JSON/file/stdin and reports partial writes with stop/continue policy and non-zero failure.","risk":"write","public":true,"reviewed":true}
|
||||
}
|
||||
}
|
||||
@@ -14,8 +14,10 @@
|
||||
package smart
|
||||
|
||||
import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/minutesdata"
|
||||
)
|
||||
|
||||
// ActionItems: fetch the extracted to-do items (待办事项) from MY most recent
|
||||
@@ -24,8 +26,7 @@ import (
|
||||
// Steps:
|
||||
// 1. list my minutes via list_by_keyword_and_time_range
|
||||
// (belongingConditionId = "created", maxResults = 20);
|
||||
// 2. pick the newest entry (largest create time, falling back to the first
|
||||
// item since lists come back newest-first) and read its taskUuid;
|
||||
// 2. pick the newest entry by an explicit comparable timestamp and read its taskUuid;
|
||||
// 3. print that minute's extracted to-do items via list_minutes_todos.
|
||||
//
|
||||
// If the list is empty it reports "暂无妙记" instead of failing obscurely.
|
||||
@@ -35,41 +36,68 @@ var ActionItems = shortcut.Shortcut{
|
||||
Service: "minutes",
|
||||
Command: "+action-items",
|
||||
Product: "minutes",
|
||||
Description: "取我最新一条妙记里的待办事项",
|
||||
Intent: "当你刚开完会、只想立刻知道自己最近这条听记里系统帮你提取出了哪些待办事项," +
|
||||
"却不想先翻听记列表、复制 taskUuid、再手动去查待办时使用;" +
|
||||
"内部会先列出你创建的听记,自动挑出最新的一条,再拉取这条听记里提取的待办事项清单(含待办内容、参与人、待办时间等)。" +
|
||||
"这是只读操作,不会新增或修改任何待办;若你名下没有任何听记则提示「暂无妙记」。",
|
||||
Description: "读取指定或我最新一条听记中已抽取的行动项",
|
||||
Intent: "当你要读取已知 taskUuid(--id)的听记行动项,或不传 --id 自动选择自己最新听记时使用;" +
|
||||
"只接受服务端显式 actions/dingtalkTodoList 数组,合法空数组表示没有抽取到行动项,缺字段或错误响应不会被当成空成功。" +
|
||||
"这是只读的 Minutes 产物读取,不会创建或修改钉钉 Todo。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: minutesSmartContract(
|
||||
"+action-items",
|
||||
"读取最新听记中已抽取的行动项",
|
||||
"要读取指定听记(--id)或默认最新听记中由听记服务抽取的 actions/dingtalkTodoList 时使用;该命令只读,不会创建钉钉待办。",
|
||||
[]string{"要创建或修改真正的钉钉待办时使用 Todo 产品命令"},
|
||||
[]string{"dws minutes +action-items --id <taskUuid>", "dws minutes +action-items"},
|
||||
nil,
|
||||
),
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "id", Type: shortcut.FlagString, Desc: "听记 taskUuid;不传时选择我最新的一条"},
|
||||
},
|
||||
Tips: []string{
|
||||
`dws minutes +action-items --id <taskUuid>`,
|
||||
`dws minutes +action-items`,
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
// Step 1 — list my minutes (newest first). belongingConditionId /
|
||||
// maxResults mirror helpers.callListByKeywordRange.
|
||||
data, err := rt.CallMCPData("minutes", "list_by_keyword_and_time_range", map[string]any{
|
||||
"belongingConditionId": "created",
|
||||
"maxResults": float64(20),
|
||||
taskUUID := rt.Str("id")
|
||||
if taskUUID == "" {
|
||||
// Step 1 — list my minutes (newest first). belongingConditionId /
|
||||
// maxResults mirror helpers.callListByKeywordRange.
|
||||
data, err := rt.CallMCPData("minutes", "list_by_keyword_and_time_range", map[string]any{
|
||||
"belongingConditionId": "created",
|
||||
"maxResults": float64(20),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 2 — locate the newest minute's taskUuid. Reuse the defensive
|
||||
// list/UUID/createTime parsing from latest_minutes.go.
|
||||
taskUUID, err = latestMinutesTaskUUID(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if taskUUID == "" {
|
||||
return apperrors.NewValidation("暂无妙记")
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3 — validate and print its extracted to-do items. Empty arrays are
|
||||
// valid business data; a missing result/actions collection is not.
|
||||
todosData, err := rt.CallMCPData("minutes", "list_minutes_todos", map[string]any{
|
||||
"taskUuid": taskUUID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 2 — locate the newest minute's taskUuid. Reuse the defensive
|
||||
// list/UUID/createTime parsing from latest_minutes.go.
|
||||
taskUUID := latestMinutesTaskUUID(data)
|
||||
if taskUUID == "" {
|
||||
return apperrors.NewValidation("暂无妙记")
|
||||
if err := minutesdata.ValidateArtifact("todos", taskUUID, todosData); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 3 — print its extracted to-do items (taskUuid param mirrors
|
||||
// helpers' list_minutes_todos call).
|
||||
return rt.CallMCP("list_minutes_todos", map[string]any{
|
||||
"taskUuid": taskUUID,
|
||||
})
|
||||
return rt.Output(todosData["result"])
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
shortcut.Register(ActionItems)
|
||||
shortcut.Register(finalizeMinutesSmartShortcut(ActionItems))
|
||||
}
|
||||
|
||||
@@ -14,8 +14,10 @@
|
||||
package smart
|
||||
|
||||
import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/minutesdata"
|
||||
)
|
||||
|
||||
// LatestMinutes: fetch the details of MY most recent minutes (听记) in one step.
|
||||
@@ -23,29 +25,40 @@ import (
|
||||
// Steps:
|
||||
// 1. list my minutes via list_by_keyword_and_time_range (belongingConditionId
|
||||
// = "created"), optionally filtered by --keyword;
|
||||
// 2. pick the newest entry (largest create time, falling back to the first
|
||||
// item) and read its taskUuid;
|
||||
// 2. pick the newest entry by an explicit comparable timestamp and read its taskUuid;
|
||||
// 3. print that minute's basic info via get_minutes_basic_info.
|
||||
//
|
||||
// If the list is empty it reports "暂无妙记" instead of failing obscurely.
|
||||
//
|
||||
// dws minutes +latest-minutes
|
||||
// dws minutes +latest-minutes --keyword 周会
|
||||
// dws minutes +latest
|
||||
// dws minutes +latest --keyword 周会
|
||||
var LatestMinutes = shortcut.Shortcut{
|
||||
Service: "minutes",
|
||||
Command: "+latest-minutes",
|
||||
Command: "+latest",
|
||||
Aliases: []string{"+latest-minutes"},
|
||||
Product: "minutes",
|
||||
Description: "取我最新的一条妙记(听记)详情",
|
||||
Intent: "当你只想快速看回自己最近的一条会议听记,却不想先翻列表、复制 taskUuid 再查详情时使用;" +
|
||||
"内部先列出你创建的听记(可用 --keyword 缩小范围),自动挑出最新的一条,再拉取它的基础信息(标题、创建人、时间、访问链接等)。" +
|
||||
"这是只读操作,不会修改任何听记;若你名下没有任何听记则提示「暂无妙记」。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: minutesSmartContract(
|
||||
"+latest",
|
||||
"取我最新的一条妙记(听记)详情",
|
||||
"需要按真实创建/开始时间选出当前用户最新一条听记并直接读取基础详情时使用;可用 --keyword 缩小候选集。",
|
||||
[]string{"已知 taskUuid 时直接使用基础信息原子命令;需要搜索多条结果时使用 +search"},
|
||||
[]string{"dws minutes +latest", "dws minutes +latest --keyword 周会"},
|
||||
[]string{"+latest-minutes"},
|
||||
),
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "keyword", Type: shortcut.FlagString, Desc: "按关键字过滤听记(可选)", Required: false},
|
||||
},
|
||||
Tips: []string{
|
||||
`dws minutes +latest-minutes`,
|
||||
`dws minutes +latest-minutes --keyword 周会`,
|
||||
`dws minutes +latest`,
|
||||
`dws minutes +latest --keyword 周会`,
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
// Step 1 — list my minutes (newest first). belongingConditionId /
|
||||
@@ -63,103 +76,42 @@ var LatestMinutes = shortcut.Shortcut{
|
||||
}
|
||||
|
||||
// Step 2 — locate the newest minute's taskUuid.
|
||||
taskUUID := latestMinutesTaskUUID(data)
|
||||
taskUUID, err := latestMinutesTaskUUID(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if taskUUID == "" {
|
||||
return apperrors.NewValidation("暂无妙记")
|
||||
}
|
||||
|
||||
// Step 3 — print its basic info (taskUuid param mirrors helper).
|
||||
return rt.CallMCP("get_minutes_basic_info", map[string]any{
|
||||
// Step 3 — validate and print its basic info. A successful-looking empty
|
||||
// result must not become a Shortcut success.
|
||||
basicData, err := rt.CallMCPData("minutes", "get_minutes_basic_info", map[string]any{
|
||||
"taskUuid": taskUUID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
basic, err := minutesdata.Basic(taskUUID, basicData)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(basic)
|
||||
},
|
||||
}
|
||||
|
||||
// latestMinutesItems walks a list_by_keyword_and_time_range response and returns
|
||||
// its minutes entries. The gateway wraps the list under one of several common
|
||||
// container keys, so we probe them defensively before scanning for a bare list.
|
||||
func latestMinutesItems(data map[string]any) []map[string]any {
|
||||
for _, key := range []string{"result", "list", "minutesList", "items", "data", "records"} {
|
||||
if arr, ok := data[key].([]any); ok {
|
||||
return latestMinutesToMaps(arr)
|
||||
}
|
||||
// Some responses nest the list one level deeper, e.g. {"data": {"list": [...]}}.
|
||||
if inner, ok := data[key].(map[string]any); ok {
|
||||
for _, k2 := range []string{"list", "minutesList", "items", "records", "result"} {
|
||||
if arr, ok := inner[k2].([]any); ok {
|
||||
return latestMinutesToMaps(arr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func latestMinutesToMaps(arr []any) []map[string]any {
|
||||
out := make([]map[string]any, 0, len(arr))
|
||||
for _, it := range arr {
|
||||
if m, ok := it.(map[string]any); ok {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// latestMinutesTaskUUID picks the newest minute's taskUuid: the item with the
|
||||
// largest numeric create time, falling back to the first item that carries a
|
||||
// taskUuid (lists come back newest-first).
|
||||
func latestMinutesTaskUUID(data map[string]any) string {
|
||||
items := latestMinutesItems(data)
|
||||
if len(items) == 0 {
|
||||
return ""
|
||||
// largest explicit create/start time. Unknown list shapes and rows without a
|
||||
// comparable timestamp fail closed instead of becoming an empty or arbitrary
|
||||
// "latest" success.
|
||||
func latestMinutesTaskUUID(data map[string]any) (string, error) {
|
||||
page, err := minutesdata.ParseListPage(data)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
best := ""
|
||||
var bestTime float64
|
||||
haveTime := false
|
||||
firstUUID := ""
|
||||
|
||||
for _, m := range items {
|
||||
uuid := latestMinutesUUID(m)
|
||||
if uuid == "" {
|
||||
continue
|
||||
}
|
||||
if firstUUID == "" {
|
||||
firstUUID = uuid
|
||||
}
|
||||
if t, ok := latestMinutesCreateTime(m); ok {
|
||||
if !haveTime || t > bestTime {
|
||||
haveTime = true
|
||||
bestTime = t
|
||||
best = uuid
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if haveTime && best != "" {
|
||||
return best
|
||||
}
|
||||
return firstUUID
|
||||
}
|
||||
|
||||
func latestMinutesUUID(m map[string]any) string {
|
||||
for _, key := range []string{"taskUuid", "taskUUID", "uuid", "id"} {
|
||||
if s, ok := m[key].(string); ok && s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func latestMinutesCreateTime(m map[string]any) (float64, bool) {
|
||||
for _, key := range []string{"createTime", "gmtCreate", "startTime", "createTimeStart"} {
|
||||
if v, ok := m[key].(float64); ok {
|
||||
return v, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
return minutesdata.LatestTaskUUID(page)
|
||||
}
|
||||
|
||||
func init() {
|
||||
shortcut.Register(LatestMinutes)
|
||||
shortcut.Register(finalizeMinutesSmartShortcut(LatestMinutes))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,408 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package smart
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/localio"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type smartMinutesFailWriter struct{}
|
||||
|
||||
func (smartMinutesFailWriter) Write([]byte) (int, error) {
|
||||
return 0, errors.New("fixture output failure")
|
||||
}
|
||||
|
||||
func runMinutesCLIWithWriter(t *testing.T, caller *smartCoverageCaller, writer io.Writer, args ...string) error {
|
||||
t.Helper()
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetOut(writer)
|
||||
root.SetErr(&bytes.Buffer{})
|
||||
root.SetArgs(args)
|
||||
return root.Execute()
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesSmartContractFinalizer(t *testing.T) {
|
||||
value := shortcut.Shortcut{
|
||||
Description: "desc", Intent: "intent",
|
||||
Contract: corecmd.ContractDecl{Parameters: []contract.ParamDecl{
|
||||
{Name: "a", Description: "parameter"},
|
||||
{Name: "a", Description: "evidence present"},
|
||||
{Name: "other", Description: "other"},
|
||||
}},
|
||||
Flags: []shortcut.Flag{{Name: "a", Desc: "flag"}},
|
||||
Constraints: []shortcut.Constraint{
|
||||
{Kind: shortcut.ConstraintAtLeastOne, Flags: []string{"a"}},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"a"}},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"a"}, Description: "evidence"},
|
||||
},
|
||||
}
|
||||
got := finalizeMinutesSmartShortcut(value)
|
||||
if got.Contract.Selection.AgentSummary != "desc" || got.Contract.Selection.UseWhen[0] != "intent" || !strings.Contains(got.Flags[0].Desc, "evidence") || !strings.Contains(got.Contract.Parameters[0].Description, "evidence") || got.Contract.Parameters[1].Description != "evidence present" || got.Contract.Parameters[2].Description != "other" {
|
||||
t.Fatalf("finalized=%#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesLatestAndActionItemsBranchesE2E(t *testing.T) {
|
||||
listKey := "minutes/list_by_keyword_and_time_range"
|
||||
basicKey := "minutes/get_minutes_basic_info"
|
||||
todoKey := "minutes/list_minutes_todos"
|
||||
latestCases := []struct {
|
||||
name string
|
||||
responses map[string][]string
|
||||
failAt map[string]int
|
||||
wantError bool
|
||||
}{
|
||||
{name: "list call", failAt: map[string]int{listKey: 1}, wantError: true},
|
||||
{name: "list parse", responses: map[string][]string{listKey: {`{"success":true,"result":{}}`}}, wantError: true},
|
||||
{name: "empty", responses: map[string][]string{listKey: {`{"success":true,"result":{"itemList":[]}}`}}, wantError: true},
|
||||
{name: "basic call", responses: map[string][]string{listKey: {`{"success":true,"result":{"itemList":[{"uuid":"u1","startTime":1}]}}`}}, failAt: map[string]int{basicKey: 1}, wantError: true},
|
||||
{name: "basic parse", responses: map[string][]string{listKey: {`{"success":true,"result":{"itemList":[{"uuid":"u1","startTime":1}]}}`}, basicKey: {`{"success":true,"result":{}}`}}, wantError: true},
|
||||
{name: "success", responses: map[string][]string{listKey: {`{"success":true,"result":{"itemList":[{"uuid":"u1","startTime":1}]}}`}, basicKey: {`{"success":true,"result":{"taskUuid":"u1","title":"ok"}}`}}},
|
||||
}
|
||||
for _, test := range latestCases {
|
||||
t.Run("latest "+test.name, func(t *testing.T) {
|
||||
payload, _, err := runMinutesCLI(t, &smartCoverageCaller{responses: test.responses, failAt: test.failAt}, "minutes", "+latest", "--keyword", "weekly")
|
||||
if (err != nil) != test.wantError {
|
||||
t.Fatalf("payload=%#v err=%v", payload, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
latestOutput := &smartCoverageCaller{responses: map[string][]string{listKey: {`{"success":true,"result":{"itemList":[{"uuid":"u1","startTime":1}]}}`}, basicKey: {`{"success":true,"result":{"taskUuid":"u1"}}`}}}
|
||||
if err := runMinutesCLIWithWriter(t, latestOutput, smartMinutesFailWriter{}, "minutes", "+latest"); err == nil {
|
||||
t.Fatal("latest output failure accepted")
|
||||
}
|
||||
if _, err := latestMinutesTaskUUID(map[string]any{}); err == nil {
|
||||
t.Fatal("latest malformed data accepted")
|
||||
}
|
||||
|
||||
actionCases := []struct {
|
||||
name string
|
||||
responses map[string][]string
|
||||
failAt map[string]int
|
||||
args []string
|
||||
wantError bool
|
||||
}{
|
||||
{name: "list call", failAt: map[string]int{listKey: 1}, wantError: true},
|
||||
{name: "list parse", responses: map[string][]string{listKey: {`{"success":true,"result":{}}`}}, wantError: true},
|
||||
{name: "empty", responses: map[string][]string{listKey: {`{"success":true,"result":{"itemList":[]}}`}}, wantError: true},
|
||||
{name: "todo call", failAt: map[string]int{todoKey: 1}, args: []string{"--id", "u1"}, wantError: true},
|
||||
{name: "todo parse", responses: map[string][]string{todoKey: {`{"success":true,"result":{}}`}}, args: []string{"--id", "u1"}, wantError: true},
|
||||
{name: "success id", responses: map[string][]string{todoKey: {`{"success":true,"result":{"actions":[]}}`}}, args: []string{"--id", "u1"}},
|
||||
{name: "success latest", responses: map[string][]string{listKey: {`{"success":true,"result":{"itemList":[{"uuid":"u1","startTime":1}]}}`}, todoKey: {`{"success":true,"result":{"actions":[]}}`}}},
|
||||
}
|
||||
for _, test := range actionCases {
|
||||
t.Run("actions "+test.name, func(t *testing.T) {
|
||||
args := []string{"minutes", "+action-items"}
|
||||
args = append(args, test.args...)
|
||||
payload, _, err := runMinutesCLI(t, &smartCoverageCaller{responses: test.responses, failAt: test.failAt}, args...)
|
||||
if (err != nil) != test.wantError {
|
||||
t.Fatalf("payload=%#v err=%v", payload, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
actionOutput := &smartCoverageCaller{responses: map[string][]string{todoKey: {`{"success":true,"result":{"actions":[]}}`}}}
|
||||
if err := runMinutesCLIWithWriter(t, actionOutput, smartMinutesFailWriter{}, "minutes", "+action-items", "--id", "u1"); err == nil {
|
||||
t.Fatal("actions output failure accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesSearchAndTranscriptBranchesE2E(t *testing.T) {
|
||||
listKey := "minutes/list_by_keyword_and_time_range"
|
||||
transcriptKey := "minutes/get_minutes_transcription"
|
||||
searchCases := []struct {
|
||||
name string
|
||||
responses map[string][]string
|
||||
failAt map[string]int
|
||||
}{
|
||||
{name: "call", failAt: map[string]int{listKey: 1}},
|
||||
{name: "project", responses: map[string][]string{listKey: {`{"success":true,"result":{"itemList":[{"title":"missing id"}]}}`}}},
|
||||
}
|
||||
for _, test := range searchCases {
|
||||
if _, _, err := runMinutesCLI(t, &smartCoverageCaller{responses: test.responses, failAt: test.failAt}, "minutes", "+minutes-search", "--query", "x"); err == nil {
|
||||
t.Fatalf("search %s failure accepted", test.name)
|
||||
}
|
||||
}
|
||||
searchOutput := &smartCoverageCaller{responses: map[string][]string{listKey: {`{"success":true,"result":{"itemList":[]}}`}}}
|
||||
if err := runMinutesCLIWithWriter(t, searchOutput, smartMinutesFailWriter{}, "minutes", "+minutes-search", "--query", "x"); err == nil {
|
||||
t.Fatal("search output failure accepted")
|
||||
}
|
||||
|
||||
if _, _, err := runMinutesCLI(t, &smartCoverageCaller{}, "minutes", "+transcript", "--id", "u1", "--page-limit", "0"); err == nil {
|
||||
t.Fatal("transcript page limit accepted")
|
||||
}
|
||||
transcriptCases := []struct {
|
||||
name string
|
||||
responses map[string][]string
|
||||
failAt map[string]int
|
||||
wantError bool
|
||||
}{
|
||||
{name: "list call", failAt: map[string]int{listKey: 1}, wantError: true},
|
||||
{name: "list parse", responses: map[string][]string{listKey: {`{"success":true,"result":{}}`}}, wantError: true},
|
||||
{name: "empty", responses: map[string][]string{listKey: {`{"success":true,"result":{"itemList":[]}}`}}, wantError: true},
|
||||
{name: "success", responses: map[string][]string{listKey: {`{"success":true,"result":{"itemList":[{"uuid":"u1","startTime":1}]}}`}, transcriptKey: {`{"success":true,"result":{"paragraphList":[{"paragraphId":"p1"}],"hasNext":false}}`}}},
|
||||
{name: "partial", responses: map[string][]string{listKey: {`{"success":true,"result":{"itemList":[{"uuid":"u1","startTime":1}]}}`}, transcriptKey: {`{"success":true,"result":{"paragraphList":[{"paragraphId":"p1"}],"hasNext":true,"nextToken":"n2"}}`}}, failAt: map[string]int{transcriptKey: 2}, wantError: true},
|
||||
}
|
||||
for _, test := range transcriptCases {
|
||||
t.Run("transcript "+test.name, func(t *testing.T) {
|
||||
payload, output, err := runMinutesCLI(t, &smartCoverageCaller{responses: test.responses, failAt: test.failAt}, "minutes", "+transcript", "--keyword", "weekly", "--direction", "1")
|
||||
if (err != nil) != test.wantError {
|
||||
t.Fatalf("payload=%#v output=%q err=%v", payload, output, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
transcriptOutput := &smartCoverageCaller{responses: map[string][]string{transcriptKey: {`{"success":true,"result":{"paragraphList":[],"hasNext":false}}`}}}
|
||||
if err := runMinutesCLIWithWriter(t, transcriptOutput, smartMinutesFailWriter{}, "minutes", "+transcript", "--id", "u1"); err == nil {
|
||||
t.Fatal("transcript output failure accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesDetailRemainingBranchesE2E(t *testing.T) {
|
||||
invalid := [][]string{
|
||||
{"minutes", "+detail", "--id", "u1", "--page-limit", "0"},
|
||||
{"minutes", "+detail", "--id", "u1", "--ids", "u2"},
|
||||
{"minutes", "+detail"},
|
||||
{"minutes", "+detail", "--id", "u1", "--transcript-output", "file", "--output-dir", "../escape"},
|
||||
}
|
||||
for _, args := range invalid {
|
||||
if payload, output, err := runMinutesCLI(t, &smartCoverageCaller{}, args...); err == nil || payload != nil || output != "" {
|
||||
t.Fatalf("detail invalid accepted %v payload=%#v output=%q err=%v", args, payload, output, err)
|
||||
}
|
||||
}
|
||||
all := &smartCoverageCaller{responses: map[string][]string{
|
||||
"minutes/get_minutes_basic_info": {`{"success":true,"result":{"taskUuid":"u1"}}`},
|
||||
"minutes/get_minutes_ai_summary": {`{"success":true,"result":{"fullSummary":"summary"}}`},
|
||||
"minutes/get_minutes_keywords": {`{"success":true,"result":{"keywords":[]}}`},
|
||||
"minutes/get_minutes_transcription": {`{"success":true,"result":{"paragraphList":[{"paragraphId":"p1"}],"hasNext":false}}`},
|
||||
"minutes/list_minutes_todos": {`{"success":true,"result":{"actions":[]}}`},
|
||||
}}
|
||||
payload, _, err := runMinutesCLI(t, all, "minutes", "+detail", "--id", "u1")
|
||||
if err != nil || payload["complete"] != true {
|
||||
t.Fatalf("detail all payload=%#v err=%v", payload, err)
|
||||
}
|
||||
if err := runMinutesCLIWithWriter(t, all, smartMinutesFailWriter{}, "minutes", "+detail", "--id", "u1", "--artifacts", "basic"); err == nil {
|
||||
t.Fatal("detail single output failure accepted")
|
||||
}
|
||||
batchOutput := &smartCoverageCaller{responses: map[string][]string{"minutes/get_minutes_basic_info": {`{"success":true,"result":{"taskUuid":"u1"}}`, `{"success":true,"result":{"taskUuid":"u2"}}`}}}
|
||||
if err := runMinutesCLIWithWriter(t, batchOutput, smartMinutesFailWriter{}, "minutes", "+detail", "--ids", "u1,u2", "--artifacts", "basic"); err == nil {
|
||||
t.Fatal("detail batch output failure accepted")
|
||||
}
|
||||
|
||||
for _, artifact := range []string{"basic", "summary", "keywords", "todos"} {
|
||||
tool := minutesArtifactTools[artifact]
|
||||
caller := &smartCoverageCaller{failAt: map[string]int{"minutes/" + tool: 1}}
|
||||
payload, output, err := runMinutesCLI(t, caller, "minutes", "+detail", "--id", "u1", "--artifacts", artifact)
|
||||
if err == nil || output == "" || payload["complete"] != false {
|
||||
t.Fatalf("detail call %s payload=%#v err=%v", artifact, payload, err)
|
||||
}
|
||||
caller = &smartCoverageCaller{responses: map[string][]string{"minutes/" + tool: {`{"success":true,"result":{}}`}}}
|
||||
payload, output, err = runMinutesCLI(t, caller, "minutes", "+detail", "--id", "u1", "--artifacts", artifact)
|
||||
if err == nil || output == "" || payload["complete"] != false {
|
||||
t.Fatalf("detail parse %s payload=%#v err=%v", artifact, payload, err)
|
||||
}
|
||||
}
|
||||
|
||||
transcript := &smartCoverageCaller{responses: map[string][]string{"minutes/get_minutes_transcription": {`{"success":true,"result":{"paragraphList":[{"paragraphId":"p1"}],"hasNext":false}}`}}}
|
||||
testseam.Swap(t, &smartMinutesMarshalIndent, func(any, string, string) ([]byte, error) { return nil, errors.New("marshal") })
|
||||
payload, output, err := runMinutesCLI(t, transcript, "minutes", "+detail", "--id", "u1", "--artifacts", "transcript", "--transcript-output", "file", "--output-dir", "out")
|
||||
if err == nil || output == "" || payload["complete"] != false {
|
||||
t.Fatalf("detail marshal payload=%#v err=%v", payload, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesDetailPublishFailure(t *testing.T) {
|
||||
testseam.Swap(t, &smartMinutesPublishBytes, func([]byte, localio.PublishBytesOptions) (localio.DownloadResult, error) {
|
||||
return localio.DownloadResult{}, errors.New("publish")
|
||||
})
|
||||
caller := &smartCoverageCaller{responses: map[string][]string{"minutes/get_minutes_transcription": {`{"success":true,"result":{"paragraphList":[{"paragraphId":"p1"}],"hasNext":false}}`}}}
|
||||
payload, output, err := runMinutesCLI(t, caller, "minutes", "+detail", "--id", "u1", "--artifacts", "transcript", "--transcript-output", "both", "--output-dir", "out")
|
||||
if err == nil || output == "" || payload["complete"] != false {
|
||||
t.Fatalf("detail publish payload=%#v err=%v", payload, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesDetailFileSuccess(t *testing.T) {
|
||||
old, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
work := t.TempDir()
|
||||
if err := os.Chdir(work); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(old) })
|
||||
caller := &smartCoverageCaller{responses: map[string][]string{"minutes/get_minutes_transcription": {`{"success":true,"result":{"paragraphList":[{"paragraphId":"p1"}],"hasNext":false}}`}}}
|
||||
payload, _, err := runMinutesCLI(t, caller, "minutes", "+detail", "--id", "u1", "--artifacts", "transcript", "--transcript-output", "file", "--output-dir", "out")
|
||||
if err != nil || payload["complete"] != true {
|
||||
t.Fatalf("detail file payload=%#v err=%v", payload, err)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(work, "out", "u1.json")); statErr != nil {
|
||||
t.Fatal(statErr)
|
||||
}
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
cmd := &cobra.Command{Use: "detail"}
|
||||
cmd.Flags().String("cursor", "", "")
|
||||
cmd.Flags().Bool("single-page", false, "")
|
||||
cmd.Flags().Int("page-limit", 100, "")
|
||||
cmd.Flags().String("transcript-output", "inline", "")
|
||||
rt := shortcut.RuntimeContextForTest(cmd, MinutesDetail)
|
||||
if bundle, failures := readMinutesDetail(rt, "u1", []string{"unknown"}, "0"); len(failures) != 0 || bundle["complete"] != true {
|
||||
t.Fatalf("unknown detail bundle=%#v failures=%#v", bundle, failures)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesReplaceParsing(t *testing.T) {
|
||||
for _, raw := range [][]string{{"missing"}, {" =>x"}, {"a=>x", "a=>y"}} {
|
||||
if pairs, err := parseReplacePairs(raw); err == nil || pairs != nil {
|
||||
t.Fatalf("invalid pairs accepted: %#v pairs=%#v err=%v", raw, pairs, err)
|
||||
}
|
||||
}
|
||||
if pairs, err := parseReplacePairs([]string{" a => b "}); err != nil || len(pairs) != 1 || pairs[0].orig != "a" || pairs[0].repl != "b" {
|
||||
t.Fatalf("pairs=%#v err=%v", pairs, err)
|
||||
}
|
||||
|
||||
for _, raw := range []string{"not-json", `[]`, `[{"replacement":"x"}]`} {
|
||||
if pairs, err := parseReplaceJSON(raw); err == nil || pairs != nil {
|
||||
t.Fatalf("invalid JSON accepted: %q pairs=%#v err=%v", raw, pairs, err)
|
||||
}
|
||||
}
|
||||
if pairs, err := parseReplaceJSON(`{"replacements":[{"originalText":" a ","replacedText":" b "}]}`); err != nil || len(pairs) != 1 || pairs[0] != (replacePair{orig: "a", repl: "b"}) {
|
||||
t.Fatalf("envelope pairs=%#v err=%v", pairs, err)
|
||||
}
|
||||
if pairs, err := parseReplaceJSON(`[{"source":"a","replacement":"b"}]`); err != nil || len(pairs) != 1 {
|
||||
t.Fatalf("direct pairs=%#v err=%v", pairs, err)
|
||||
}
|
||||
|
||||
for _, pairs := range [][]replacePair{nil, {{orig: " "}}, {{orig: "a"}, {orig: "a"}}} {
|
||||
if got, err := validateReplacePairs(pairs); err == nil || got != nil {
|
||||
t.Fatalf("invalid validated: %#v got=%#v err=%v", pairs, got, err)
|
||||
}
|
||||
}
|
||||
valid := []replacePair{{orig: "a", repl: "b"}}
|
||||
if got, err := validateReplacePairs(valid); err != nil || len(got) != 1 {
|
||||
t.Fatalf("valid=%#v err=%v", got, err)
|
||||
}
|
||||
|
||||
for _, test := range []struct {
|
||||
before, after string
|
||||
pair replacePair
|
||||
beforeSource int
|
||||
beforeTarget int
|
||||
want bool
|
||||
}{
|
||||
{before: "same", after: "same", pair: replacePair{orig: "a", repl: "b"}},
|
||||
{before: "a", after: "", pair: replacePair{orig: "a"}, beforeSource: 1, want: true},
|
||||
{before: "a", after: "a", pair: replacePair{orig: "a"}, beforeSource: 1},
|
||||
{before: "a", after: "aa", pair: replacePair{orig: "a", repl: "aa"}, beforeSource: 1, beforeTarget: 0, want: true},
|
||||
{before: "a", after: "b", pair: replacePair{orig: "a", repl: "b"}, beforeSource: 1, beforeTarget: 0, want: true},
|
||||
{before: "a", after: "c", pair: replacePair{orig: "a", repl: "b"}, beforeSource: 1, beforeTarget: 0},
|
||||
} {
|
||||
if got := replaceReadbackVerified(test.before, test.after, test.pair, test.beforeSource, test.beforeTarget); got != test.want {
|
||||
t.Fatalf("verified(%#v)=%v", test, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesReplaceLoadAndTranscript(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "replace"}
|
||||
cmd.Flags().StringSlice("pair", []string{"bad"}, "")
|
||||
cmd.Flags().String("json", "", "")
|
||||
if pairs, err := loadReplacePairs(shortcut.RuntimeContextForTest(cmd, ReplaceBatch)); err == nil || pairs != nil {
|
||||
t.Fatalf("direct malformed pair accepted pairs=%#v err=%v", pairs, err)
|
||||
}
|
||||
if _, _, err := runMinutesCLI(t, &smartCoverageCaller{}, "minutes", "+replace-batch", "--id", "u1", "--pair", "a=>b", "--page-limit", "0"); err == nil {
|
||||
t.Fatal("replace page limit accepted")
|
||||
}
|
||||
if _, _, err := runMinutesCLI(t, &smartCoverageCaller{}, "minutes", "+replace-batch", "--id", "u1", "--json", "@missing", "--dry-run"); err == nil {
|
||||
t.Fatal("missing JSON file accepted")
|
||||
}
|
||||
if _, _, err := runMinutesCLI(t, &smartCoverageCaller{}, "minutes", "+replace-batch", "--id", "u1", "--json", "bad", "--dry-run"); err == nil {
|
||||
t.Fatal("malformed JSON accepted")
|
||||
}
|
||||
if _, _, err := runMinutesCLI(t, &smartCoverageCaller{}, "minutes", "+replace-batch", "--id", "u1", "--pair", "a=>b", "--json", `[{"source":"a","replacement":"c"}]`, "--dry-run"); err == nil {
|
||||
t.Fatal("cross-input duplicate accepted")
|
||||
}
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
responses []string
|
||||
failAt int
|
||||
}{
|
||||
{name: "call", failAt: 1},
|
||||
{name: "empty", responses: []string{`{"success":true,"result":{"paragraphList":[],"hasNext":false}}`}},
|
||||
{name: "no text", responses: []string{`{"success":true,"result":{"paragraphList":[{"paragraphId":"p1"}],"hasNext":false}}`}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
caller := &smartCoverageCaller{responses: map[string][]string{"minutes/get_minutes_transcription": test.responses}, failAt: map[string]int{"minutes/get_minutes_transcription": test.failAt}}
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
cmd := &cobra.Command{Use: "replace"}
|
||||
rt := shortcut.RuntimeContextForTest(cmd, ReplaceBatch)
|
||||
if text, err := minutesReplaceTranscriptText(rt, "u1", 1); err == nil || text != "" {
|
||||
t.Fatalf("text=%q err=%v", text, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
nested := &smartCoverageCaller{responses: map[string][]string{"minutes/get_minutes_transcription": {`{"success":true,"result":{"paragraphList":[{"paragraph":"one","sentences":[{"text":"two"}]}],"hasNext":false}}`}}}
|
||||
helpers.InitDepsForTest(t, nested)
|
||||
if text, err := minutesReplaceTranscriptText(shortcut.RuntimeContextForTest(&cobra.Command{Use: "replace"}, ReplaceBatch), "u1", 1); err != nil || !strings.Contains(text, "one") || !strings.Contains(text, "two") {
|
||||
t.Fatalf("nested text=%q err=%v", text, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesReplaceExecutionBranchesE2E(t *testing.T) {
|
||||
transcriptKey := "minutes/get_minutes_transcription"
|
||||
writeKey := "minutes/replace_minutes_text"
|
||||
preRead := &smartCoverageCaller{failAt: map[string]int{transcriptKey: 1}}
|
||||
if _, _, err := runMinutesCLI(t, preRead, "minutes", "+replace-batch", "--id", "u1", "--pair", "a=>A", "--yes"); err == nil {
|
||||
t.Fatal("replace pre-read failure accepted")
|
||||
}
|
||||
missing := &smartCoverageCaller{responses: map[string][]string{transcriptKey: {`{"success":true,"result":{"paragraphList":[{"paragraph":"other"}],"hasNext":false}}`}}}
|
||||
if _, _, err := runMinutesCLI(t, missing, "minutes", "+replace-batch", "--id", "u1", "--pair", "a=>A", "--yes"); err == nil || missing.counts[writeKey] != 0 {
|
||||
t.Fatal("missing source write accepted")
|
||||
}
|
||||
readback := &smartCoverageCaller{
|
||||
responses: map[string][]string{
|
||||
transcriptKey: {`{"success":true,"result":{"paragraphList":[{"paragraph":"a"}],"hasNext":false}}`},
|
||||
writeKey: {`{"success":true,"result":{}}`},
|
||||
},
|
||||
failAt: map[string]int{transcriptKey: 2},
|
||||
}
|
||||
payload, output, err := runMinutesCLI(t, readback, "minutes", "+replace-batch", "--id", "u1", "--pair", "a=>A", "--yes")
|
||||
if err == nil || output == "" || payload["failed"] != float64(1) {
|
||||
t.Fatalf("readback payload=%#v err=%v", payload, err)
|
||||
}
|
||||
success := func() *smartCoverageCaller {
|
||||
return &smartCoverageCaller{responses: map[string][]string{
|
||||
transcriptKey: {
|
||||
`{"success":true,"result":{"paragraphList":[{"paragraph":"a"}],"hasNext":false}}`,
|
||||
`{"success":true,"result":{"paragraphList":[{"paragraph":"A"}],"hasNext":false}}`,
|
||||
},
|
||||
writeKey: {`{"success":true,"result":{}}`},
|
||||
}}
|
||||
}
|
||||
payload, output, err = runMinutesCLI(t, success(), "minutes", "+replace-batch", "--id", "u1", "--pair", "a=>A", "--yes")
|
||||
if err != nil || output == "" || payload["ok"] != true || payload["applied"] != float64(1) {
|
||||
t.Fatalf("success payload=%#v err=%v", payload, err)
|
||||
}
|
||||
if err := runMinutesCLIWithWriter(t, success(), smartMinutesFailWriter{}, "minutes", "+replace-batch", "--id", "u1", "--pair", "a=>A", "--yes"); err == nil {
|
||||
t.Fatal("replace output failure accepted")
|
||||
}
|
||||
if err := runMinutesCLIWithWriter(t, &smartCoverageCaller{}, smartMinutesFailWriter{}, "minutes", "+replace-batch", "--id", "u1", "--pair", "a=>A", "--dry-run"); err == nil {
|
||||
t.Fatal("replace dry-run output failure accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package smart
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
func minutesSmartContract(command, description, useWhen string, avoidWhen, examples, aliases []string) corecmd.ContractDecl {
|
||||
name := "shortcut_" + strings.ReplaceAll(strings.TrimPrefix(command, "+"), "-", "_")
|
||||
identityAliases := make([]string, 0, len(aliases))
|
||||
for _, alias := range aliases {
|
||||
identityAliases = append(identityAliases, "minutes "+alias)
|
||||
}
|
||||
return corecmd.ContractDecl{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "minutes",
|
||||
Name: name,
|
||||
CanonicalPath: "minutes." + name,
|
||||
CLIPath: "minutes " + command,
|
||||
PrimaryCLIPath: "minutes " + command,
|
||||
Aliases: identityAliases,
|
||||
},
|
||||
Description: description,
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: contract.InterfaceModeComposite,
|
||||
Availability: contract.InterfaceAvailable,
|
||||
Reason: "The executable Shortcut owns strict Minutes response validation, orchestration, completeness and final output; no single RPC represents the final command contract.",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: description,
|
||||
UseWhen: []string{useWhen},
|
||||
AvoidWhen: avoidWhen,
|
||||
Examples: examples,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// finalizeMinutesSmartShortcut keeps Minutes shortcuts that live in the smart
|
||||
// package under the same declare=delivery contract as the Minutes package.
|
||||
func finalizeMinutesSmartShortcut(value shortcut.Shortcut) shortcut.Shortcut {
|
||||
value.Contract.Selection.AgentSummary = value.Description
|
||||
value.Contract.Selection.UseWhen = []string{value.Intent}
|
||||
for _, constraint := range value.Constraints {
|
||||
if constraint.Kind != shortcut.ConstraintCustom {
|
||||
continue
|
||||
}
|
||||
evidence := strings.TrimSpace(constraint.Description)
|
||||
if evidence == "" {
|
||||
continue
|
||||
}
|
||||
for flagIndex := range value.Flags {
|
||||
flag := &value.Flags[flagIndex]
|
||||
if !smartMinutesContains(constraint.Flags, flag.Name) || strings.Contains(flag.Desc, evidence) {
|
||||
continue
|
||||
}
|
||||
flag.Desc = strings.TrimRight(flag.Desc, ";。 ") + ";约束:" + evidence
|
||||
}
|
||||
for parameterIndex := range value.Contract.Parameters {
|
||||
parameter := &value.Contract.Parameters[parameterIndex]
|
||||
if !smartMinutesContains(constraint.Flags, parameter.Name) || strings.Contains(parameter.Description, evidence) {
|
||||
continue
|
||||
}
|
||||
parameter.Description = strings.TrimRight(parameter.Description, ";。 ") + ";约束:" + evidence
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func smartMinutesContains(values []string, target string) bool {
|
||||
for _, value := range values {
|
||||
if value == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -14,12 +14,22 @@
|
||||
package smart
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/localio"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/minutesdata"
|
||||
)
|
||||
|
||||
var (
|
||||
smartMinutesMarshalIndent = json.MarshalIndent
|
||||
smartMinutesPublishBytes = localio.PublishBytes
|
||||
)
|
||||
|
||||
// MinutesDetail: fetch several artifacts of ONE minute (听记) in a single command
|
||||
@@ -44,10 +54,10 @@ var MinutesDetail = shortcut.Shortcut{
|
||||
Service: "minutes",
|
||||
Command: "+detail",
|
||||
Product: "minutes",
|
||||
Description: "一条命令聚合取一条妙记(听记)的多项产物(基础信息/摘要/关键词/逐字稿/待办)",
|
||||
Intent: "当你已经有某条听记的 taskUuid,想在一次操作里同时拿到它的基础信息、AI 摘要、关键词、逐字稿和待办,而不想分别敲 4~5 个子命令再自己拼时使用;" +
|
||||
"内部按 --artifacts 选择要拉的产物(默认全部:basic/summary/keywords/transcript/todos),逐个调用对应的原子工具并聚合成一个结果," +
|
||||
"某一项失败不会中断整体(会以错误字符串记录在该项下)。这是纯只读操作,不会修改听记;--direction 仅影响逐字稿排序(0=正序默认,1=倒序)。",
|
||||
Description: "批量聚合听记基础信息、摘要、关键词、完整逐字稿和行动项,支持安全文件输出",
|
||||
Intent: "当你已有一个或最多 50 个 taskUuid,要一次读取所选 basic/summary/keywords/transcript/todos 产物时使用;" +
|
||||
"逐字稿默认追完所有分页并可用 file/both 安全落盘,批量结果带 complete 与逐项 failure ledger。" +
|
||||
"任一所选产物失败都会保留已取得结果并返回非零,绝不会把 partial bundle 当成完整成功;这是纯只读操作。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
@@ -61,7 +71,7 @@ var MinutesDetail = shortcut.Shortcut{
|
||||
CLIPath: "minutes +detail",
|
||||
PrimaryCLIPath: "minutes +detail",
|
||||
},
|
||||
Description: "一条命令聚合取一条妙记(听记)的多项产物(基础信息/摘要/关键词/逐字稿/待办)",
|
||||
Description: "批量聚合听记基础信息、摘要、关键词、完整逐字稿和行动项,支持安全文件输出",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
@@ -73,22 +83,49 @@ var MinutesDetail = shortcut.Shortcut{
|
||||
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
|
||||
Examples: []string{
|
||||
"dws minutes +detail --id <taskUuid>",
|
||||
"dws minutes +detail --id <taskUuid> --artifacts summary,todos",
|
||||
"dws minutes +detail --ids <uuid1,uuid2> --artifacts summary,transcript --transcript-output file",
|
||||
},
|
||||
},
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "id", Type: shortcut.FlagString, Desc: "听记 taskUuid(必填)", Required: true},
|
||||
{Name: "id", Type: shortcut.FlagString, Desc: "单个听记 taskUuid"},
|
||||
{Name: "ids", Type: shortcut.FlagStringSlice, Desc: "多个听记 taskUuid,最多 50 个"},
|
||||
{Name: "artifacts", Type: shortcut.FlagStringSlice, Desc: "要拉取的产物子集(默认全部)", Required: false, Enum: []string{"basic", "summary", "keywords", "transcript", "todos"}},
|
||||
{Name: "direction", Type: shortcut.FlagString, Desc: "逐字稿排序: 0=正序(默认), 1=倒序(可选)", Required: false, Enum: []string{"0", "1"}},
|
||||
{Name: "cursor", Type: shortcut.FlagString, Desc: "逐字稿单页/续拉的起始 nextToken"},
|
||||
{Name: "single-page", Type: shortcut.FlagBool, Desc: "逐字稿只读取一页并返回 nextToken"},
|
||||
{Name: "page-limit", Type: shortcut.FlagInt, Default: "100", Desc: "逐字稿自动翻页安全上限"},
|
||||
{Name: "transcript-output", Type: shortcut.FlagString, Default: "inline", Desc: "逐字稿输出方式", Enum: []string{"inline", "file", "both"}},
|
||||
{Name: "output-dir", Type: shortcut.FlagString, Default: "minutes-transcripts", Desc: "file/both 模式下的安全相对输出目录"},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"id", "ids"}, Description: "--id 与 --ids 只能选择一种,去重后 taskUuid 必须为 1..50 个"},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-limit"}, Description: "--page-limit 必须大于 0"},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"transcript-output", "output-dir"}, Description: "file/both 输出目录必须是安全相对路径"},
|
||||
},
|
||||
Tips: []string{
|
||||
`dws minutes +detail --id <taskUuid>`,
|
||||
`dws minutes +detail --id <taskUuid> --artifacts summary,todos`,
|
||||
`dws minutes +detail --id <taskUuid> --direction 1`,
|
||||
`dws minutes +detail --ids <uuid1,uuid2> --artifacts summary,transcript --transcript-output file`,
|
||||
`dws minutes +detail --id <taskUuid> --direction 1 --transcript-output both`,
|
||||
},
|
||||
Validate: func(rt *shortcut.RuntimeContext) error {
|
||||
if rt.Int("page-limit") <= 0 {
|
||||
return apperrors.NewValidation("--page-limit 必须大于 0")
|
||||
}
|
||||
if strings.TrimSpace(rt.Str("id")) != "" && len(rt.StrSlice("ids")) > 0 {
|
||||
return apperrors.NewValidation("--id 与 --ids 只能选择一种")
|
||||
}
|
||||
ids := minutesDetailIDs(rt)
|
||||
if len(ids) == 0 || len(ids) > 50 {
|
||||
return apperrors.NewValidation("taskUuid 数量必须为 1..50")
|
||||
}
|
||||
if rt.Str("transcript-output") != "inline" {
|
||||
return localio.ValidateOutput(rt.Str("output-dir"))
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
taskUUID := rt.Str("id")
|
||||
ids := minutesDetailIDs(rt)
|
||||
direction := rt.Str("direction")
|
||||
if direction == "" {
|
||||
direction = "0"
|
||||
@@ -100,30 +137,132 @@ var MinutesDetail = shortcut.Shortcut{
|
||||
want = minutesArtifactOrder
|
||||
}
|
||||
|
||||
bundle := map[string]any{"taskUuid": taskUUID}
|
||||
for _, raw := range want {
|
||||
name := strings.ToLower(strings.TrimSpace(raw))
|
||||
tool, ok := minutesArtifactTools[name]
|
||||
if !ok {
|
||||
continue // guarded by Validate, but stay defensive
|
||||
results := make([]map[string]any, 0, len(ids))
|
||||
allFailures := make([]map[string]any, 0)
|
||||
succeeded := 0
|
||||
for _, taskUUID := range ids {
|
||||
bundle, failures := readMinutesDetail(rt, taskUUID, want, direction)
|
||||
results = append(results, bundle)
|
||||
if len(failures) == 0 {
|
||||
succeeded++
|
||||
}
|
||||
params := map[string]any{"taskUuid": taskUUID}
|
||||
if name == "transcript" {
|
||||
params["direction"] = direction
|
||||
for _, failure := range failures {
|
||||
failure["taskUuid"] = taskUUID
|
||||
allFailures = append(allFailures, failure)
|
||||
}
|
||||
data, err := rt.CallMCPData("minutes", tool, params)
|
||||
if err != nil {
|
||||
// Partial-failure tolerance: record and continue.
|
||||
bundle[name] = map[string]any{"error": err.Error()}
|
||||
continue
|
||||
}
|
||||
bundle[name] = data
|
||||
}
|
||||
|
||||
return rt.Output(bundle)
|
||||
if !rt.Changed("ids") && len(results) == 1 {
|
||||
if err := rt.Output(results[0]); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(allFailures) > 0 {
|
||||
return minutesDetailReadError(ids, allFailures)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
payload := map[string]any{
|
||||
"operation": "minutes.detail", "complete": len(allFailures) == 0,
|
||||
"requested": len(ids), "succeeded": succeeded, "failed": len(ids) - succeeded,
|
||||
"results": results, "failures": allFailures,
|
||||
}
|
||||
if err := rt.Output(payload); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(allFailures) > 0 {
|
||||
return minutesDetailReadError(ids, allFailures)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func readMinutesDetail(rt *shortcut.RuntimeContext, taskUUID string, want []string, direction string) (map[string]any, []map[string]any) {
|
||||
bundle := map[string]any{"taskUuid": taskUUID}
|
||||
failures := make([]map[string]any, 0)
|
||||
for _, raw := range want {
|
||||
name := strings.ToLower(strings.TrimSpace(raw))
|
||||
tool, ok := minutesArtifactTools[name]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if name == "transcript" {
|
||||
result, err := collectMinutesTranscript(rt, taskUUID, direction, rt.Str("cursor"), rt.Bool("single-page"), rt.Int("page-limit"))
|
||||
transcript := minutesdata.TranscriptPayload(taskUUID, direction, result)
|
||||
if err == nil && rt.Str("transcript-output") != "inline" {
|
||||
raw, marshalErr := smartMinutesMarshalIndent(transcript, "", " ")
|
||||
if marshalErr == nil {
|
||||
raw = append(raw, '\n')
|
||||
published, publishErr := smartMinutesPublishBytes(raw, localio.PublishBytesOptions{Output: strings.TrimSuffix(rt.Str("output-dir"), "/") + "/", PreferredName: taskUUID + ".json"})
|
||||
if publishErr == nil {
|
||||
transcript["path"] = published.RelativePath
|
||||
transcript["sizeBytes"] = published.SizeBytes
|
||||
transcript["fileComplete"] = true
|
||||
if rt.Str("transcript-output") == "file" {
|
||||
delete(transcript, "paragraphList")
|
||||
transcript["inline"] = false
|
||||
}
|
||||
} else {
|
||||
err = publishErr
|
||||
}
|
||||
} else {
|
||||
err = marshalErr
|
||||
}
|
||||
}
|
||||
bundle[name] = transcript
|
||||
if err != nil {
|
||||
failures = append(failures, map[string]any{"artifact": name, "error": err.Error()})
|
||||
}
|
||||
continue
|
||||
}
|
||||
data, err := rt.CallMCPData("minutes", tool, map[string]any{"taskUuid": taskUUID})
|
||||
if err != nil {
|
||||
bundle[name] = map[string]any{"error": err.Error()}
|
||||
failures = append(failures, map[string]any{"artifact": name, "error": err.Error()})
|
||||
continue
|
||||
}
|
||||
if err := minutesdata.ValidateArtifact(name, taskUUID, data); err != nil {
|
||||
bundle[name] = map[string]any{"error": err.Error()}
|
||||
failures = append(failures, map[string]any{"artifact": name, "error": err.Error()})
|
||||
continue
|
||||
}
|
||||
bundle[name] = data
|
||||
}
|
||||
bundle["complete"] = len(failures) == 0
|
||||
bundle["failureCount"] = len(failures)
|
||||
if len(failures) > 0 {
|
||||
bundle["failures"] = failures
|
||||
}
|
||||
return bundle, failures
|
||||
}
|
||||
|
||||
func minutesDetailIDs(rt *shortcut.RuntimeContext) []string {
|
||||
values := []string{rt.Str("id")}
|
||||
values = append(values, rt.StrSlice("ids")...)
|
||||
seen := map[string]bool{}
|
||||
out := []string{}
|
||||
for _, value := range values {
|
||||
for _, item := range strings.Split(value, ",") {
|
||||
item = strings.TrimSpace(item)
|
||||
if item != "" && !seen[item] {
|
||||
seen[item] = true
|
||||
out = append(out, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func minutesDetailReadError(ids []string, failures []map[string]any) error {
|
||||
return apperrors.NewAPI(
|
||||
fmt.Sprintf("听记详情读取不完整:%d 个产物失败", len(failures)),
|
||||
apperrors.WithOperation("minutes/+detail"),
|
||||
apperrors.WithOrigin("shortcut"),
|
||||
apperrors.WithFailureStage("artifact_read"),
|
||||
apperrors.WithReason("minutes_detail_incomplete"),
|
||||
apperrors.WithRetryable(false),
|
||||
apperrors.WithDetails(map[string]any{"taskUuids": ids, "failures": failures}),
|
||||
)
|
||||
}
|
||||
|
||||
// minutesArtifactTools maps the user-facing artifact name to the real MCP tool
|
||||
// (ground truth: internal/helpers/minutes.go). Each tool takes a single
|
||||
// "taskUuid"; transcript additionally takes "direction".
|
||||
@@ -140,5 +279,5 @@ var minutesArtifactTools = map[string]string{
|
||||
var minutesArtifactOrder = []string{"basic", "summary", "keywords", "transcript", "todos"}
|
||||
|
||||
func init() {
|
||||
shortcut.Register(MinutesDetail)
|
||||
shortcut.Register(finalizeMinutesSmartShortcut(MinutesDetail))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package smart
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
)
|
||||
|
||||
func runMinutesCLI(t *testing.T, caller *smartCoverageCaller, args ...string) (map[string]any, string, error) {
|
||||
t.Helper()
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
root := newPlatformCoverageRoot()
|
||||
var stdout bytes.Buffer
|
||||
root.SetOut(&stdout)
|
||||
root.SetArgs(args)
|
||||
err := root.Execute()
|
||||
if stdout.Len() == 0 {
|
||||
return nil, "", err
|
||||
}
|
||||
var payload map[string]any
|
||||
if decodeErr := json.Unmarshal(stdout.Bytes(), &payload); decodeErr != nil {
|
||||
t.Fatalf("decode output %q: %v", stdout.String(), decodeErr)
|
||||
}
|
||||
return payload, stdout.String(), err
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesDetailPaginatesRealTranscriptShapeE2E(t *testing.T) {
|
||||
caller := &smartCoverageCaller{responses: map[string][]string{
|
||||
"minutes/get_minutes_transcription": {
|
||||
`{"success":true,"result":{"paragraphList":[{"paragraphId":"p1"},{"paragraphId":"p2"}],"hasNext":true,"nextToken":"n2"}}`,
|
||||
`{"success":true,"result":{"paragraphList":[{"paragraphId":"p2"},{"paragraphId":"p3"}],"hasNext":false,"nextToken":""}}`,
|
||||
},
|
||||
}}
|
||||
payload, _, err := runMinutesCLI(t, caller, "minutes", "+detail", "--id", "u1", "--artifacts", "transcript")
|
||||
if err != nil {
|
||||
t.Fatalf("detail: %v", err)
|
||||
}
|
||||
transcript := payload["transcript"].(map[string]any)
|
||||
if payload["complete"] != true || transcript["complete"] != true || transcript["pages"] != float64(2) || transcript["paragraphCount"] != float64(3) || transcript["duplicateCount"] != float64(1) {
|
||||
t.Fatalf("detail payload = %#v", payload)
|
||||
}
|
||||
args := caller.arguments["minutes/get_minutes_transcription"]
|
||||
if len(args) != 2 || args[1]["nextToken"] != "n2" {
|
||||
t.Fatalf("transcript calls = %#v", args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesDetailUnknownOrPartialTranscriptIsNotSuccessE2E(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
responses []string
|
||||
failAt int
|
||||
wantCount float64
|
||||
}{
|
||||
{name: "missing collection", responses: []string{`{"success":true,"result":{}}`}, wantCount: 0},
|
||||
{name: "later page failure", responses: []string{`{"success":true,"result":{"paragraphList":[{"paragraphId":"p1"}],"hasNext":true,"nextToken":"n2"}}`}, failAt: 2, wantCount: 1},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
caller := &smartCoverageCaller{
|
||||
responses: map[string][]string{"minutes/get_minutes_transcription": test.responses},
|
||||
failAt: map[string]int{"minutes/get_minutes_transcription": test.failAt},
|
||||
}
|
||||
payload, output, err := runMinutesCLI(t, caller, "minutes", "+detail", "--id", "u1", "--artifacts", "transcript")
|
||||
if err == nil || output == "" || payload["complete"] != false || payload["failureCount"] != float64(1) {
|
||||
t.Fatalf("incomplete detail accepted = payload:%#v output:%q err:%v", payload, output, err)
|
||||
}
|
||||
transcript := payload["transcript"].(map[string]any)
|
||||
if transcript["complete"] != false || transcript["paragraphCount"] != test.wantCount {
|
||||
t.Fatalf("incomplete transcript = %#v", transcript)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesDetailBatchWritesVerifiedTranscriptFilesE2E(t *testing.T) {
|
||||
old, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
work := t.TempDir()
|
||||
if err := os.Chdir(work); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(old) })
|
||||
caller := &smartCoverageCaller{responses: map[string][]string{
|
||||
"minutes/get_minutes_transcription": {
|
||||
`{"success":true,"result":{"paragraphList":[{"paragraphId":"p1","paragraph":"one"}],"hasNext":false}}`,
|
||||
`{"success":true,"result":{"paragraphList":[{"paragraphId":"p2","paragraph":"two"}],"hasNext":false}}`,
|
||||
},
|
||||
}}
|
||||
payload, _, err := runMinutesCLI(t, caller, "minutes", "+detail", "--ids", "u1,u2", "--artifacts", "transcript", "--transcript-output", "file", "--output-dir", "transcripts")
|
||||
if err != nil || payload["complete"] != true || payload["requested"] != float64(2) || payload["succeeded"] != float64(2) {
|
||||
t.Fatalf("batch detail=%#v err=%v", payload, err)
|
||||
}
|
||||
for _, result := range payload["results"].([]any) {
|
||||
transcript := result.(map[string]any)["transcript"].(map[string]any)
|
||||
if transcript["inline"] != false || transcript["fileComplete"] != true || transcript["paragraphList"] != nil {
|
||||
t.Fatalf("file transcript=%#v", transcript)
|
||||
}
|
||||
path := transcript["path"].(string)
|
||||
info, statErr := os.Stat(filepath.Join(work, filepath.FromSlash(path)))
|
||||
if statErr != nil || info.Size() <= 0 || float64(info.Size()) != transcript["sizeBytes"] {
|
||||
t.Fatalf("transcript file path=%q info=%#v err=%v", path, info, statErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesDetailBatchPartialIsNonZeroE2E(t *testing.T) {
|
||||
caller := &smartCoverageCaller{responses: map[string][]string{
|
||||
"minutes/get_minutes_transcription": {
|
||||
`{"success":true,"result":{"paragraphList":[{"paragraphId":"p1","paragraph":"one"}],"hasNext":false}}`,
|
||||
`{"success":true,"result":{}}`,
|
||||
},
|
||||
}}
|
||||
payload, output, err := runMinutesCLI(t, caller, "minutes", "+detail", "--ids", "u1,u2", "--artifacts", "transcript")
|
||||
if err == nil || output == "" || payload["complete"] != false || payload["requested"] != float64(2) || payload["succeeded"] != float64(1) || payload["failed"] != float64(1) {
|
||||
t.Fatalf("batch partial accepted: payload=%#v output=%q err=%v", payload, output, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesSearchAndLatestRejectUnknownButAcceptItemListE2E(t *testing.T) {
|
||||
unknown := &smartCoverageCaller{responses: map[string][]string{
|
||||
"minutes/list_by_keyword_and_time_range": {`{"success":true,"result":{}}`},
|
||||
}}
|
||||
if payload, output, err := runMinutesCLI(t, unknown, "minutes", "+minutes-search", "--query", "weekly"); err == nil || payload != nil || output != "" {
|
||||
t.Fatalf("unknown search accepted = payload:%#v output:%q err:%v", payload, output, err)
|
||||
}
|
||||
|
||||
valid := &smartCoverageCaller{responses: map[string][]string{
|
||||
"minutes/list_by_keyword_and_time_range": {`{"success":true,"result":{"itemList":[{"uuid":"u1","title":"weekly","startTime":1}]}}`},
|
||||
"minutes/get_minutes_basic_info": {`{"success":true,"result":{"taskUuid":"u1","title":"weekly"}}`},
|
||||
}}
|
||||
_, _, err := runMinutesCLI(t, valid, "minutes", "+latest-minutes")
|
||||
if err != nil {
|
||||
t.Fatalf("latest itemList: %v", err)
|
||||
}
|
||||
if calls := valid.arguments["minutes/get_minutes_basic_info"]; len(calls) != 1 || calls[0]["taskUuid"] != "u1" {
|
||||
t.Fatalf("latest readback calls = %#v", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesTranscriptSinglePagePublishesIncompleteCursorE2E(t *testing.T) {
|
||||
caller := &smartCoverageCaller{responses: map[string][]string{
|
||||
"minutes/get_minutes_transcription": {`{"success":true,"result":{"paragraphList":[{"paragraphId":"p1"}],"hasNext":true,"nextToken":"n2"}}`},
|
||||
}}
|
||||
payload, output, err := runMinutesCLI(t, caller, "minutes", "+transcript", "--id", "u1", "--single-page")
|
||||
if err != nil || output == "" || payload["complete"] != false || payload["nextToken"] != "n2" || payload["paragraphCount"] != float64(1) {
|
||||
t.Fatalf("single page = payload:%#v output:%q err:%v", payload, output, err)
|
||||
}
|
||||
if strings.Contains(output, `"complete": true`) {
|
||||
t.Fatalf("single page falsely complete: %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesReplaceBatchPartialWriteIsNonZeroE2E(t *testing.T) {
|
||||
for _, policy := range []struct {
|
||||
name string
|
||||
args []string
|
||||
wantCalls int
|
||||
wantApplied float64
|
||||
wantUnattempted float64
|
||||
}{
|
||||
{name: "stop", wantCalls: 2, wantApplied: 1, wantUnattempted: 1},
|
||||
{name: "continue", args: []string{"--failure-policy", "continue"}, wantCalls: 3, wantApplied: 2, wantUnattempted: 0},
|
||||
} {
|
||||
t.Run(policy.name, func(t *testing.T) {
|
||||
caller := &smartCoverageCaller{
|
||||
responses: map[string][]string{
|
||||
"minutes/replace_minutes_text": {`{"success":true}`, `{"success":true}`, `{"success":true}`},
|
||||
"minutes/get_minutes_transcription": {
|
||||
`{"success":true,"result":{"paragraphList":[{"paragraphId":"p1","paragraph":"a b c"}],"hasNext":false}}`,
|
||||
`{"success":true,"result":{"paragraphList":[{"paragraphId":"p1","paragraph":"A b c"}],"hasNext":false}}`,
|
||||
`{"success":true,"result":{"paragraphList":[{"paragraphId":"p1","paragraph":"A b C"}],"hasNext":false}}`,
|
||||
},
|
||||
},
|
||||
failAt: map[string]int{"minutes/replace_minutes_text": 2},
|
||||
}
|
||||
args := []string{"minutes", "+replace-batch", "--id", "u1", "--pair", "a=>A", "--pair", "b=>B", "--pair", "c=>C", "--yes"}
|
||||
args = append(args, policy.args...)
|
||||
payload, output, err := runMinutesCLI(t, caller, args...)
|
||||
if err == nil || output == "" || payload["ok"] != false || payload["failed"] != float64(1) || payload["applied"] != policy.wantApplied || payload["unattempted"] != policy.wantUnattempted {
|
||||
t.Fatalf("partial replace = payload:%#v output:%q err:%v", payload, output, err)
|
||||
}
|
||||
if got := caller.counts["minutes/replace_minutes_text"]; got != policy.wantCalls {
|
||||
t.Fatalf("replace calls = %d, want %d", got, policy.wantCalls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesReplaceAcknowledgedButUnchangedReadbackIsNonZeroE2E(t *testing.T) {
|
||||
caller := &smartCoverageCaller{responses: map[string][]string{
|
||||
"minutes/replace_minutes_text": {`{"success":true}`},
|
||||
"minutes/get_minutes_transcription": {
|
||||
`{"success":true,"result":{"paragraphList":[{"paragraphId":"p1","paragraph":"a"}],"hasNext":false}}`,
|
||||
`{"success":true,"result":{"paragraphList":[{"paragraphId":"p1","paragraph":"a"}],"hasNext":false}}`,
|
||||
},
|
||||
}}
|
||||
payload, output, err := runMinutesCLI(t, caller, "minutes", "+replace-batch", "--id", "u1", "--pair", "a=>A", "--yes")
|
||||
if err == nil || output == "" || payload["ok"] != false || payload["failed"] != float64(1) || payload["applied"] != float64(0) {
|
||||
t.Fatalf("unchanged readback accepted: payload=%#v output=%q err=%v", payload, output, err)
|
||||
}
|
||||
result := payload["results"].([]any)[0].(map[string]any)
|
||||
if result["acknowledged"] != true || result["remoteEffectUnknown"] != true || result["verified"] == true {
|
||||
t.Fatalf("unchanged result=%#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesReplaceJSONDryRunDoesNotWriteE2E(t *testing.T) {
|
||||
caller := &smartCoverageCaller{}
|
||||
payload, output, err := runMinutesCLI(t, caller,
|
||||
"minutes", "+replace-batch", "--id", "u1",
|
||||
"--json", `[{"source":"a","replacement":"A"},{"originalText":"b","replacedText":"B"}]`,
|
||||
"--dry-run")
|
||||
if err != nil || output == "" || payload["dryRun"] != true || payload["executed"] != false || payload["total"] != float64(2) {
|
||||
t.Fatalf("replace dry-run = payload:%#v output:%q err:%v", payload, output, err)
|
||||
}
|
||||
if caller.counts["minutes/replace_minutes_text"] != 0 {
|
||||
t.Fatalf("dry-run performed writes: %#v", caller.counts)
|
||||
}
|
||||
}
|
||||
@@ -16,8 +16,8 @@ package smart
|
||||
import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/minutesdata"
|
||||
)
|
||||
|
||||
// MinutesSearch: search MY minutes (听记) by keyword and print a projected list.
|
||||
@@ -86,50 +86,25 @@ var MinutesSearch = shortcut.Shortcut{
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 2 — project matched entries. latestMinutesItems (from
|
||||
// latest_minutes.go) defensively unwraps the list container.
|
||||
items := latestMinutesItems(data)
|
||||
results := make([]map[string]any, 0, len(items))
|
||||
for _, m := range items {
|
||||
results = append(results, map[string]any{
|
||||
"title": minutesSearchTitle(m),
|
||||
"createTime": minutesSearchCreateTime(m),
|
||||
// latestMinutesUUID probes taskUuid/taskUUID/uuid/id in order.
|
||||
"taskUuid": latestMinutesUUID(m),
|
||||
})
|
||||
// Step 2 — validate and project matched entries. An explicit [] is a
|
||||
// successful empty search; an unknown/malformed shape is a contract error.
|
||||
page, err := minutesdata.ParseListPage(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
results, err := minutesdata.ProjectList(page)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 3 — empty result guard.
|
||||
if len(results) == 0 {
|
||||
return apperrors.NewValidation("没搜到妙记")
|
||||
}
|
||||
|
||||
return rt.Output(map[string]any{"minutes": results})
|
||||
return rt.Output(map[string]any{"count": len(results), "minutes": results})
|
||||
},
|
||||
}
|
||||
|
||||
// minutesSearchTitle reads a minute's display title, tolerating the common
|
||||
// title keys the gateway may use.
|
||||
func minutesSearchTitle(m map[string]any) string {
|
||||
for _, key := range []string{"title", "minutesTitle", "name", "subject"} {
|
||||
if s, ok := m[key].(string); ok && s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// minutesSearchCreateTime reads a minute's create time, returning the raw value
|
||||
// (usually epoch millis) under whichever candidate key is present.
|
||||
func minutesSearchCreateTime(m map[string]any) any {
|
||||
for _, key := range []string{"createTime", "gmtCreate", "startTime", "createTimeStart"} {
|
||||
if v, ok := m[key]; ok && v != nil {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
shortcut.Register(MinutesSearch)
|
||||
// Keep the historical Schema identity as a hidden compatibility
|
||||
// leaf. +search is the only public discovery route, but removing this
|
||||
// independently shipped ToolSpec would break existing Schema consumers.
|
||||
shortcut.Register(finalizeMinutesSmartShortcut(MinutesSearch))
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
package smart
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
@@ -21,7 +22,9 @@ import (
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/localio"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/minutesdata"
|
||||
)
|
||||
|
||||
// ReplaceBatch: apply MANY word replacements to one minute (听记) in a single
|
||||
@@ -33,18 +36,19 @@ import (
|
||||
// repeatedly and eyeballing each result. This shortcut takes multiple --pair
|
||||
// "原文=>替换" entries, validates them (rejecting duplicate source words so two
|
||||
// rules don't fight over the same term), applies each via replace_minutes_text
|
||||
// and aggregates a per-pair {applied|error} report through rt.Output — one
|
||||
// failing pair does not abort the rest.
|
||||
// and aggregates a per-pair {applied|error} report through rt.Output. The
|
||||
// default policy stops at the first error; explicit continue still returns
|
||||
// non-zero when any write fails.
|
||||
//
|
||||
// dws minutes +replace-batch --id <taskUuid> --pair "张三=>张三丰" --pair "Q2=>第二季度"
|
||||
var ReplaceBatch = shortcut.Shortcut{
|
||||
Service: "minutes",
|
||||
Command: "+replace-batch",
|
||||
Product: "minutes",
|
||||
Description: "对一条妙记(听记)批量执行多组文字替换(原文=>替换)",
|
||||
Intent: "当你要在同一条听记里一次性纠正多个词(如把多个错识别的人名/术语统一替换),而底层工具一次只能替换一组时使用;" +
|
||||
"内部按多个 --pair \"原文=>替换\" 逐组调用替换工具,先在本地校验去重(同一个「原文」不能出现两次,避免两条规则互相打架)," +
|
||||
"再逐组应用并聚合每组的成功/失败结果,某一组失败不会中断其余组。这是写操作,会实际修改听记文字内容,请确认 taskUuid 与替换规则无误。",
|
||||
Description: "预检并批量执行多组听记文字替换,逐项验证且失败必定非零",
|
||||
Intent: "当你要在同一条听记里一次纠正多个错识别人名或术语时使用;支持重复 --pair,或通过 --json 接受字面量、@相对文件和 stdin。" +
|
||||
"命令会先读取完整逐字稿并校验所有原文、空值和重复规则,再逐项写入并重新读取完整逐字稿验证效果。" +
|
||||
"默认首错停止;只有显式 failure-policy=continue 才继续,但任一失败仍返回非零及 applied/failed/unattempted ledger。底层不是事务且没有自动回滚,并会同时影响逐字稿和摘要。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium",
|
||||
@@ -58,7 +62,7 @@ var ReplaceBatch = shortcut.Shortcut{
|
||||
CLIPath: "minutes +replace-batch",
|
||||
PrimaryCLIPath: "minutes +replace-batch",
|
||||
},
|
||||
Description: "对一条妙记(听记)批量执行多组文字替换(原文=>替换)",
|
||||
Description: "预检并批量执行多组听记文字替换,逐项验证且失败必定非零",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
@@ -73,34 +77,50 @@ var ReplaceBatch = shortcut.Shortcut{
|
||||
"dws minutes +replace-batch --id <taskUuid> --pair \"Q2=>第二季度\" --pair \"PM=>产品经理\"",
|
||||
},
|
||||
},
|
||||
DryRun: &contract.DryRunSpec{
|
||||
PreviewKind: contract.DryRunPreviewPlan,
|
||||
RemoteReads: false,
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "pair", Description: `替换规则,格式 "原文=>替换",可重复传多组(必填);每组原文不能为空且不能重复`},
|
||||
{Name: "json", Description: "JSON 字面量、@工作目录相对文件或 - 表示 stdin"},
|
||||
{Name: "failure-policy", Description: "stop=首错停止;continue=显式继续,但只要有失败仍返回非零"},
|
||||
},
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "id", Type: shortcut.FlagString, Desc: "听记 taskUuid(必填)", Required: true},
|
||||
{Name: "pair", Type: shortcut.FlagStringSlice, Desc: `替换规则,格式 "原文=>替换",可重复传多组(必填)`, Required: true},
|
||||
{Name: "pair", Type: shortcut.FlagStringSlice, Desc: `替换规则,格式 "原文=>替换",可重复传多组`},
|
||||
{Name: "json", Type: shortcut.FlagString, Desc: "替换规则 JSON 字面量、@相对文件或 - 表示 stdin"},
|
||||
{Name: "failure-policy", Type: shortcut.FlagString, Default: "stop", Desc: "失败策略", Enum: []string{"stop", "continue"}},
|
||||
{Name: "page-limit", Type: shortcut.FlagInt, Default: "100", Desc: "逐字稿写前/写后验证的翻页上限"},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{
|
||||
{
|
||||
Kind: shortcut.ConstraintCustom,
|
||||
Flags: []string{"pair"},
|
||||
Description: `每个 --pair 必须使用 "原文=>替换" 格式,原文不能为空且不能重复`,
|
||||
Flags: []string{"pair", "json"},
|
||||
Description: `--pair 与 --json 至少提供一种;规则必须使用 "原文=>替换" 或 JSON 数组;原文不能为空且不能重复`,
|
||||
},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-limit"}, Description: "--page-limit 必须大于 0"},
|
||||
},
|
||||
Tips: []string{
|
||||
`dws minutes +replace-batch --id <taskUuid> --pair "张三=>张三丰"`,
|
||||
`dws minutes +replace-batch --id <taskUuid> --pair "Q2=>第二季度" --pair "PM=>产品经理"`,
|
||||
},
|
||||
Validate: func(rt *shortcut.RuntimeContext) error {
|
||||
_, err := parseReplacePairs(rt.StrSlice("pair"))
|
||||
return err
|
||||
if rt.Int("page-limit") <= 0 {
|
||||
return apperrors.NewValidation("--page-limit 必须大于 0")
|
||||
}
|
||||
if len(rt.StrSlice("pair")) > 0 {
|
||||
_, err := parseReplacePairs(rt.StrSlice("pair"))
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
taskUUID := rt.Str("id")
|
||||
pairs, err := parseReplacePairs(rt.StrSlice("pair"))
|
||||
pairs, err := loadReplacePairs(rt)
|
||||
if err != nil {
|
||||
return err // already validated, but stay defensive
|
||||
return err
|
||||
}
|
||||
if rt.DryRun() {
|
||||
replacements := make([]map[string]any, 0, len(pairs))
|
||||
@@ -111,40 +131,109 @@ var ReplaceBatch = shortcut.Shortcut{
|
||||
})
|
||||
}
|
||||
return rt.Output(map[string]any{
|
||||
"dryRun": true,
|
||||
"taskUuid": taskUUID,
|
||||
"total": len(pairs),
|
||||
"replacements": replacements,
|
||||
"operation": "minutes.replace_batch",
|
||||
"dry_run": true,
|
||||
"dryRun": true,
|
||||
"preview_kind": contract.DryRunPreviewPlan,
|
||||
"executed": false,
|
||||
"failurePolicy": rt.Str("failure-policy"),
|
||||
"taskUuid": taskUUID,
|
||||
"total": len(pairs),
|
||||
"replacements": replacements,
|
||||
})
|
||||
}
|
||||
|
||||
currentText, err := minutesReplaceTranscriptText(rt, taskUUID, rt.Int("page-limit"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, pair := range pairs {
|
||||
if strings.Count(currentText, pair.orig) == 0 {
|
||||
return apperrors.NewValidation(fmt.Sprintf("完整逐字稿中不存在待替换原文 %q;未执行任何写入", pair.orig))
|
||||
}
|
||||
}
|
||||
|
||||
results := make([]map[string]any, 0, len(pairs))
|
||||
applied := 0
|
||||
for _, p := range pairs {
|
||||
failed := 0
|
||||
unattempted := 0
|
||||
for index, p := range pairs {
|
||||
// Params mirror helpers replace_minutes_text call site: taskUuid /
|
||||
// originalText / replacedText.
|
||||
_, callErr := rt.CallMCPWriteData("minutes", "replace_minutes_text", map[string]any{
|
||||
writeData, callErr := rt.CallMCPWriteDataStrict("minutes", "replace_minutes_text", map[string]any{
|
||||
"taskUuid": taskUUID,
|
||||
"originalText": p.orig,
|
||||
"replacedText": p.repl,
|
||||
})
|
||||
if callErr == nil {
|
||||
callErr = minutesdata.RequireWriteAcknowledgement("replace text", writeData)
|
||||
}
|
||||
entry := map[string]any{"originalText": p.orig, "replacedText": p.repl}
|
||||
acknowledged := callErr == nil
|
||||
if acknowledged {
|
||||
entry["acknowledged"] = true
|
||||
beforeSource := strings.Count(currentText, p.orig)
|
||||
beforeTarget := strings.Count(currentText, p.repl)
|
||||
afterText, readErr := minutesReplaceTranscriptText(rt, taskUUID, rt.Int("page-limit"))
|
||||
if readErr != nil {
|
||||
callErr = fmt.Errorf("写入已确认但逐字稿读回失败: %w", readErr)
|
||||
} else if !replaceReadbackVerified(currentText, afterText, p, beforeSource, beforeTarget) {
|
||||
callErr = fmt.Errorf("写入已确认但逐字稿读回未证明替换生效")
|
||||
} else {
|
||||
currentText = afterText
|
||||
entry["verified"] = true
|
||||
}
|
||||
}
|
||||
if callErr != nil {
|
||||
entry["error"] = callErr.Error()
|
||||
entry["remoteEffectUnknown"] = acknowledged
|
||||
failed++
|
||||
results = append(results, entry)
|
||||
if rt.Str("failure-policy") == "stop" {
|
||||
for _, pending := range pairs[index+1:] {
|
||||
results = append(results, map[string]any{
|
||||
"originalText": pending.orig,
|
||||
"replacedText": pending.repl,
|
||||
"unattempted": true,
|
||||
})
|
||||
unattempted++
|
||||
}
|
||||
break
|
||||
}
|
||||
} else {
|
||||
entry["applied"] = true
|
||||
applied++
|
||||
results = append(results, entry)
|
||||
}
|
||||
results = append(results, entry)
|
||||
}
|
||||
|
||||
return rt.Output(map[string]any{
|
||||
"taskUuid": taskUUID,
|
||||
"total": len(pairs),
|
||||
"applied": applied,
|
||||
"failed": len(pairs) - applied,
|
||||
"results": results,
|
||||
})
|
||||
payload := map[string]any{
|
||||
"ok": failed == 0,
|
||||
"partial": applied > 0 && (failed > 0 || unattempted > 0),
|
||||
"taskUuid": taskUUID,
|
||||
"failurePolicy": rt.Str("failure-policy"),
|
||||
"total": len(pairs),
|
||||
"applied": applied,
|
||||
"failed": failed,
|
||||
"unattempted": unattempted,
|
||||
"results": results,
|
||||
}
|
||||
if err := rt.Output(payload); err != nil {
|
||||
return err
|
||||
}
|
||||
if failed > 0 {
|
||||
return apperrors.NewAPI(
|
||||
fmt.Sprintf("批量替换未完成:成功 %d、失败 %d、未执行 %d", applied, failed, unattempted),
|
||||
apperrors.WithOperation("minutes/replace_minutes_text"),
|
||||
apperrors.WithOrigin("shortcut"),
|
||||
apperrors.WithFailureStage("write"),
|
||||
apperrors.WithReason("minutes_replace_partial_failure"),
|
||||
apperrors.WithExecutionStarted(true),
|
||||
apperrors.WithRetryable(false),
|
||||
apperrors.WithDetails(payload),
|
||||
)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
@@ -183,6 +272,133 @@ func parseReplacePairs(raw []string) ([]replacePair, error) {
|
||||
return pairs, nil
|
||||
}
|
||||
|
||||
func loadReplacePairs(rt *shortcut.RuntimeContext) ([]replacePair, error) {
|
||||
pairs, err := parseReplacePairs(rt.StrSlice("pair"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if rt.Changed("json") {
|
||||
text, err := localio.ReadTextInput(rt.Str("json"), rt.Command().InOrStdin(), 8<<20)
|
||||
if err != nil {
|
||||
return nil, apperrors.NewValidation(err.Error())
|
||||
}
|
||||
jsonPairs, err := parseReplaceJSON(text)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pairs = append(pairs, jsonPairs...)
|
||||
}
|
||||
return validateReplacePairs(pairs)
|
||||
}
|
||||
|
||||
func parseReplaceJSON(raw string) ([]replacePair, error) {
|
||||
type wirePair struct {
|
||||
Source string `json:"source"`
|
||||
Replacement string `json:"replacement"`
|
||||
OriginalText string `json:"originalText"`
|
||||
ReplacedText string `json:"replacedText"`
|
||||
}
|
||||
var direct []wirePair
|
||||
if err := json.Unmarshal([]byte(raw), &direct); err != nil {
|
||||
var envelope struct {
|
||||
Replacements []wirePair `json:"replacements"`
|
||||
}
|
||||
if envelopeErr := json.Unmarshal([]byte(raw), &envelope); envelopeErr != nil || envelope.Replacements == nil {
|
||||
return nil, apperrors.NewValidation(fmt.Sprintf("--json 必须是替换规则数组或 {\"replacements\":[...]}: %v", err))
|
||||
}
|
||||
direct = envelope.Replacements
|
||||
}
|
||||
if len(direct) == 0 {
|
||||
return nil, apperrors.NewValidation("--json 至少需要一条替换规则")
|
||||
}
|
||||
pairs := make([]replacePair, 0, len(direct))
|
||||
for index, item := range direct {
|
||||
source := item.Source
|
||||
if source == "" {
|
||||
source = item.OriginalText
|
||||
}
|
||||
replacement := item.Replacement
|
||||
if replacement == "" {
|
||||
replacement = item.ReplacedText
|
||||
}
|
||||
if strings.TrimSpace(source) == "" {
|
||||
return nil, apperrors.NewValidation(fmt.Sprintf("--json 第 %d 条规则缺少 source/originalText", index))
|
||||
}
|
||||
pairs = append(pairs, replacePair{orig: strings.TrimSpace(source), repl: strings.TrimSpace(replacement)})
|
||||
}
|
||||
return pairs, nil
|
||||
}
|
||||
|
||||
func validateReplacePairs(pairs []replacePair) ([]replacePair, error) {
|
||||
if len(pairs) == 0 {
|
||||
return nil, apperrors.NewValidation("至少需要一条替换规则")
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, pair := range pairs {
|
||||
if strings.TrimSpace(pair.orig) == "" {
|
||||
return nil, apperrors.NewValidation("替换规则的原文不能为空")
|
||||
}
|
||||
if seen[pair.orig] {
|
||||
return nil, apperrors.NewValidation(fmt.Sprintf("重复的「原文」%q:同一个原文只能有一条替换规则", pair.orig))
|
||||
}
|
||||
seen[pair.orig] = true
|
||||
}
|
||||
return pairs, nil
|
||||
}
|
||||
|
||||
func minutesReplaceTranscriptText(rt *shortcut.RuntimeContext, taskUUID string, pageLimit int) (string, error) {
|
||||
result, err := collectMinutesTranscript(rt, taskUUID, "0", "", false, pageLimit)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(result.Paragraphs) == 0 {
|
||||
return "", fmt.Errorf("逐字稿为空,无法证明文字替换目标或写入结果")
|
||||
}
|
||||
parts := make([]string, 0, len(result.Paragraphs))
|
||||
var collect func(any)
|
||||
collect = func(value any) {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
for key, child := range typed {
|
||||
switch key {
|
||||
case "paragraph", "sentence", "word", "text", "content":
|
||||
if text, ok := child.(string); ok && text != "" {
|
||||
parts = append(parts, text)
|
||||
}
|
||||
default:
|
||||
collect(child)
|
||||
}
|
||||
}
|
||||
case []any:
|
||||
for _, child := range typed {
|
||||
collect(child)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, paragraph := range result.Paragraphs {
|
||||
collect(paragraph)
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return "", fmt.Errorf("逐字稿没有可验证的文本字段")
|
||||
}
|
||||
return strings.Join(parts, "\n"), nil
|
||||
}
|
||||
|
||||
func replaceReadbackVerified(before, after string, pair replacePair, beforeSource, beforeTarget int) bool {
|
||||
if before == after {
|
||||
return false
|
||||
}
|
||||
afterSource := strings.Count(after, pair.orig)
|
||||
if pair.repl == "" {
|
||||
return afterSource < beforeSource
|
||||
}
|
||||
afterTarget := strings.Count(after, pair.repl)
|
||||
if strings.Contains(pair.repl, pair.orig) {
|
||||
return afterTarget > beforeTarget
|
||||
}
|
||||
return afterSource < beforeSource && afterTarget > beforeTarget
|
||||
}
|
||||
|
||||
func init() {
|
||||
shortcut.Register(ReplaceBatch)
|
||||
shortcut.Register(finalizeMinutesSmartShortcut(ReplaceBatch))
|
||||
}
|
||||
|
||||
@@ -14,8 +14,12 @@
|
||||
package smart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/minutesdata"
|
||||
)
|
||||
|
||||
// Transcript: fetch the verbatim transcript (逐字稿 / 语音转写原文) of MY most
|
||||
@@ -38,41 +42,66 @@ var Transcript = shortcut.Shortcut{
|
||||
Service: "minutes",
|
||||
Command: "+transcript",
|
||||
Product: "minutes",
|
||||
Description: "取我最新一条妙记(听记)的逐字稿(语音转写原文)",
|
||||
Intent: "当你想直接读回自己最近一次会议听记的逐字稿(完整语音转写原文),又不想先翻列表、复制 taskUuid 再单独查转写时使用;" +
|
||||
"内部先列出你创建的听记(可用 --keyword 缩小范围),自动挑出最新的一条,再拉取它的逐字记录(每条含发言人、文本、时间戳)。" +
|
||||
"可用 --direction 控制排序:0=正序(时间递增,默认),1=倒序。这是只读操作,不会修改任何听记;" +
|
||||
"若你名下没有任何听记则提示「暂无妙记」。",
|
||||
Description: "读取指定或我最新一条听记的完整逐字稿,并交付分页完整性证据",
|
||||
Intent: "当你要读取已知 taskUuid(--id)的完整逐字稿,或不传 --id 自动选择自己最新听记时使用;" +
|
||||
"默认追完 nextToken、跨页去重并输出 complete/pages/nextToken,只有显式 --single-page 才停在一页。" +
|
||||
"可用 --direction 控制排序,--keyword 仅在自动选择最新听记时缩小候选;任何分页漂移或中途失败都返回非零而不是把部分原文当成全集。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "keyword", Type: shortcut.FlagString, Desc: "按关键字过滤听记(可选)", Required: false},
|
||||
{Name: "direction", Type: shortcut.FlagString, Desc: "排序方向: 0=正序(默认), 1=倒序(可选)", Required: false},
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: minutesSmartContract(
|
||||
"+transcript",
|
||||
"读取指定或最新听记的完整逐字稿",
|
||||
"需要读取逐字稿并自动追完 nextToken、去重段落,同时看到 complete/pages/nextToken 完整性证据时使用;不传 --id 时严格选择最新听记。",
|
||||
[]string{"只需单个原始分页响应时使用底层转写命令;需要汇总多个制品时使用 +detail"},
|
||||
[]string{"dws minutes +transcript --id <taskUuid>", "dws minutes +transcript --keyword 周会"},
|
||||
nil,
|
||||
),
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "id", Type: shortcut.FlagString, Desc: "听记 taskUuid;不传时选择我最新的一条"},
|
||||
{Name: "keyword", Type: shortcut.FlagString, Desc: "按关键字过滤听记(可选)", Required: false},
|
||||
{Name: "direction", Type: shortcut.FlagString, Desc: "排序方向: 0=正序(默认), 1=倒序(可选)", Required: false, Enum: []string{"0", "1"}},
|
||||
{Name: "cursor", Type: shortcut.FlagString, Desc: "单页/续拉的起始 nextToken"},
|
||||
{Name: "single-page", Type: shortcut.FlagBool, Desc: "只读取一页;输出 complete/nextToken"},
|
||||
{Name: "page-limit", Type: shortcut.FlagInt, Default: "100", Desc: "自动翻页安全上限"},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"page-limit"}, Description: "--page-limit 必须大于 0"}},
|
||||
Tips: []string{
|
||||
`dws minutes +transcript`,
|
||||
`dws minutes +transcript --id <taskUuid>`,
|
||||
`dws minutes +transcript --keyword 周会`,
|
||||
`dws minutes +transcript --direction 1`,
|
||||
},
|
||||
Validate: func(rt *shortcut.RuntimeContext) error {
|
||||
if rt.Int("page-limit") <= 0 {
|
||||
return apperrors.NewValidation("--page-limit 必须大于 0")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
// Step 1 — list my minutes (newest first). belongingConditionId /
|
||||
// maxResults / keyword mirror helpers.callListByKeywordRange.
|
||||
listArgs := map[string]any{
|
||||
"belongingConditionId": "created",
|
||||
"maxResults": float64(20),
|
||||
}
|
||||
if kw := rt.Str("keyword"); kw != "" {
|
||||
listArgs["keyword"] = kw
|
||||
}
|
||||
data, err := rt.CallMCPData("minutes", "list_by_keyword_and_time_range", listArgs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 2 — locate the newest minute's taskUuid (reuses helper from
|
||||
// latest_minutes.go).
|
||||
taskUUID := latestMinutesTaskUUID(data)
|
||||
taskUUID := rt.Str("id")
|
||||
if taskUUID == "" {
|
||||
return apperrors.NewValidation("暂无妙记")
|
||||
// Step 1 — list my minutes (newest first). belongingConditionId /
|
||||
// maxResults / keyword mirror helpers.callListByKeywordRange.
|
||||
listArgs := map[string]any{
|
||||
"belongingConditionId": "created",
|
||||
"maxResults": float64(20),
|
||||
}
|
||||
if kw := rt.Str("keyword"); kw != "" {
|
||||
listArgs["keyword"] = kw
|
||||
}
|
||||
data, err := rt.CallMCPData("minutes", "list_by_keyword_and_time_range", listArgs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 2 — locate the newest minute's taskUuid.
|
||||
taskUUID, err = latestMinutesTaskUUID(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if taskUUID == "" {
|
||||
return apperrors.NewValidation("暂无妙记")
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3 — print its verbatim transcript. taskUuid + direction mirror
|
||||
@@ -82,13 +111,46 @@ var Transcript = shortcut.Shortcut{
|
||||
if direction == "" {
|
||||
direction = "0"
|
||||
}
|
||||
return rt.CallMCP("get_minutes_transcription", map[string]any{
|
||||
"taskUuid": taskUUID,
|
||||
"direction": direction,
|
||||
})
|
||||
result, readErr := collectMinutesTranscript(rt, taskUUID, direction, rt.Str("cursor"), rt.Bool("single-page"), rt.Int("page-limit"))
|
||||
payload := minutesdata.TranscriptPayload(taskUUID, direction, result)
|
||||
if err := rt.Output(payload); err != nil {
|
||||
return err
|
||||
}
|
||||
if readErr != nil {
|
||||
return minutesTranscriptReadError(taskUUID, result, readErr)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func collectMinutesTranscript(rt *shortcut.RuntimeContext, taskUUID, direction, cursor string, singlePage bool, pageLimit int) (minutesdata.TranscriptResult, error) {
|
||||
return minutesdata.CollectTranscript(func(nextToken string) (map[string]any, error) {
|
||||
params := map[string]any{"taskUuid": taskUUID, "direction": direction}
|
||||
if nextToken != "" {
|
||||
params["nextToken"] = nextToken
|
||||
}
|
||||
return rt.CallMCPData("minutes", "get_minutes_transcription", params)
|
||||
}, cursor, singlePage, pageLimit)
|
||||
}
|
||||
|
||||
func minutesTranscriptReadError(taskUUID string, result minutesdata.TranscriptResult, cause error) error {
|
||||
return apperrors.NewAPI(
|
||||
fmt.Sprintf("逐字稿读取不完整:已读取 %d 页、%d 个段落", result.Pages, len(result.Paragraphs)),
|
||||
apperrors.WithOperation("minutes/get_minutes_transcription"),
|
||||
apperrors.WithOrigin("mcp"),
|
||||
apperrors.WithFailureStage("pagination"),
|
||||
apperrors.WithReason("minutes_transcript_incomplete"),
|
||||
apperrors.WithRetryable(false),
|
||||
apperrors.WithDetails(map[string]any{
|
||||
"taskUuid": taskUUID,
|
||||
"pages": result.Pages,
|
||||
"paragraphCount": len(result.Paragraphs),
|
||||
"nextToken": result.NextToken,
|
||||
"cause": cause.Error(),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
func init() {
|
||||
shortcut.Register(Transcript)
|
||||
shortcut.Register(finalizeMinutesSmartShortcut(Transcript))
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ SEMANTIC_PATHS = [
|
||||
ROOT / "internal" / "shortcut" / "semantic_catalog.json",
|
||||
ROOT / "internal" / "shortcut" / "semantic_catalog_doc.json",
|
||||
ROOT / "internal" / "shortcut" / "semantic_catalog_aitable.json",
|
||||
ROOT / "internal" / "shortcut" / "semantic_catalog_minutes.json",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ cli_version: ">=1.0.15"
|
||||
| `doc` | 45 | `dingtalk-doc` |
|
||||
| `drive` | 7 | `dingtalk-drive` |
|
||||
| `mail` | 10 | `dingtalk-mail` |
|
||||
| `minutes` | 6 | `dingtalk-minutes` |
|
||||
| `minutes` | 27 | `dingtalk-minutes` |
|
||||
| `oa` | 7 | `dingtalk-misc` |
|
||||
| `report` | 2 | `dingtalk-misc` |
|
||||
| `sheet` | 2 | `dingtalk-misc` |
|
||||
|
||||
@@ -194,9 +194,9 @@ Example:
|
||||
dws wiki feed list --workspace <workspaceId> --limit 10 --cursor <nextToken> --format json
|
||||
Flags:
|
||||
--workspace string 知识库 ID 或 URL (必填)
|
||||
--limit int 每页数量 (默认 20,最大 50)
|
||||
--limit int 每页数量 (默认 10,最大 20)。用户未明确要求条数时禁止加此 flag,让服务端走默认 10
|
||||
--cursor string 分页游标 (首页留空)
|
||||
--exclude-file 是否排除文件相关的动态 (默认 false)
|
||||
--exclude-file 排除普通文件、媒体文件、文件夹及 Office 文件动态,仅保留在线文档操作 (默认 false)
|
||||
```
|
||||
|
||||
查询指定知识库的动态,返回谁在什么时间进行了更新、上传、评论等操作。
|
||||
@@ -205,6 +205,28 @@ Flags:
|
||||
|
||||
> **权限要求**:调用者需具备知识库的成员权限,非成员会被拒绝访问。
|
||||
|
||||
**动态类型 `type` 枚举(严格按此映射展示,禁止猜测)**:
|
||||
|
||||
| type | 含义 |
|
||||
|------|------|
|
||||
| 0 | 创建文档 |
|
||||
| 1 | 更新文档 |
|
||||
| 2 | 评论文档 |
|
||||
| 3 | 点赞文档 |
|
||||
| 4 | 加入团队空间 |
|
||||
| 5 | 表格选区数据变更 |
|
||||
| 6 | 更新 office 文件 |
|
||||
| 7 | 上传普通文件(非媒体文件) |
|
||||
| 8 | 上传媒体文件(图片/视频) |
|
||||
| 9 | 上传文件夹 |
|
||||
| 10 | 上传文件夹 V2 |
|
||||
| 11 | 加入团队 |
|
||||
| 12 | 创建知识库 |
|
||||
|
||||
> **重要 — 展示规则(必须严格遵守)**:
|
||||
> 1. **typeLabel 字段**:CLI 已自动将 `type` 数字映射为 `typeLabel` 中文标签(如 `更新文档`、`创建文档`),**直接使用 `typeLabel` 展示即可**,无需自行查表映射。
|
||||
> 2. **timeFormatted 字段**:已是北京时间字符串(格式如 `2026-07-30 08:00`),直接展示即可,无需任何转换。原始毫秒时间戳保存在 `time` 字段中(数字类型)。
|
||||
|
||||
### 列出知识库节点
|
||||
```
|
||||
Usage:
|
||||
@@ -323,6 +345,10 @@ Flags:
|
||||
- 用户说"知识库动态/最近有什么更新/谁改了什么/知识库活动" → `feed list`(需 `--workspace`)
|
||||
- 用户说"知识库最近的评论/更新记录/操作日志" → `feed list`(需 `--workspace`)
|
||||
- 用户说"删除知识库/移除知识库/把知识库删了" → `space delete`(需 `--workspace`)
|
||||
- 用户说"排除文件/只看创建文档/只看更新文档/只看文档操作/不要上传文件的记录/过滤掉文件动态/只看文档变更" → `feed list --exclude-file`(**必须带 flag,禁止客户端自行过滤**)
|
||||
|
||||
> **重要 — `--exclude-file` 使用规则**:
|
||||
> 当用户意图排除文件类动态(上传文件、更新 office 文件等)或只看文档操作时,**必须在命令中带上 `--exclude-file`**,由后端完成过滤。禁止先拉全量数据再用 Python/jq 等工具在客户端过滤,那样既浪费带宽又可能因分页遗漏数据。
|
||||
|
||||
> **跨产品路由说明**:知识库节点的**内容操作**(读取/编辑/块级操作)仍由 `dws doc` 承担:
|
||||
>- 用户说"读某个知识库里的某篇文档" → 先 `node list` 拿到 nodeId,再走 **`dws doc read --node <nodeId>`**
|
||||
|
||||
@@ -24,12 +24,33 @@ metadata:
|
||||
|
||||
| Shortcut | 风险 | 适用场景 |
|
||||
|---|---|---|
|
||||
| `dws minutes +detail` | read | 一条命令聚合取一条妙记(听记)的多项产物(基础信息/摘要/关键词/逐字稿/待办) |
|
||||
| `dws minutes +action-items` | read | 读取指定或我最新一条听记中已抽取的行动项 |
|
||||
| `dws minutes +apply-permission` | write | 为当前用户申请语义化的听记访问权限 |
|
||||
| `dws minutes +detail` | read | 批量聚合听记基础信息、摘要、关键词、完整逐字稿和行动项,支持安全文件输出 |
|
||||
| `dws minutes +download` | read | 批量取得听记音视频地址并安全下载到本地 |
|
||||
| `dws minutes +export-pack` | read | 把完整听记产物写入受控目录并生成不含签名 URL 的 manifest |
|
||||
| `dws minutes +latest` | read | 取我最新的一条妙记(听记)详情 |
|
||||
| `dws minutes +list-all` | read | 查询我有权限访问的所有听记列表 |
|
||||
| `dws minutes +list-mine` | read | 查询我创建的听记列表 |
|
||||
| `dws minutes +list-shared` | read | 查询他人共享给我的听记列表 |
|
||||
| `dws minutes +mindmap` | write | 创建听记思维导图并轮询到明确成功、失败或超时 |
|
||||
| `dws minutes +prepare-asr` | write | 读取个人热词、计算差异、按需新增并可显式同步删除后读回验证 |
|
||||
| `dws minutes +record-pause` | write | 暂停听记录音 |
|
||||
| `dws minutes +record-resume` | write | 恢复听记录音 |
|
||||
| `dws minutes +record-start` | write | 发起听记(开始录音) |
|
||||
| `dws minutes +replace-batch` | write | 对一条妙记(听记)批量执行多组文字替换(原文=>替换) |
|
||||
| `dws minutes +record-stop` | write | 结束听记录音 |
|
||||
| `dws minutes +record-wrap-up` | write | 停止实时录音并有界等待听记产物,失败时保留恢复句柄 |
|
||||
| `dws minutes +replace-batch` | write | 预检并批量执行多组听记文字替换,逐项验证且失败必定非零 |
|
||||
| `dws minutes +search` | read | 按范围、标题关键词和时间搜索听记,支持安全全量翻页 |
|
||||
| `dws minutes +share` | write | 按成员逐项授予一个或多个听记权限,输出可审计的部分写入 ledger |
|
||||
| `dws minutes +speaker-insights` | write | 创建发言人段落总结并轮询结果,保留异步任务恢复句柄 |
|
||||
| `dws minutes +speaker-replace` | write | 预检逐字稿中的发言人昵称,替换后重新读回验证 |
|
||||
| `dws minutes +summary` | write | 读取当前纪要、校验图片引用、全量覆盖并读回验证 |
|
||||
| `dws minutes +transcript` | read | 读取指定或我最新一条听记的完整逐字稿,并交付分页完整性证据 |
|
||||
| `dws minutes +unshare` | write | 按成员逐项移除一个或多个听记权限,输出可审计的部分写入 ledger |
|
||||
| `dws minutes +update` | write | 读取现状、预览差异、更新听记标题并读回验证 |
|
||||
| `dws minutes +upload` | write | 把本地音视频完整上传并创建听记,失败时取消会话 |
|
||||
| `dws minutes +upload-and-analyze` | write | 本地音视频直传听记并等待分析产物,可选思维导图和发言人洞察 |
|
||||
<!-- VISIBLE_SHORTCUTS_END -->
|
||||
|
||||
## 意图表
|
||||
|
||||
@@ -184,9 +184,9 @@ Example:
|
||||
dws wiki feed list --workspace <workspaceId> --limit 10 --cursor <nextToken> --format json
|
||||
Flags:
|
||||
--workspace string 知识库 ID 或 URL (必填)
|
||||
--limit int 每页数量 (默认 20,最大 50)
|
||||
--limit int 每页数量 (默认 10,最大 20)。用户未明确要求条数时禁止加此 flag,让服务端走默认 10
|
||||
--cursor string 分页游标 (首页留空)
|
||||
--exclude-file 是否排除文件相关的动态 (默认 false)
|
||||
--exclude-file 排除普通文件、媒体文件、文件夹及 Office 文件动态,仅保留在线文档操作 (默认 false)
|
||||
```
|
||||
|
||||
查询指定知识库的动态,返回谁在什么时间进行了更新、上传、评论等操作。
|
||||
@@ -195,6 +195,28 @@ Flags:
|
||||
|
||||
> **权限要求**:调用者需具备知识库的成员权限,非成员会被拒绝访问。
|
||||
|
||||
**动态类型 `type` 枚举(严格按此映射展示,禁止猜测)**:
|
||||
|
||||
| type | 含义 |
|
||||
|------|------|
|
||||
| 0 | 创建文档 |
|
||||
| 1 | 更新文档 |
|
||||
| 2 | 评论文档 |
|
||||
| 3 | 点赞文档 |
|
||||
| 4 | 加入团队空间 |
|
||||
| 5 | 表格选区数据变更 |
|
||||
| 6 | 更新 office 文件 |
|
||||
| 7 | 上传普通文件(非媒体文件) |
|
||||
| 8 | 上传媒体文件(图片/视频) |
|
||||
| 9 | 上传文件夹 |
|
||||
| 10 | 上传文件夹 V2 |
|
||||
| 11 | 加入团队 |
|
||||
| 12 | 创建知识库 |
|
||||
|
||||
> **重要 — 展示规则(必须严格遵守)**:
|
||||
> 1. **typeLabel 字段**:CLI 已自动将 `type` 数字映射为 `typeLabel` 中文标签(如 `更新文档`、`创建文档`),**直接使用 `typeLabel` 展示即可**,无需自行查表映射。
|
||||
> 2. **timeFormatted 字段**:已是北京时间字符串(格式如 `2026-07-30 08:00`),直接展示即可,无需任何转换。原始毫秒时间戳保存在 `time` 字段中(数字类型)。
|
||||
|
||||
### 列出知识库节点
|
||||
```
|
||||
Usage:
|
||||
@@ -333,6 +355,10 @@ Flags:
|
||||
- 用户说"知识库动态/最近有什么更新/谁改了什么/知识库活动" → `feed list`(需 `--workspace`)
|
||||
- 用户说"知识库最近的评论/更新记录/操作日志" → `feed list`(需 `--workspace`)
|
||||
- 用户说"删除知识库/移除知识库/把知识库删了" → `space delete`(需 `--workspace`)
|
||||
- 用户说"排除文件/只看创建文档/只看更新文档/只看文档操作/不要上传文件的记录/过滤掉文件动态/只看文档变更" → `feed list --exclude-file`(**必须带 flag,禁止客户端自行过滤**)
|
||||
|
||||
> **重要 — `--exclude-file` 使用规则**:
|
||||
> 当用户意图排除文件类动态(上传文件、更新 office 文件等)或只看文档操作时,**必须在命令中带上 `--exclude-file`**,由后端完成过滤。禁止先拉全量数据再用 Python/jq 等工具在客户端过滤,那样既浪费带宽又可能因分页遗漏数据。
|
||||
|
||||
> **跨产品路由说明**:知识库节点的**内容操作**(读取/编辑/块级操作)仍由 `dws doc` 承担:
|
||||
>- 用户说"读某个知识库里的某篇文档" → 先 `node list` 拿到 nodeId,再走 **`dws doc read --node <nodeId>`**
|
||||
|
||||
@@ -44,6 +44,84 @@ func TestEvalDispatchWorkflowUsesRepositoryPermissionAndReviewedSHA(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvalDispatchWorkflowCanWritePRConversationComments(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatalf("resolve repository root: %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(root, ".github", "workflows", "eval-dispatch.yml"))
|
||||
if err != nil {
|
||||
t.Fatalf("read eval-dispatch workflow: %v", err)
|
||||
}
|
||||
workflow := string(data)
|
||||
|
||||
dispatchStart := strings.Index(workflow, "jobs:\n dispatch:")
|
||||
if dispatchStart < 0 {
|
||||
t.Fatal("eval-dispatch workflow missing dispatch job")
|
||||
}
|
||||
stepsOffset := strings.Index(workflow[dispatchStart:], "\n steps:")
|
||||
if stepsOffset < 0 {
|
||||
t.Fatal("eval-dispatch workflow missing dispatch steps")
|
||||
}
|
||||
permissions := workflow[dispatchStart : dispatchStart+stepsOffset]
|
||||
if !containsTrimmedLine(permissions, "pull-requests: write") {
|
||||
t.Error("dispatch job must grant write permission for PR conversation comments")
|
||||
}
|
||||
if containsTrimmedLine(permissions, "pull-requests: read") {
|
||||
t.Error("dispatch job still limits pull requests to read-only")
|
||||
}
|
||||
if containsTrimmedLine(permissions, "issues: write") {
|
||||
t.Error("dispatch job must keep a single write domain instead of granting issue-wide writes")
|
||||
}
|
||||
|
||||
commentWrites := []struct {
|
||||
step string
|
||||
method string
|
||||
endpoint string
|
||||
}{
|
||||
{
|
||||
step: "Reply usage on parse failure",
|
||||
method: "POST",
|
||||
endpoint: "issues/${PR_NUMBER}/comments",
|
||||
},
|
||||
{
|
||||
step: "Create dispatch placeholder",
|
||||
method: "POST",
|
||||
endpoint: "issues/${PR_NUMBER}/comments",
|
||||
},
|
||||
{
|
||||
step: "Finalize dispatch marker",
|
||||
method: "PATCH",
|
||||
endpoint: "issues/comments/${DISPATCH_COMMENT_ID}",
|
||||
},
|
||||
{
|
||||
step: "Mark dispatch preparation failure",
|
||||
method: "PATCH",
|
||||
endpoint: "issues/comments/${DISPATCH_COMMENT_ID}",
|
||||
},
|
||||
}
|
||||
for _, write := range commentWrites {
|
||||
write := write
|
||||
t.Run(write.step, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
step := evalDispatchWorkflowStep(t, workflow, write.step)
|
||||
if !strings.Contains(step, "gh api --method "+write.method) {
|
||||
t.Errorf("step %q must use gh api so GitHub's HTTP error message remains visible", write.step)
|
||||
}
|
||||
if !strings.Contains(step, write.endpoint) {
|
||||
t.Errorf("step %q missing comment endpoint %q", write.step, write.endpoint)
|
||||
}
|
||||
for _, forbidden := range []string{"curl --fail", "Authorization: Bearer", "2>/dev/null"} {
|
||||
if strings.Contains(step, forbidden) {
|
||||
t.Errorf("step %q hides or manually transports API diagnostics via %q", write.step, forbidden)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvalDispatchWorkflowPublishesArtifactBoundRequest(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -102,7 +180,7 @@ func TestEvalDispatchWorkflowPublishesArtifactBoundRequest(t *testing.T) {
|
||||
"ARTIFACT_ID: ${{ steps.artifact.outputs.artifact-id }}",
|
||||
"ARTIFACT_DIGEST: ${{ steps.artifact.outputs.artifact-digest }}",
|
||||
"<!-- eval-dispatch: ${marker_json} -->",
|
||||
"-X PATCH",
|
||||
"gh api --method PATCH",
|
||||
"issues/comments/${DISPATCH_COMMENT_ID}",
|
||||
} {
|
||||
if !strings.Contains(workflow, want) {
|
||||
@@ -120,6 +198,30 @@ func TestEvalDispatchWorkflowPublishesArtifactBoundRequest(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func evalDispatchWorkflowStep(t *testing.T, workflow, name string) string {
|
||||
t.Helper()
|
||||
|
||||
marker := " - name: " + name + "\n"
|
||||
start := strings.Index(workflow, marker)
|
||||
if start < 0 {
|
||||
t.Fatalf("eval-dispatch workflow missing step %q", name)
|
||||
}
|
||||
rest := workflow[start+len(marker):]
|
||||
if end := strings.Index(rest, "\n - name: "); end >= 0 {
|
||||
return rest[:end]
|
||||
}
|
||||
return rest
|
||||
}
|
||||
|
||||
func containsTrimmedLine(text, want string) bool {
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
if strings.TrimSpace(line) == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestEvalPollValidatePython(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user