feat(im): close shortcut capability gaps

This commit is contained in:
Dennis committed 2026-07-29 13:29:53 +08:00
1 parent 6ab01a365e
commit 41e0fb381a
28 files changed
+12101 -241

No files matched your search

@@ -17,7 +17,7 @@ import (
const (
publicShortcutCount = 265
schemaPublishedShortcutCount = 210
schemaPublishedShortcutCount = 215
)
func TestEmbeddedSchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) {
@@ -106,7 +106,7 @@ func TestEmbeddedShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T)
product := executeShortcutSchemaQuery(t, "chat")
productPayload, _ := product["product"].(map[string]any)
if got, want := int(product["count"].(float64)), 120; got != want {
if got, want := int(product["count"].(float64)), 125; got != want {
t.Fatalf("schema chat count = %d, want %d", got, want)
}
summaries := schemaContractObjectSlice(productPayload["tools"])
@@ -116,8 +116,8 @@ func TestEmbeddedShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T)
shortcutCount++
}
}
if shortcutCount != 42 {
t.Fatalf("schema chat shortcut summaries = %d, want 42", shortcutCount)
if shortcutCount != 47 {
t.Fatalf("schema chat shortcut summaries = %d, want 47", shortcutCount)
}
}
File diff suppressed because it is too large. Load diff
@@ -1,17 +1,17 @@
{
"version": 1,
"source_hash": "sha256:1fed2a8bc5328212fdf646c3e68d465a2a440b2d303c1ad120981e06c97da487",
"surface_hash": "sha256:4cf8460240b19f896c3a330c69982cbb5f57aa8576cdf30373172082eea893ed",
"source_hash": "sha256:ee1c5251720a1cefb87e97f98ee15bca1f929179c9d9a1c598ea5e0c7e281f95",
"surface_hash": "sha256:698ccdc961c0b745e6524e33ae13d87ef4fcac012a2e833c7a727a79d35004db",
"coverage": {
"surface_products": 26,
"products_with_metadata": 26,
"surface_tools": 813,
"tools_with_metadata": 813,
"tools_with_agent_summary": 813,
"tools_with_use_when": 813,
"tools_with_avoid_when": 813,
"tools_with_examples": 813,
"tools_with_interface_mode": 813,
"surface_tools": 818,
"tools_with_metadata": 818,
"tools_with_agent_summary": 818,
"tools_with_use_when": 818,
"tools_with_avoid_when": 818,
"tools_with_examples": 818,
"tools_with_interface_mode": 818,
"unmatched_skill_tools": 122,
"unreviewed_skill_tools": 11
},
+10 -10
View File
@@ -1,11 +1,11 @@
{
"version": 1,
"source_hash": "sha256:1fed2a8bc5328212fdf646c3e68d465a2a440b2d303c1ad120981e06c97da487",
"surface_hash": "sha256:4cf8460240b19f896c3a330c69982cbb5f57aa8576cdf30373172082eea893ed",
"source_hash": "sha256:ee1c5251720a1cefb87e97f98ee15bca1f929179c9d9a1c598ea5e0c7e281f95",
"surface_hash": "sha256:698ccdc961c0b745e6524e33ae13d87ef4fcac012a2e833c7a727a79d35004db",
"source_files": 160,
"hint_files": 54,
"hint_products": 43,
"hint_tools": 1778,
"hint_tools": 1788,
"interface_metadata": {
"source": "mcp-tools-list+cli-registry",
"revision": "4574f7022c32cf4c033e9b7b4156e2fec815fed8",
@@ -29,13 +29,13 @@
"coverage": {
"surface_products": 26,
"products_with_metadata": 26,
"surface_tools": 813,
"tools_with_metadata": 813,
"tools_with_agent_summary": 813,
"tools_with_use_when": 813,
"tools_with_avoid_when": 813,
"tools_with_examples": 813,
"tools_with_interface_mode": 813,
"surface_tools": 818,
"tools_with_metadata": 818,
"tools_with_agent_summary": 818,
"tools_with_use_when": 818,
"tools_with_avoid_when": 818,
"tools_with_examples": 818,
"tools_with_interface_mode": 818,
"unmatched_skill_tools": 122,
"unreviewed_skill_tools": 11
},
+142 -12
View File
@@ -1,17 +1,17 @@
{
"version": 1,
"surface_hash": "sha256:4cf8460240b19f896c3a330c69982cbb5f57aa8576cdf30373172082eea893ed",
"source_hash": "sha256:a933860d7efdc55d1868cb1efe8ff56c493a48cbffae246e2013f4394e70259a",
"surface_hash": "sha256:698ccdc961c0b745e6524e33ae13d87ef4fcac012a2e833c7a727a79d35004db",
"source_hash": "sha256:7e52ceb70627216cc13cdbad03cb5daec57c03d03cc7f854ed06cadfa984e015",
"catalog": {
"agent_metadata": {
"products_with_metadata": 26,
"source": "embedded-skill-metadata",
"source_hash": "sha256:1fed2a8bc5328212fdf646c3e68d465a2a440b2d303c1ad120981e06c97da487",
"surface_hash": "sha256:4cf8460240b19f896c3a330c69982cbb5f57aa8576cdf30373172082eea893ed",
"source_hash": "sha256:ee1c5251720a1cefb87e97f98ee15bca1f929179c9d9a1c598ea5e0c7e281f95",
"surface_hash": "sha256:698ccdc961c0b745e6524e33ae13d87ef4fcac012a2e833c7a727a79d35004db",
"surface_products": 26,
"surface_tools": 813,
"tools_with_agent_summary": 813,
"tools_with_metadata": 813,
"surface_tools": 818,
"tools_with_agent_summary": 818,
"tools_with_metadata": 818,
"unmatched_skill_tools": 122,
"version": 1
},
@@ -7722,7 +7722,7 @@
"id": "chat",
"name": "群聊 / 消息 / 机器人",
"runtime": true,
"tool_count": 120,
"tool_count": 125,
"tools": [
{
"agent_metadata_source": "embedded-skill-metadata",
@@ -9719,7 +9719,7 @@
"cli_name": "+at-me",
"cli_path": "chat +at-me",
"confirmation": "not_required",
"description": "当你想快速看回最近谁在群里或单聊里 @了你、但不想手动把起止时间换算成毫秒、也不想记 list-mentions 的一堆参数时使用;内部按本地时区算出「最近 N 天」(默认 7 天,可用 --days 调整回溯天数)的时间窗,搜索这段时间内 @我 的消息,再在本地把每条消息投影成发送人、时间、内容、所在会话四个关键字段。这是纯只读操作,只做搜索与本地投影,不会发送、撤回或标记任何消息。",
"description": "当你想快速看回最近谁在群里或单聊里 @了你、但不想手动把起止时间换算成毫秒、也不想记 list-mentions 的一堆参数时使用;内部按本地时区算出「最近 N 天」(默认 7 天,可用 --days 调整回溯天数)的时间窗,搜索这段时间内 @我 的消息,再在本地把每条消息投影成发送人、时间、内容、所在会话四个关键字段。这是纯只读操作,只做搜索与本地投影,不会发送、撤回或标记任何消息。\n\n参数约束:\n - --output-dir 必须是工作目录内的相对路径,不允许绝对路径或 .. 逃逸",
"effect": "read",
"idempotency": "idempotent",
"interface_mode": "composite",
@@ -10071,6 +10071,32 @@
"当你想查看自己作为群主或管理员在管理哪些群时使用;只读分页返回,可用 --role OWNER/ADMIN 按角色过滤。"
]
},
{
"agent_metadata_source": "embedded-skill-metadata",
"agent_summary": "拉取某个会话(群聊或单聊)的消息列表并投影出发言人/文本/时间",
"agent_summary_source": "dws-agent-selection/chat",
"availability": "available",
"avoid_when": [
"要跨多个会话按关键词、发送者或消息类型检索时使用 +search-msg;已有一批精确消息 ID 时使用 +messages-mget"
],
"canonical_path": "chat.shortcut_chat_messages",
"cli_name": "+chat-messages",
"cli_path": "chat +chat-messages",
"confirmation": "not_required",
"description": "当你想快速看某个会话里的消息(谁在什么时间说了什么),而不想拿到一大坨原始消息字段时使用;群聊传 --group(群会话 ID,openConversationId),单聊传 --user(对方 userId),两者互斥且必须二选一。省略 --time 时默认从当前时间向前读取最近消息;也可指定时间边界并用 --direction newer/older 控制方向。内部据此调用群聊或单聊的消息列表接口,再在本地投影出每条消息的发言人、文本和时间。这是纯只读操作,只做拉取与本地投影,不会发送或修改任何消息。\n\n参数约束:\n - --group、--conversation-id、--id、--user、--open-dingtalk-id 必须且只能指定一个\n - --output-dir 必须是工作目录内的相对路径,不允许绝对路径或 .. 逃逸",
"effect": "read",
"idempotency": "idempotent",
"interface_mode": "composite",
"interface_reason": "Reviewed built-in Shortcut adapter: it routes group or direct-message history reads, projects a stable message shape, and optionally orchestrates safe resource downloads with a failure ledger.",
"name": "shortcut_chat_messages",
"primary_cli_path": "chat +chat-messages",
"reviewed": true,
"risk": "low",
"title": "拉取某个会话(群聊或单聊)的消息列表并投影出发言人/文本/时间",
"use_when": [
"当你想快速看某个会话里的消息(谁在什么时间说了什么),而不想拿到一大坨原始消息字段时使用;群聊传 --group(群会话 ID,openConversationId),单聊传 --user(对方 userId),两者互斥且必须二选一。省略 --time 时默认从当前时间向前读取最近消息;也可指定时间边界并用 --direction newer/older 控制方向。内部据此调用群聊或单聊的消息列表接口,再在本地投影出每条消息的发言人、文本和时间。这是纯只读操作,只做拉取与本地投影,不会发送或修改任何消息。"
]
},
{
"agent_metadata_source": "embedded-skill-metadata",
"agent_summary": "全员禁言 / 取消全员禁言",
@@ -10603,7 +10629,7 @@
"cli_name": "+messages-mget",
"cli_path": "chat +messages-mget",
"confirmation": "not_required",
"description": "当你已有一批消息 openMsgId、需要批量取回完整详情、reaction 和可执行资源引用时使用;一次最多 50 条。--download-resources 可把所有可识别 mediaId 安全下载到工作目录内,并逐资源返回成功/失败 ledger。\n\n参数约束:\n - --msg-ids 去重后必须包含 1-50 条消息 ID\n - --output-dir 必须是工作目录内的相对路径,不允许绝对路径或 .. 逃逸",
"description": "当你已有一批消息 openMsgId、需要批量取回完整详情、reaction 和可执行资源引用时使用;一次最多 50 条。--download-resources 可把所有可识别 mediaId/fileId 安全下载到工作目录内,并逐资源返回成功/失败 ledger。\n\n参数约束:\n - --msg-ids 去重后必须包含 1-50 条消息 ID\n - --output-dir 必须是工作目录内的相对路径,不允许绝对路径或 .. 逃逸",
"effect": "read",
"idempotency": "idempotent",
"interface_mode": "composite",
@@ -10614,7 +10640,7 @@
"risk": "low",
"title": "根据消息 ID 批量查询消息(最多 50 条)",
"use_when": [
"当你已有一批消息 openMsgId、需要批量取回完整详情、reaction 和可执行资源引用时使用;一次最多 50 条。--download-resources 可把所有可识别 mediaId 安全下载到工作目录内,并逐资源返回成功/失败 ledger。"
"当你已有一批消息 openMsgId、需要批量取回完整详情、reaction 和可执行资源引用时使用;一次最多 50 条。--download-resources 可把所有可识别 mediaId/fileId 安全下载到工作目录内,并逐资源返回成功/失败 ledger。"
]
},
{
@@ -10669,6 +10695,32 @@
"当你想知道自己发出的某条消息有哪些人已读/未读时使用;只读,需传会话 openConversationId 和该消息 openMessageId,可指定目标成员列表。"
]
},
{
"agent_metadata_source": "embedded-skill-metadata",
"agent_summary": "统一发送文本、Markdown、当前用户文件或已有 mediaId 图片",
"agent_summary_source": "dws-agent-selection/chat",
"availability": "available",
"avoid_when": [
"需要 bot/webhook 发送媒体、卡片或 thread 回复时不要假设等价支持;改用真实存在的专用下层命令,缺少下层能力时停止"
],
"canonical_path": "chat.shortcut_messages_send",
"cli_name": "+messages-send",
"cli_path": "chat +messages-send",
"confirmation": "user_required",
"description": "当你希望用同一个入口选择 current-user、bot 或 webhook 身份发送消息时使用;命令会按身份校验目标、内容和凭据并路由到真实下层。current-user 支持文本/Markdown、已有 mediaId 图片、安全相对路径文件上传和幂等键;bot 支持群聊或批量单聊文本/Markdown;webhook 的目标由 token 所在群决定。不会把 user 文件能力伪装成 bot/webhook 等价能力。\n\n参数约束:\n - --text、--markdown、--media-id、--file、--file-path 至少指定一个\n - --text、--markdown、--media-id、--file、--file-path 互斥,最多指定一个\n - --identity、--as 互斥,最多指定一个\n - --group、--chat-id 互斥,最多指定一个\n - --user、--open-dingtalk-id 互斥,最多指定一个\n - --uuid、--idempotency-key 互斥,最多指定一个\n - 身份相关目标、内容和凭据不能为空,完整能力矩阵见命令说明",
"effect": "write",
"idempotency": "unknown",
"interface_mode": "composite",
"interface_reason": "Reviewed composite send adapter: it selects current-user, bot, or webhook transport; current-user additionally supports userId resolution, mediaId images, and the native init/upload/commit local-file flow.",
"name": "shortcut_messages_send",
"primary_cli_path": "chat +messages-send",
"reviewed": true,
"risk": "medium",
"title": "统一发送文本、Markdown、当前用户文件或已有 mediaId 图片",
"use_when": [
"当你希望用同一个入口选择 current-user、bot 或 webhook 身份发送消息时使用;命令会按身份校验目标、内容和凭据并路由到真实下层。current-user 支持文本/Markdown、已有 mediaId 图片、安全相对路径文件上传和幂等键;bot 支持群聊或批量单聊文本/Markdown;webhook 的目标由 token 所在群决定。不会把 user 文件能力伪装成 bot/webhook 等价能力。"
]
},
{
"agent_metadata_source": "embedded-skill-metadata",
"agent_summary": "自定义机器人 Webhook 发送群消息",
@@ -10695,6 +10747,32 @@
"当你只有自定义机器人的 Webhook token、想往其所在群推送消息时使用;会实际通过 Webhook 发群消息,需传 token、标题、正文,可 @手机号/userId 或 @所有人。"
]
},
{
"agent_metadata_source": "embedded-skill-metadata",
"agent_summary": "创建流式卡片,可在同一次调用中写入内容并结束",
"agent_summary_source": "dws-agent-selection/chat",
"availability": "available",
"avoid_when": [
"已有 bizId、只需要追加或更新现有卡片内容时使用 +messages-update-card"
],
"canonical_path": "chat.shortcut_messages_send_card",
"cli_name": "+messages-send-card",
"cli_path": "chat +messages-send-card",
"confirmation": "user_required",
"description": "当你要发送一张流式卡片消息时使用;群 openConversationId 或单聊接收者 userId 二选一。只传目标时创建卡片并返回 bizId,供后续 messages-update-card 流式更新;同时传 --content 时会自动串联创建和更新,默认以 flowStatus=3 完成,避免卡片停留在加载中。\n\n参数约束:\n - --group、--receiver 必须且只能指定一个\n - --flow-status 必须在 1-5 之间,且显式指定时必须同时提供 --content",
"effect": "write",
"idempotency": "unknown",
"interface_mode": "composite",
"interface_reason": "Reviewed card lifecycle adapter: it can call create_and_send_card alone or compose it with update_streaming_card after extracting the returned bizId.",
"name": "shortcut_messages_send_card",
"primary_cli_path": "chat +messages-send-card",
"reviewed": true,
"risk": "medium",
"title": "创建流式卡片,可在同一次调用中写入内容并结束",
"use_when": [
"当你要发送一张流式卡片消息时使用;群 openConversationId 或单聊接收者 userId 二选一。只传目标时创建卡片并返回 bizId,供后续 messages-update-card 流式更新;同时传 --content 时会自动串联创建和更新,默认以 flowStatus=3 完成,避免卡片停留在加载中。"
]
},
{
"agent_metadata_source": "embedded-skill-metadata",
"agent_summary": "流式更新卡片内容(最后一次 --flow-status 应为 3)",
@@ -10747,6 +10825,32 @@
"当你想快速看一眼自己都加入了哪些群、以及每个群的会话ID、名称、群主和人数,而不想翻分页或盯着原始返回时使用;内部分页拉取你加入的群列表,把每个群防御式地投影成 会话id / 名称 / 群主 / 人数 / 类型 等关键字段,输出成干净的结果。可选 --type 在本地按群类型过滤(底层接口本身不带类型参数,故为客户端过滤)。这是只读操作,不会改动任何群或成员关系。"
]
},
{
"agent_metadata_source": "embedded-skill-metadata",
"agent_summary": "多维搜索消息,可全量翻页并批量富化详情",
"agent_summary_source": "dws-agent-selection/chat",
"availability": "available",
"avoid_when": [
"只想读取一个已知会话的连续历史时使用 +chat-messages;已有精确消息 ID 时使用 +messages-mget"
],
"canonical_path": "chat.shortcut_search_msg",
"cli_name": "+search-msg",
"cli_path": "chat +search-msg",
"confirmation": "not_required",
"description": "当你要按关键词、发送者、@对象、会话、消息类型或机器人来源组合搜索 IM 消息时使用;默认查询近 7 天,也可指定精确起止时间。--page-all 会连续拉取游标页,默认再按消息 ID 分批富化详情;任何续页或富化失败都会保留已取得结果并返回逐项失败 ledger,绝不把截断结果标成完整。\n\n参数约束:\n - 至少指定一个内容、身份、会话或消息类型过滤条件\n - 需与 --end 一起传\n - 需与 --start 一起传\n - --groups、--chat-id 互斥,最多指定一个\n - --senders、--sender 互斥,最多指定一个\n - --at-me、--is-at-me 互斥,最多指定一个\n - --conversation-type、--chat-type 互斥,最多指定一个\n - --limit、--page-size 互斥,最多指定一个\n - --cursor、--page-token 互斥,最多指定一个\n - --output-dir 必须是工作目录内的相对路径,不允许绝对路径或 .. 逃逸",
"effect": "read",
"idempotency": "idempotent",
"interface_mode": "composite",
"interface_reason": "Reviewed search adapter: it combines filters, cursor pagination, batched mget enrichment, stable projection, completeness accounting, and optional safe resource downloads.",
"name": "shortcut_search_msg",
"primary_cli_path": "chat +search-msg",
"reviewed": true,
"risk": "low",
"title": "多维搜索消息,可全量翻页并批量富化详情",
"use_when": [
"当你要按关键词、发送者、@对象、会话、消息类型或机器人来源组合搜索 IM 消息时使用;默认查询近 7 天,也可指定精确起止时间。--page-all 会连续拉取游标页,默认再按消息 ID 分批富化详情;任何续页或富化失败都会保留已取得结果并返回逐项失败 ledger,绝不把截断结果标成完整。"
]
},
{
"agent_metadata_source": "embedded-skill-metadata",
"agent_summary": "按群名直接给群发消息(自动搜群解析 openConversationId)",
@@ -10773,6 +10877,32 @@
"当你只知道群的名字、想直接往这个群里发一条消息而不想先手动查群 ID 时使用;内部先按群名搜索群聊解析出唯一 openConversationId 再发送,群名匹配到多个群时会列出候选让你区分、绝不自行假定。会真实发出群消息。"
]
},
{
"agent_metadata_source": "embedded-skill-metadata",
"agent_summary": "拉取某条话题消息的全部回复并投影出发言人/文本/时间",
"agent_summary_source": "dws-agent-selection/chat",
"availability": "available",
"avoid_when": [
"要回复 Thread 或发送新回复时不要使用此只读入口;当前没有经过验证的 thread writer Shortcut"
],
"canonical_path": "chat.shortcut_thread_replies",
"cli_name": "+thread-replies",
"cli_path": "chat +thread-replies",
"confirmation": "not_required",
"description": "当你已经拿到某个群里一条「话题消息」的 threadId/topicId、想快速看这条话题下的全部回复(谁在什么时间回复了什么),而不想拿到一大坨原始消息字段时使用;内部按 --group(群会话 ID)和 --thread-id(兼容 --topic-id)拉取该话题的回复列表,可选 --time 指定起始时间、--limit 指定每页条数,再在本地投影出每条回复的发言人、文本和回复时间。这是纯只读操作,只做拉取与本地投影,不会发送或修改任何消息。\n\n参数约束:\n - --thread-id 与兼容参数 --topic-id 必须且只能指定一个\n - --output-dir 必须是工作目录内的相对路径,不允许绝对路径或 .. 逃逸",
"effect": "read",
"idempotency": "idempotent",
"interface_mode": "composite",
"interface_reason": "Reviewed thread reader adapter: it accepts threadId/topicId, reads lower topic replies, projects stable message fields, and optionally downloads reply resources safely.",
"name": "shortcut_thread_replies",
"primary_cli_path": "chat +thread-replies",
"reviewed": true,
"risk": "low",
"title": "拉取某条话题消息的全部回复并投影出发言人/文本/时间",
"use_when": [
"当你已经拿到某个群里一条「话题消息」的 threadId/topicId、想快速看这条话题下的全部回复(谁在什么时间回复了什么),而不想拿到一大坨原始消息字段时使用;内部按 --group(群会话 ID)和 --thread-id(兼容 --topic-id)拉取该话题的回复列表,可选 --time 指定起始时间、--limit 指定每页条数,再在本地投影出每条回复的发言人、文本和回复时间。这是纯只读操作,只做拉取与本地投影,不会发送或修改任何消息。"
]
},
{
"agent_metadata_source": "embedded-skill-metadata",
"agent_summary": "列出我有未读消息的会话(投影会话名/未读数/会话ID)",
@@ -25785,6 +25915,6 @@
}
],
"source": "embedded-command-catalog",
"tool_count": 813
"tool_count": 818
}
}
File diff suppressed because it is too large. Load diff
+1 -6
View File
@@ -168,7 +168,6 @@
"chat +chat-get-by-id",
"chat +chat-members-get",
"chat +chat-members-list",
"chat +chat-messages",
"chat +chat-mute-member",
"chat +chat-quit",
"chat +chat-remove-bot",
@@ -205,15 +204,11 @@
"chat +messages-reply",
"chat +messages-resource-download",
"chat +messages-resource-url",
"chat +messages-send",
"chat +messages-send-by-bot",
"chat +messages-send-card",
"chat +messages-set-pin",
"chat +messages-set-top",
"chat +messages-unset-pin",
"chat +messages-unset-top",
"chat +search-msg",
"chat +thread-replies"
"chat +messages-unset-top"
]
}
]
@@ -421,10 +421,22 @@
"canonical_path": "chat.shortcut_chat_role_query_user",
"cli_path": "chat +chat-role-query-user"
},
{
"canonical_path": "chat.shortcut_chat_messages",
"cli_path": "chat +chat-messages"
},
{
"canonical_path": "chat.shortcut_messages_send",
"cli_path": "chat +messages-send"
},
{
"canonical_path": "chat.shortcut_messages_send_by_webhook",
"cli_path": "chat +messages-send-by-webhook"
},
{
"canonical_path": "chat.shortcut_messages_send_card",
"cli_path": "chat +messages-send-card"
},
{
"canonical_path": "chat.shortcut_messages_list_direct",
"cli_path": "chat +messages-list-direct"
@@ -473,10 +485,18 @@
"canonical_path": "chat.shortcut_my_groups",
"cli_path": "chat +my-groups"
},
{
"canonical_path": "chat.shortcut_search_msg",
"cli_path": "chat +search-msg"
},
{
"canonical_path": "chat.shortcut_send_to_group",
"cli_path": "chat +send-to-group"
},
{
"canonical_path": "chat.shortcut_thread_replies",
"cli_path": "chat +thread-replies"
},
{
"canonical_path": "chat.shortcut_unread_chats",
"cli_path": "chat +unread-chats"
@@ -1152,6 +1152,32 @@
"cli_path": "chat +chat-role-query-user",
"runtime_gate": "none"
},
"chat.shortcut_chat_messages": {
"effect": "read",
"risk": "low",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed built-in Shortcut adapter: it routes group or direct-message history reads, projects a stable message shape, and optionally orchestrates safe resource downloads with a failure ledger.",
"reviewed": true,
"review_reason": "Reviewed against the executable Shortcut, group/direct lower read tools, shared safe-output validation, resource de-duplication, and per-resource failure ledger.",
"cli_path": "chat +chat-messages",
"runtime_gate": "none"
},
"chat.shortcut_messages_send": {
"effect": "write",
"risk": "medium",
"confirmation": "user_required",
"idempotency": "unknown",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed composite send adapter: it selects current-user, bot, or webhook transport; current-user additionally supports userId resolution, mediaId images, and the native init/upload/commit local-file flow.",
"reviewed": true,
"review_reason": "Reviewed against every executable identity/content/target validation branch and the real lower tool mappings; bot and webhook media are intentionally rejected because no equivalent lower capability exists.",
"cli_path": "chat +messages-send",
"runtime_gate": "typed_yes"
},
"chat.shortcut_messages_send_by_webhook": {
"effect": "write",
"risk": "medium",
@@ -1165,6 +1191,19 @@
"cli_path": "chat +messages-send-by-webhook",
"runtime_gate": "typed_yes"
},
"chat.shortcut_messages_send_card": {
"effect": "write",
"risk": "medium",
"confirmation": "user_required",
"idempotency": "unknown",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed card lifecycle adapter: it can call create_and_send_card alone or compose it with update_streaming_card after extracting the returned bizId.",
"reviewed": true,
"review_reason": "Reviewed against create-only, two-action dry-run, create/update success, missing bizId, and partial-failure behavior that preserves the created bizId.",
"cli_path": "chat +messages-send-card",
"runtime_gate": "typed_yes"
},
"chat.shortcut_messages_list_direct": {
"effect": "read",
"risk": "low",
@@ -1321,6 +1360,19 @@
"cli_path": "chat +my-groups",
"runtime_gate": "none"
},
"chat.shortcut_search_msg": {
"effect": "read",
"risk": "low",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed search adapter: it combines filters, cursor pagination, batched mget enrichment, stable projection, completeness accounting, and optional safe resource downloads.",
"reviewed": true,
"review_reason": "Reviewed against the executable advanced-search flags, page and enrichment failure ledgers, shared safe-output validation, and optional per-resource download ledger.",
"cli_path": "chat +search-msg",
"runtime_gate": "none"
},
"chat.shortcut_send_to_group": {
"effect": "write",
"risk": "medium",
@@ -1334,6 +1386,19 @@
"cli_path": "chat +send-to-group",
"runtime_gate": "typed_yes"
},
"chat.shortcut_thread_replies": {
"effect": "read",
"risk": "low",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed thread reader adapter: it accepts threadId/topicId, reads lower topic replies, projects stable message fields, and optionally downloads reply resources safely.",
"reviewed": true,
"review_reason": "Reviewed against the executable thread/topic alias contract, real list_topic_replies mapping, group fallback for resource context, and per-resource failure ledger.",
"cli_path": "chat +thread-replies",
"runtime_gate": "none"
},
"chat.shortcut_unread_chats": {
"effect": "read",
"risk": "low",
@@ -7,7 +7,7 @@
"channel": "open-source"
},
"coverage": {
"source_tools": 813,
"source_tools": 818,
"matched_tools": 71
},
"tools": {
+101 -1
View File
@@ -2110,6 +2110,46 @@
"ShortcutRegistry:chat +chat-role-query-user"
]
},
"chat.shortcut_chat_messages": {
"agent_summary": "拉取某个会话(群聊或单聊)的消息列表并投影出发言人/文本/时间",
"use_when": [
"当你想快速看某个会话里的消息(谁在什么时间说了什么),而不想拿到一大坨原始消息字段时使用;群聊传 --group(群会话 ID,openConversationId),单聊传 --user(对方 userId),两者互斥且必须二选一。省略 --time 时默认从当前时间向前读取最近消息;也可指定时间边界并用 --direction newer/older 控制方向。内部据此调用群聊或单聊的消息列表接口,再在本地投影出每条消息的发言人、文本和时间。这是纯只读操作,只做拉取与本地投影,不会发送或修改任何消息。"
],
"avoid_when": [
"要跨多个会话按关键词、发送者或消息类型检索时使用 +search-msg;已有一批精确消息 ID 时使用 +messages-mget"
],
"examples": [
"dws chat +chat-messages --group <openConversationId> --direction older",
"dws chat +chat-messages --open-dingtalk-id <openDingTalkId> --download-resources --output-dir ./downloads"
],
"reviewed": true,
"review_reason": "Agent-authored from the verified group/direct routing, stable projection, pagination, and optional safe resource workflow; it distinguishes conversation history from cross-conversation search and exact-ID mget.",
"source_refs": [
"internal/cli/schema_command_registry.json#chat.shortcut_chat_messages",
"cobra-help:dws chat +chat-messages",
"ShortcutRegistry:chat +chat-messages"
]
},
"chat.shortcut_messages_send": {
"agent_summary": "统一发送文本、Markdown、当前用户文件或已有 mediaId 图片",
"use_when": [
"当你希望用同一个入口选择 current-user、bot 或 webhook 身份发送消息时使用;命令会按身份校验目标、内容和凭据并路由到真实下层。current-user 支持文本/Markdown、已有 mediaId 图片、安全相对路径文件上传和幂等键;bot 支持群聊或批量单聊文本/Markdown;webhook 的目标由 token 所在群决定。不会把 user 文件能力伪装成 bot/webhook 等价能力。"
],
"avoid_when": [
"需要 bot/webhook 发送媒体、卡片或 thread 回复时不要假设等价支持;改用真实存在的专用下层命令,缺少下层能力时停止"
],
"examples": [
"dws chat +messages-send --as user --chat-id <openConversationId> --markdown \"## 周报\" --idempotency-key <key>",
"dws chat +messages-send --as user --user <userId> --msg-type file --file ./report.pdf"
],
"reviewed": true,
"review_reason": "Agent-authored from the verified identity/content capability matrix, userId resolution, native local-file upload flow, and fail-closed rejection of unsupported bot/webhook media.",
"source_refs": [
"internal/cli/schema_command_registry.json#chat.shortcut_messages_send",
"cobra-help:dws chat +messages-send",
"ShortcutRegistry:chat +messages-send"
]
},
"chat.shortcut_messages_send_by_webhook": {
"agent_summary": "自定义机器人 Webhook 发送群消息",
"use_when": [
@@ -2129,6 +2169,26 @@
"ShortcutRegistry:chat +messages-send-by-webhook"
]
},
"chat.shortcut_messages_send_card": {
"agent_summary": "创建流式卡片,可在同一次调用中写入内容并结束",
"use_when": [
"当你要发送一张流式卡片消息时使用;群 openConversationId 或单聊接收者 userId 二选一。只传目标时创建卡片并返回 bizId,供后续 messages-update-card 流式更新;同时传 --content 时会自动串联创建和更新,默认以 flowStatus=3 完成,避免卡片停留在加载中。"
],
"avoid_when": [
"已有 bizId、只需要追加或更新现有卡片内容时使用 +messages-update-card"
],
"examples": [
"dws chat +messages-send-card --group <openConversationId> --content \"任务已完成\"",
"dws chat +messages-send-card --receiver <userId>"
],
"reviewed": true,
"review_reason": "Agent-authored from the verified create-only and create-then-update lifecycle, two-action dry-run, bizId extraction, and partial-failure reporting.",
"source_refs": [
"internal/cli/schema_command_registry.json#chat.shortcut_messages_send_card",
"cobra-help:dws chat +messages-send-card",
"ShortcutRegistry:chat +messages-send-card"
]
},
"chat.shortcut_messages_list_direct": {
"agent_summary": "拉取单聊会话消息",
"use_when": [
@@ -2170,7 +2230,7 @@
"chat.shortcut_messages_mget": {
"agent_summary": "根据消息 ID 批量查询消息(最多 50 条)",
"use_when": [
"当你已有一批消息 openMsgId、需要批量取回完整详情、reaction 和可执行资源引用时使用;一次最多 50 条。--download-resources 可把所有可识别 mediaId 安全下载到工作目录内,并逐资源返回成功/失败 ledger。"
"当你已有一批消息 openMsgId、需要批量取回完整详情、reaction 和可执行资源引用时使用;一次最多 50 条。--download-resources 可把所有可识别 mediaId/fileId 安全下载到工作目录内,并逐资源返回成功/失败 ledger。"
],
"avoid_when": [
"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"
@@ -2359,6 +2419,26 @@
"ShortcutRegistry:chat +my-groups"
]
},
"chat.shortcut_search_msg": {
"agent_summary": "多维搜索消息,可全量翻页并批量富化详情",
"use_when": [
"当你要按关键词、发送者、@对象、会话、消息类型或机器人来源组合搜索 IM 消息时使用;默认查询近 7 天,也可指定精确起止时间。--page-all 会连续拉取游标页,默认再按消息 ID 分批富化详情;任何续页或富化失败都会保留已取得结果并返回逐项失败 ledger,绝不把截断结果标成完整。"
],
"avoid_when": [
"只想读取一个已知会话的连续历史时使用 +chat-messages;已有精确消息 ID 时使用 +messages-mget"
],
"examples": [
"dws chat +search-msg --query \"周报\" --senders <openDingTalkId> --days 3 --page-all",
"dws chat +search-msg --group <openConversationId> --message-type file --download-resources --output-dir ./downloads"
],
"reviewed": true,
"review_reason": "Agent-authored from the verified filter mapping, cursor pagination, batched mget enrichment, completeness ledger, and shared safe resource workflow.",
"source_refs": [
"internal/cli/schema_command_registry.json#chat.shortcut_search_msg",
"cobra-help:dws chat +search-msg",
"ShortcutRegistry:chat +search-msg"
]
},
"chat.shortcut_send_to_group": {
"agent_summary": "按群名直接给群发消息(自动搜群解析 openConversationId)",
"use_when": [
@@ -2378,6 +2458,26 @@
"ShortcutRegistry:chat +send-to-group"
]
},
"chat.shortcut_thread_replies": {
"agent_summary": "拉取某条话题消息的全部回复并投影出发言人/文本/时间",
"use_when": [
"当你已经拿到某个群里一条「话题消息」的 threadId/topicId、想快速看这条话题下的全部回复(谁在什么时间回复了什么),而不想拿到一大坨原始消息字段时使用;内部按 --group(群会话 ID)和 --thread-id(兼容 --topic-id)拉取该话题的回复列表,可选 --time 指定起始时间、--limit 指定每页条数,再在本地投影出每条回复的发言人、文本和回复时间。这是纯只读操作,只做拉取与本地投影,不会发送或修改任何消息。"
],
"avoid_when": [
"要回复 Thread 或发送新回复时不要使用此只读入口;当前没有经过验证的 thread writer Shortcut"
],
"examples": [
"dws chat +thread-replies --group <openConversationId> --thread-id <threadId>",
"dws chat +thread-replies --group <openConversationId> --topic-id <topicId> --download-resources --output-dir ./downloads"
],
"reviewed": true,
"review_reason": "Agent-authored from the verified thread/topic alias, lower list_topic_replies mapping, stable projection, group-context fallback, and optional safe resource workflow.",
"source_refs": [
"internal/cli/schema_command_registry.json#chat.shortcut_thread_replies",
"cobra-help:dws chat +thread-replies",
"ShortcutRegistry:chat +thread-replies"
]
},
"chat.shortcut_unread_chats": {
"agent_summary": "列出我有未读消息的会话(投影会话名/未读数/会话ID)",
"use_when": [
+43
View File
@@ -0,0 +1,43 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
package helpers
import "context"
// ConversationLocalFileMeta exposes the already-reviewed native chat upload
// metadata to built-in semantic Shortcuts. It remains an alias so the native
// Cobra leaf and Shortcut use exactly the same upload implementation.
type ConversationLocalFileMeta = conversationLocalFileMeta
// BuildConversationLocalFileMeta validates a local file and computes the
// metadata required by DingTalk's conversation-file upload flow.
func BuildConversationLocalFileMeta(filePath, fileName, md5Value string) (ConversationLocalFileMeta, error) {
return buildConversationLocalFileMeta(filePath, fileName, md5Value)
}
// UploadConversationLocalFile executes the existing init -> HTTP upload ->
// commit flow and returns the commit response for message-content assembly.
func UploadConversationLocalFile(
ctx context.Context,
targetArgs map[string]any,
meta ConversationLocalFileMeta,
uuid string,
) (string, error) {
return uploadConversationLocalFile(ctx, targetArgs, meta, uuid)
}
// ParseConversationFileSendIDs extracts the committed dentry and space IDs.
func ParseConversationFileSendIDs(text string) (int64, int64, error) {
return parseConversationFileSendIDs(text)
}
// BuildConversationFileContent renders the exact file-message content accepted
// by send_personal_message.
func BuildConversationFileContent(
dentryID, spaceID int64,
meta ConversationLocalFileMeta,
) (string, error) {
return buildConversationFileContent(dentryID, spaceID, meta)
}
+87
View File
@@ -0,0 +1,87 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package helpers
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
)
func TestCrossPlatformCoverageConversationFileShortcutWrappersReuseNativeFlow(t *testing.T) {
file := filepath.Join(t.TempDir(), "report.txt")
if err := os.WriteFile(file, []byte("content"), 0o600); err != nil {
t.Fatal(err)
}
meta, err := BuildConversationLocalFileMeta(file, "", "")
if err != nil {
t.Fatal(err)
}
if meta.FileName != "report.txt" || meta.FileSize != 7 {
t.Fatalf("meta = %#v", meta)
}
content, err := BuildConversationFileContent(11, 22, meta)
if err != nil || !strings.Contains(content, `"dentryId":11`) {
t.Fatalf("content = %q, %v", content, err)
}
dentryID, spaceID, err := ParseConversationFileSendIDs(`{"result":{"dentryId":11,"spaceId":22}}`)
if err != nil || dentryID != 11 || spaceID != 22 {
t.Fatalf("ids = %d/%d, %v", dentryID, spaceID, err)
}
oldPut := httpPutFile
t.Cleanup(func() { httpPutFile = oldPut })
var putPath string
httpPutFile = func(_ context.Context, _ string, _ map[string]string, localPath string, size int64) error {
putPath = localPath
if size != 7 {
t.Errorf("upload size = %d", size)
}
return nil
}
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"resourceUrl":"https://upload.example.test/file","uploadKey":"key"}`},
{text: `{"result":{"dentryId":11,"spaceId":22}}`},
}}
installScriptedCaller(t, caller)
commit, err := UploadConversationLocalFile(
context.Background(),
map[string]any{"openConversationId": "cid"},
meta,
"uuid",
)
if err != nil || !strings.Contains(commit, `"dentryId":11`) || putPath != file || caller.calls != 2 {
t.Fatalf("upload = %q, path=%q calls=%d err=%v", commit, putPath, caller.calls, err)
}
}
func TestCrossPlatformCoverageReadToolNameContractAndHelperBoundary(t *testing.T) {
for tool, want := range map[string]bool{
" get_conversation ": true,
"LIST_MESSAGES": true,
"query_send_status": true,
"search_messages": true,
"unread_message_conversation_list": true,
"send_personal_message": false,
"": false,
} {
if got := IsReadToolName(tool); got != want {
t.Errorf("IsReadToolName(%q) = %v, want %v", tool, got, want)
}
}
caller := &helpersReadCaller{
helpersCoreCaller: &helpersCoreCaller{format: "json", dry: true},
readResult: textToolResult(`{"success":true}`),
}
installHelpersCoreDeps(t, caller)
if _, err := CallMCPReadToolTextOnServer("chat", "send_personal_message", nil); err == nil {
t.Fatal("write tool was accepted by the read helper boundary")
}
if caller.readCalls != 0 || caller.calls != 0 {
t.Fatalf("rejected write reached caller: read=%d regular=%d", caller.readCalls, caller.calls)
}
}
+22
View File
@@ -156,6 +156,12 @@ func callMCPToolReturnTextOnServer(ctx context.Context, serverID, toolName strin
// returning a synthetic dry-run envelope that looks like business data.
func CallMCPReadToolTextOnServer(serverID, toolName string, args map[string]any) (string, error) {
ctx := context.Background()
if !IsReadToolName(toolName) {
return "", &CLIError{
Code: CodeMCPToolError,
Message: fmt.Sprintf("tool %q is not allowed on the dry-run read channel", toolName),
}
}
if deps == nil || deps.Caller == nil {
return "", &CLIError{
Code: CodeMCPToolError,
@@ -176,6 +182,22 @@ func CallMCPReadToolTextOnServer(serverID, toolName string, args map[string]any)
return parseMCPToolTextResult(serverID, toolName, result, err)
}
// IsReadToolName is the fail-closed naming contract for the dry-run read
// channel. Both the Shortcut runtime and the helper boundary enforce it so a
// future direct helper caller cannot accidentally route a write tool through
// ReadToolCaller.
func IsReadToolName(toolName string) bool {
toolName = strings.TrimSpace(strings.ToLower(toolName))
for _, prefix := range []string{
"get_", "list_", "query_", "search_", "unread_",
} {
if strings.HasPrefix(toolName, prefix) {
return true
}
}
return false
}
func parseMCPToolTextResult(serverID, toolName string, result *edition.ToolResult, err error) (string, error) {
if err != nil {
if patErr := reclassifyPATFromError(err); patErr != nil {
+261 -70
View File
@@ -14,6 +14,7 @@
package chat
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
@@ -525,39 +526,26 @@ var MessagesMget = shortcut.Shortcut{
Command: "+messages-mget",
Product: "im",
Description: "根据消息 ID 批量查询消息(最多 50 条)",
Intent: "当你已有一批消息 openMsgId、需要批量取回完整详情、reaction 和可执行资源引用时使用;一次最多 50 条。--download-resources 可把所有可识别 mediaId 安全下载到工作目录内,并逐资源返回成功/失败 ledger。",
Intent: "当你已有一批消息 openMsgId、需要批量取回完整详情、reaction 和可执行资源引用时使用;一次最多 50 条。--download-resources 可把所有可识别 mediaId/fileId 安全下载到工作目录内,并逐资源返回成功/失败 ledger。",
Risk: shortcut.RiskRead,
Flags: []shortcut.Flag{
Flags: append([]shortcut.Flag{
{Name: "msg-ids", Type: shortcut.FlagStringSlice, Desc: "消息 openMsgId 列表;--msg-ids 去重后必须包含 1-50 条消息 ID", Required: true},
{Name: "no-reactions", Type: shortcut.FlagBool, Desc: "不输出消息 reaction(默认输出)"},
{Name: "download-resources", Type: shortcut.FlagBool, Desc: "自动下载消息中的全部可识别 mediaId 资源"},
{Name: "output-dir", Type: shortcut.FlagString, Default: "./downloads", Desc: "资源输出目录;--output-dir 必须是工作目录内的相对路径,不允许绝对路径或 .. 逃逸"},
{Name: "overwrite", Type: shortcut.FlagBool, Desc: "允许覆盖同名资源文件(默认拒绝)"},
},
Constraints: []shortcut.Constraint{
}, MessageResourceDownloadFlags()...),
Constraints: append([]shortcut.Constraint{
{
Kind: shortcut.ConstraintCustom,
Flags: []string{"msg-ids"},
Description: "--msg-ids 去重后必须包含 1-50 条消息 ID",
},
{
Kind: shortcut.ConstraintCustom,
Flags: []string{"output-dir"},
Description: "--output-dir 必须是工作目录内的相对路径,不允许绝对路径或 .. 逃逸",
},
},
}, MessageResourceDownloadConstraints()...),
Tips: []string{`dws chat +messages-mget --msg-ids msgId1,msgId2`},
Validate: func(rt *shortcut.RuntimeContext) error {
ids := uniqueShortcutStrings(rt.StrSlice("msg-ids"))
if len(ids) < 1 || len(ids) > 50 {
return fmt.Errorf("--msg-ids 去重后必须包含 1-50 条消息 ID,当前 %d 条", len(ids))
}
if rt.Bool("download-resources") {
if err := validateResourceDownloadOutputFlag(rt.Str("output-dir"), "--output-dir"); err != nil {
return err
}
}
return nil
return ValidateMessageResourceDownload(rt)
},
Execute: func(rt *shortcut.RuntimeContext) error {
ids := uniqueShortcutStrings(rt.StrSlice("msg-ids"))
@@ -587,81 +575,179 @@ var MessagesMget = shortcut.Shortcut{
"messages": messages,
}
if rt.Bool("download-resources") {
ledger, err := downloadMgetResources(rt, rawMessages)
if err != nil {
return err
}
payload["resourceDownloads"] = ledger
payload["resourceDownloads"] = DownloadMessageResources(rt, rawMessages, "")
}
return rt.Output(payload)
},
}
func downloadMgetResources(rt *shortcut.RuntimeContext, messages []map[string]any) (map[string]any, error) {
// MessageResourceDownloadFlags returns the common opt-in resource workflow used
// by message list, search, mget, @me and thread-reading Shortcuts.
func MessageResourceDownloadFlags() []shortcut.Flag {
return []shortcut.Flag{
{Name: "download-resources", Type: shortcut.FlagBool, Desc: "自动下载消息中的全部可识别 mediaId/fileId 资源"},
{Name: "output-dir", Type: shortcut.FlagString, Default: "./downloads", Desc: "资源输出目录;必须是工作目录内的相对路径,禁止绝对路径和 .. 逃逸"},
{Name: "overwrite", Type: shortcut.FlagBool, Desc: "允许覆盖同名资源文件(默认拒绝)"},
}
}
// MessageResourceDownloadConstraints publishes the shared safe-output rule.
func MessageResourceDownloadConstraints() []shortcut.Constraint {
return []shortcut.Constraint{{
Kind: shortcut.ConstraintCustom,
Flags: []string{"output-dir"},
Description: "--output-dir 必须是工作目录内的相对路径,不允许绝对路径或 .. 逃逸",
}}
}
// ValidateMessageResourceDownload validates the output path only when the
// caller opts into local writes.
func ValidateMessageResourceDownload(rt *shortcut.RuntimeContext) error {
if !rt.Bool("download-resources") {
return nil
}
return validateResourceDownloadOutputFlag(rt.Str("output-dir"), "--output-dir")
}
// DownloadMessageResources downloads every unique message resource reference
// and returns a per-resource success/failure ledger. A fallback conversation
// ID lets group/thread list commands supply context when a mediaId item's lower
// response omits it; fileId resources route through the existing drive leaf.
func DownloadMessageResources(
rt *shortcut.RuntimeContext,
messages []map[string]any,
fallbackConversationID string,
) map[string]any {
resources := make([]map[string]any, 0)
for _, message := range messages {
resources = append(resources, chatmsg.Resources(message)...)
}
discoveredCount := len(resources)
uniqueResources := make([]map[string]any, 0, len(resources))
seen := map[string]bool{}
for _, resource := range resources {
resourceType := strings.TrimSpace(fmt.Sprint(resource["type"]))
resourceID := strings.TrimSpace(fmt.Sprint(resource["resourceId"]))
download, _ := resource["download"].(map[string]any)
arguments, _ := download["arguments"].(map[string]any)
messageID := strings.TrimSpace(fmt.Sprint(arguments["message-id"]))
if messageID == "<nil>" {
messageID = ""
}
key := strings.ToLower(resourceType) + "\x00" + resourceID
if resourceID != "" && resourceID != "<nil>" {
if seen[key] {
continue
}
seen[key] = true
}
uniqueResources = append(uniqueResources, resource)
}
resources = uniqueResources
if rt.DryRun() {
return map[string]any{
"dryRun": true,
"requestedCount": len(resources),
"resources": resources,
}, nil
"dryRun": true,
"discoveredCount": discoveredCount,
"requestedCount": len(resources),
"deduplicatedCount": discoveredCount - len(resources),
"resources": resources,
}
}
cwd, err := resourceGetwd()
if err != nil {
return nil, fmt.Errorf("读取工作目录失败: %w", err)
return map[string]any{
"ok": false,
"partial": false,
"discoveredCount": discoveredCount,
"requestedCount": len(resources),
"deduplicatedCount": discoveredCount - len(resources),
"downloadedCount": 0,
"failedCount": 1,
"downloads": []map[string]any{},
"failures": []map[string]any{{
"stage": "output-directory",
"error": fmt.Sprintf("读取工作目录失败: %v", err),
}},
}
}
outputDir := strings.TrimRight(rt.Str("output-dir"), `/\`) + string(os.PathSeparator)
downloads := make([]map[string]any, 0, len(resources))
failures := make([]map[string]any, 0)
plannedNames := map[string]int{}
for _, resource := range resources {
resourceType := strings.TrimSpace(fmt.Sprint(resource["type"]))
resourceID := strings.TrimSpace(fmt.Sprint(resource["resourceId"]))
download, _ := resource["download"].(map[string]any)
arguments, _ := download["arguments"].(map[string]any)
messageID := strings.TrimSpace(fmt.Sprint(arguments["message-id"]))
conversationID := strings.TrimSpace(fmt.Sprint(arguments["open-conversation-id"]))
if download["ready"] != true || resourceID == "" || messageID == "" || conversationID == "" {
if messageID == "<nil>" {
messageID = ""
}
if conversationID == "" || conversationID == "<nil>" {
conversationID = strings.TrimSpace(fallbackConversationID)
}
missingMediaContext := resourceType == "mediaId" &&
(messageID == "" || messageID == "<nil>" ||
conversationID == "" || conversationID == "<nil>")
if resourceID == "" || resourceID == "<nil>" || missingMediaContext {
failures = append(failures, map[string]any{
"resourceId": resourceID,
"messageId": messageID,
"error": "资源引用缺少 message-id 或 open-conversation-id",
"resourceType": resourceType,
"resourceId": resourceID,
"messageId": messageID,
"error": "资源引用缺少 resource-id,或 mediaId 缺少 message-id/open-conversation-id",
})
continue
}
data, callErr := rt.CallMCPData("im", "get_resource_download_url", map[string]any{
"resourceType": "mediaId",
"resourceId": resourceID,
"openMessageId": messageID,
"openConversationId": conversationID,
})
data, callErr := resolveMessageResourceDownloadData(
rt, resourceType, resourceID, messageID, conversationID)
if callErr != nil {
failures = append(failures, map[string]any{
"resourceId": resourceID,
"messageId": messageID,
"error": callErr.Error(),
"resourceType": resourceType,
"resourceId": resourceID,
"messageId": messageID,
"error": callErr.Error(),
})
continue
}
resourceURL, headers, infoErr := resourceDownloadInfo(data)
if infoErr != nil {
failures = append(failures, map[string]any{
"resourceId": resourceID,
"messageId": messageID,
"error": infoErr.Error(),
"resourceType": resourceType,
"resourceId": resourceID,
"messageId": messageID,
"error": infoErr.Error(),
})
continue
}
preferredName := resourceDownloadPreferredName(data)
output := outputDir
filename := resourceDownloadFilename(resourceURL, preferredName)
filenameKey := strings.ToLower(filename)
plannedNames[filenameKey]++
if plannedNames[filenameKey] > 1 {
extension := filepath.Ext(filename)
output = filepath.Join(
strings.TrimRight(outputDir, `/\`),
strings.TrimSuffix(filename, extension)+
fmt.Sprintf(" (%d)", plannedNames[filenameKey])+extension,
)
}
destPath, relativePath, pathErr := resolveResourceDownloadPath(
cwd, outputDir, resourceURL, rt.Bool("overwrite"))
cwd,
output,
resourceURL,
rt.Bool("overwrite"),
preferredName,
)
if pathErr != nil {
failures = append(failures, map[string]any{
"resourceId": resourceID,
"messageId": messageID,
"error": pathErr.Error(),
"resourceType": resourceType,
"resourceId": resourceID,
"messageId": messageID,
"error": pathErr.Error(),
})
continue
}
@@ -669,28 +755,32 @@ func downloadMgetResources(rt *shortcut.RuntimeContext, messages []map[string]an
rt.Command().Context(), nil, resourceURL, headers, destPath, rt.Bool("overwrite"))
if downloadErr != nil {
failures = append(failures, map[string]any{
"resourceId": resourceID,
"messageId": messageID,
"error": downloadErr.Error(),
"resourceType": resourceType,
"resourceId": resourceID,
"messageId": messageID,
"error": downloadErr.Error(),
})
continue
}
downloads = append(downloads, map[string]any{
"resourceId": resourceID,
"messageId": messageID,
"localPath": filepath.ToSlash(relativePath),
"sizeBytes": size,
"resourceType": resourceType,
"resourceId": resourceID,
"messageId": messageID,
"localPath": filepath.ToSlash(relativePath),
"sizeBytes": size,
})
}
return map[string]any{
"ok": len(failures) == 0,
"partial": len(downloads) > 0 && len(failures) > 0,
"requestedCount": len(resources),
"downloadedCount": len(downloads),
"failedCount": len(failures),
"downloads": downloads,
"failures": failures,
}, nil
"ok": len(failures) == 0,
"partial": len(downloads) > 0 && len(failures) > 0,
"discoveredCount": discoveredCount,
"requestedCount": len(resources),
"deduplicatedCount": discoveredCount - len(resources),
"downloadedCount": len(downloads),
"failedCount": len(failures),
"downloads": downloads,
"failures": failures,
}
}
func uniqueShortcutStrings(values []string) []string {
@@ -888,22 +978,42 @@ var MessagesCreateTextEmotion = shortcut.Shortcut{
},
}
// MessagesSendCard creates and pushes a streaming card (create_and_send_card, im).
// MessagesSendCard creates and optionally completes a streaming card by
// composing create_and_send_card with update_streaming_card.
var MessagesSendCard = shortcut.Shortcut{
Service: "chat",
Command: "+messages-send-card",
Product: "im",
Description: "创建并推送流式卡片(需配合 messages-update-card)",
Intent: "当你要发送一张可后续流式更新的卡片消息(如 AI 逐字输出)时使用;会实际推送卡片并返回 bizId,群 openConversationId 或单聊接收者 userId 二选一,配合 messages-update-card 更新。",
Description: "创建流式卡片,可在同一次调用中写入内容并结束",
Intent: "当你要发送一张流式卡片消息时使用;群 openConversationId 或单聊接收者 userId 二选一。只传目标时创建卡片并返回 bizId,供后续 messages-update-card 流式更新;同时传 --content 时会自动串联创建和更新,默认以 flowStatus=3 完成,避免卡片停留在加载中。",
Risk: shortcut.RiskWrite,
Flags: []shortcut.Flag{
{Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId(与 --receiver 互斥)"},
{Name: "receiver", Type: shortcut.FlagString, Desc: "单聊接收者 userId(与 --group 互斥)"},
{Name: "content", Type: shortcut.FlagString, Desc: "创建后立即写入的卡片内容;省略时仅创建并返回 bizId"},
{Name: "flow-status", Type: shortcut.FlagInt, Default: "3", Desc: "自动更新状态:1处理中/2输入中/3完成/4执行中/5错误;--flow-status 必须在 1-5 之间,且显式指定时必须同时提供 --content"},
},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"group", "receiver"}},
{
Kind: shortcut.ConstraintCustom,
Flags: []string{"flow-status"},
Description: "--flow-status 必须在 1-5 之间,且显式指定时必须同时提供 --content",
},
},
Tips: []string{
`dws chat +messages-send-card --group <openConversationId>`,
`dws chat +messages-send-card --group <openConversationId> --content "任务已完成"`,
},
Validate: func(rt *shortcut.RuntimeContext) error {
if status := rt.Int("flow-status"); status < 1 || status > 5 {
return fmt.Errorf("--flow-status 必须在 1-5 之间")
}
if rt.Changed("flow-status") && rt.Str("content") == "" {
return fmt.Errorf("--flow-status 只有与 --content 一起使用才有意义")
}
return nil
},
Tips: []string{`dws chat +messages-send-card --group <openConversationId>`},
Execute: func(rt *shortcut.RuntimeContext) error {
group := rt.Str("group")
receiver := rt.Str("receiver")
@@ -919,10 +1029,91 @@ var MessagesSendCard = shortcut.Shortcut{
} else {
params["receiverUid"] = receiver
}
return rt.CallMCP("create_and_send_card", params)
content := rt.Str("content")
if content == "" {
return rt.CallMCP("create_and_send_card", params)
}
status := rt.Int("flow-status")
if rt.DryRun() {
return rt.Output(map[string]any{
"dry_run": true,
"executed": false,
"preview_kind": "plan",
"actionCount": 2,
"failedCount": 0,
"actions": []map[string]any{
{
"tool": "create_and_send_card",
"arguments": params,
},
{
"tool": "update_streaming_card",
"arguments": map[string]any{
"bizId": "<from create_and_send_card>",
"msgContent": content,
"flowStatus": status,
},
},
},
})
}
created, err := rt.CallMCPWriteData("im", "create_and_send_card", params)
if err != nil {
return err
}
bizID := findCardBizID(created)
if bizID == "" {
return fmt.Errorf("卡片已创建但下层未返回 bizId,无法自动更新;请检查 create_and_send_card 响应")
}
updated, err := rt.CallMCPWriteData("im", "update_streaming_card", map[string]any{
"bizId": bizID,
"msgContent": content,
"flowStatus": status,
})
if err != nil {
return fmt.Errorf("卡片已创建(bizId=%s),但自动更新失败: %w", bizID, err)
}
return rt.Output(map[string]any{
"ok": true,
"bizId": bizID,
"flowStatus": status,
"created": created,
"updated": updated,
})
},
}
func findCardBizID(value any) string {
switch typed := value.(type) {
case map[string]any:
for _, key := range []string{"bizId", "bizID", "biz_id"} {
if candidate := strings.TrimSpace(fmt.Sprint(typed[key])); candidate != "" && candidate != "<nil>" {
return candidate
}
}
for _, child := range typed {
if candidate := findCardBizID(child); candidate != "" {
return candidate
}
}
case []any:
for _, child := range typed {
if candidate := findCardBizID(child); candidate != "" {
return candidate
}
}
case string:
trimmed := strings.TrimSpace(typed)
if strings.HasPrefix(trimmed, "{") || strings.HasPrefix(trimmed, "[") {
var nested any
if json.Unmarshal([]byte(trimmed), &nested) == nil {
return findCardBizID(nested)
}
}
}
return ""
}
// MessagesUpdateCard streams updated card content (update_streaming_card, im).
var MessagesUpdateCard = shortcut.Shortcut{
Service: "chat",
@@ -7,7 +7,6 @@ import (
"context"
"errors"
"net/http"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
@@ -63,8 +62,8 @@ func TestCrossPlatformCoverageMgetResourceDownloadOutcomes(t *testing.T) {
}})
root := newPlatformCoverageRoot()
root.SetArgs(baseArgs)
if err := root.Execute(); err == nil || !strings.Contains(err.Error(), "工作目录") {
t.Fatalf("getwd error = %v", err)
if err := root.Execute(); err != nil {
t.Fatalf("getwd ledger error = %v", err)
}
})
+667
View File
@@ -0,0 +1,667 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package chat
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
func TestCrossPlatformCoverageMessagesSendCurrentUserImageAndUserResolution(t *testing.T) {
fake := &larkAlignmentCaller{}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetArgs([]string{
"chat", "+messages-send",
"--user", "user-id",
"--msg-type", "image",
"--media-id", "@image",
"--idempotency-key", "image-key",
"--yes",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 2 {
t.Fatalf("calls = %#v, want contact resolution + send", fake.calls)
}
if fake.calls[0].product != "contact" || fake.calls[0].tool != "get_user_info_by_user_ids" {
t.Fatalf("resolution call = %#v", fake.calls[0])
}
send := fake.calls[1]
if send.product != "chat" || send.tool != "send_personal_message" {
t.Fatalf("send call = %#v", send)
}
for key, want := range map[string]any{
"receiverOpenDingTalkId": "D-resolved",
"msgType": "image",
"uuid": "image-key",
} {
if !reflect.DeepEqual(send.args[key], want) {
t.Errorf("%s = %#v, want %#v", key, send.args[key], want)
}
}
var content map[string]string
if err := json.Unmarshal([]byte(send.args["content"].(string)), &content); err != nil {
t.Fatal(err)
}
if content["mediaId"] != "@image" {
t.Fatalf("image content = %#v", content)
}
}
func TestCrossPlatformCoverageMessagesSendCurrentUserLocalFileFlow(t *testing.T) {
t.Chdir(t.TempDir())
if err := os.WriteFile("report.txt", []byte("gap-fill"), 0o600); err != nil {
t.Fatal(err)
}
var uploaded []byte
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
t.Errorf("upload method = %s", r.Method)
}
var err error
uploaded, err = io.ReadAll(r.Body)
if err != nil {
t.Error(err)
}
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(server.Close)
fake := &larkAlignmentCaller{responses: map[string]string{
"im/init_conversation_file_upload": `{"resourceUrl":"` + server.URL + `","uploadKey":"upload-key"}`,
"im/commit_conversation_file_upload": `{"result":{"dentryId":11,"spaceId":22}}`,
"chat/send_personal_message": `{"result":{"openMessageId":"sent-file"}}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{
"chat", "+messages-send",
"--group", "cid",
"--msg-type", "audio",
"--file-path", "./report.txt",
"--uuid", "file-key",
"--yes",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if string(uploaded) != "gap-fill" || len(fake.calls) != 3 {
t.Fatalf("uploaded = %q, calls = %#v", uploaded, fake.calls)
}
if fake.calls[0].tool != "init_conversation_file_upload" ||
fake.calls[1].tool != "commit_conversation_file_upload" ||
fake.calls[2].tool != "send_personal_message" {
t.Fatalf("file flow calls = %#v", fake.calls)
}
send := fake.calls[2]
if send.args["msgType"] != "file" || send.args["openConversationId"] != "cid" ||
send.args["uuid"] != "file-key" {
t.Fatalf("file send args = %#v", send.args)
}
var content map[string]any
if err := json.Unmarshal([]byte(send.args["content"].(string)), &content); err != nil {
t.Fatal(err)
}
if content["dentryId"] != float64(11) || content["spaceId"] != float64(22) ||
content["fileName"] != "report.txt" {
t.Fatalf("file content = %#v", content)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["requestedMessageType"] != "audio" || payload["effectiveMessageType"] != "file" {
t.Fatalf("file output = %#v", payload)
}
}
func TestCrossPlatformCoverageMessagesSendCurrentUserLocalFileDryRunAndFailures(t *testing.T) {
t.Chdir(t.TempDir())
if err := os.WriteFile("fixture.bin", []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
uploadServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(uploadServer.Close)
fake := &larkAlignmentCaller{}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{
"chat", "+messages-send",
"--open-dingtalk-id", "D-target",
"--file", "./fixture.bin",
"--dry-run",
"--yes",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 0 {
t.Fatalf("file dry-run made lower calls: %#v", fake.calls)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["actionCount"] != float64(2) || payload["preview_kind"] != "plan" {
t.Fatalf("dry-run payload = %#v", payload)
}
for _, tc := range []struct {
name string
args []string
}{
{"unsafe path", []string{"--group", "cid", "--file", "../outside"}},
{"missing file", []string{"--group", "cid", "--file", "./missing"}},
{"media mismatch", []string{"--group", "cid", "--media-id", "@image", "--msg-type", "file"}},
{"file mismatch", []string{"--group", "cid", "--file", "./fixture.bin", "--msg-type", "image"}},
{"missing media", []string{"--group", "cid", "--text", "x", "--msg-type", "image"}},
{"bot media", []string{"--identity", "bot", "--robot-code", "robot", "--group", "cid", "--media-id", "@image"}},
{"webhook media", []string{"--identity", "webhook", "--webhook-token", "token", "--media-id", "@image"}},
{"user target conflict", []string{"--group", "cid", "--user", "u1", "--text", "x"}},
{"user media at", []string{"--group", "cid", "--media-id", "@image", "--at-all"}},
{"text flag with markdown type", []string{"--group", "cid", "--text", "x", "--msg-type", "markdown"}},
{"markdown flag with text type", []string{"--group", "cid", "--markdown", "x", "--msg-type", "text"}},
} {
t.Run(tc.name, func(t *testing.T) {
helpers.InitDeps(&larkAlignmentCaller{})
command := newPlatformCoverageRoot()
args := append([]string{"chat", "+messages-send"}, tc.args...)
args = append(args, "--yes")
command.SetArgs(args)
if err := command.Execute(); err == nil {
t.Fatalf("invalid args succeeded: %v", tc.args)
}
})
}
for _, tc := range []struct {
name string
fake *larkAlignmentCaller
}{
{
name: "upload init error",
fake: &larkAlignmentCaller{failProductTool: "im/init_conversation_file_upload"},
},
{
name: "commit response missing IDs",
fake: &larkAlignmentCaller{responses: map[string]string{
"im/init_conversation_file_upload": `{"resourceUrl":"` + uploadServer.URL + `","uploadKey":"key"}`,
"im/commit_conversation_file_upload": `{}`,
}},
},
{
name: "message send error",
fake: &larkAlignmentCaller{
failProductTool: "chat/send_personal_message",
responses: map[string]string{
"im/init_conversation_file_upload": `{"resourceUrl":"` + uploadServer.URL + `","uploadKey":"key"}`,
"im/commit_conversation_file_upload": `{"dentryId":1,"spaceId":2}`,
},
},
},
} {
t.Run(tc.name, func(t *testing.T) {
helpers.InitDeps(tc.fake)
command := newPlatformCoverageRoot()
command.SetArgs([]string{
"chat", "+messages-send",
"--group", "cid",
"--file", "./fixture.bin",
"--yes",
})
if err := command.Execute(); err == nil {
t.Fatal("file failure scenario unexpectedly succeeded")
}
})
}
}
func TestCrossPlatformCoverageMessagesSendUserResolutionFailures(t *testing.T) {
for _, tc := range []struct {
name string
fake *larkAlignmentCaller
}{
{"lookup error", &larkAlignmentCaller{failProductTool: "contact/get_user_info_by_user_ids"}},
{"missing open id", &larkAlignmentCaller{responses: map[string]string{
"contact/get_user_info_by_user_ids": `{"result":[]}`,
}}},
} {
t.Run(tc.name, func(t *testing.T) {
helpers.InitDeps(tc.fake)
root := newPlatformCoverageRoot()
root.SetArgs([]string{
"chat", "+messages-send",
"--user", "user-id",
"--text", "hello",
"--yes",
})
if err := root.Execute(); err == nil {
t.Fatal("unresolved user was accepted")
}
if len(tc.fake.calls) != 1 {
t.Fatalf("calls = %#v", tc.fake.calls)
}
})
}
}
func TestCrossPlatformCoverageMessagesSendTextModesAndExecuteGuard(t *testing.T) {
for _, args := range [][]string{
{"--group", "cid", "--markdown", "## markdown"},
{"--group", "cid", "--text", "plain", "--msg-type", "text"},
} {
fake := &larkAlignmentCaller{}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetArgs(append(append([]string{"chat", "+messages-send"}, args...), "--yes"))
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 1 || fake.calls[0].tool != "send_personal_message" {
t.Fatalf("text mode calls = %#v", fake.calls)
}
}
shortcut.Register(shortcut.Shortcut{
Service: "chat",
Command: "+gap-send-execute-guard",
Flags: MessagesSend.Flags,
Execute: executeMessagesSend,
})
helpers.InitDeps(&larkAlignmentCaller{})
root := newPlatformCoverageRoot()
root.SetArgs([]string{
"chat", "+gap-send-execute-guard",
"--group", "cid",
"--media-id", "@image",
"--msg-type", "file",
})
if err := root.Execute(); err == nil {
t.Fatal("execute-time content mismatch was accepted")
}
}
func TestCrossPlatformCoverageMessagesSendCardOneCallLifecycle(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"im/create_and_send_card": `{"result":{"card":{"biz_id":"biz-1"}}}`,
"im/update_streaming_card": `{"result":{"updated":true}}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{
"chat", "+messages-send-card",
"--group", "cid",
"--content", "完成",
"--flow-status", "3",
"--yes",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 2 || fake.calls[0].tool != "create_and_send_card" ||
fake.calls[1].tool != "update_streaming_card" {
t.Fatalf("card calls = %#v", fake.calls)
}
if fake.calls[1].args["bizId"] != "biz-1" || fake.calls[1].args["msgContent"] != "完成" ||
fake.calls[1].args["flowStatus"] != 3 {
t.Fatalf("card update args = %#v", fake.calls[1].args)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["bizId"] != "biz-1" || payload["ok"] != true {
t.Fatalf("card output = %#v", payload)
}
}
func TestCrossPlatformCoverageMessagesSendCardDryRunAndFailureBoundaries(t *testing.T) {
t.Run("create only", func(t *testing.T) {
fake := &larkAlignmentCaller{}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetArgs([]string{
"chat", "+messages-send-card",
"--group", "cid",
"--yes",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 1 || fake.calls[0].tool != "create_and_send_card" {
t.Fatalf("create-only calls = %#v", fake.calls)
}
})
t.Run("dry run", func(t *testing.T) {
fake := &larkAlignmentCaller{}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{
"chat", "+messages-send-card",
"--receiver", "user",
"--content", "处理中",
"--flow-status", "1",
"--dry-run",
"--yes",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 0 {
t.Fatalf("card dry-run made lower calls: %#v", fake.calls)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["actionCount"] != float64(2) {
t.Fatalf("card plan = %#v", payload)
}
})
for _, tc := range []struct {
name string
fake *larkAlignmentCaller
wantError string
}{
{
name: "create error",
fake: &larkAlignmentCaller{failProductTool: "im/create_and_send_card"},
wantError: "fixture lower call failed",
},
{
name: "missing biz id",
fake: &larkAlignmentCaller{responses: map[string]string{
"im/create_and_send_card": `{"result":{"created":true}}`,
}},
wantError: "未返回 bizId",
},
{
name: "update error preserves id",
fake: &larkAlignmentCaller{
failProductTool: "im/update_streaming_card",
responses: map[string]string{
"im/create_and_send_card": `{"bizId":"biz-preserved"}`,
},
},
wantError: "biz-preserved",
},
} {
t.Run(tc.name, func(t *testing.T) {
helpers.InitDeps(tc.fake)
root := newPlatformCoverageRoot()
root.SetArgs([]string{
"chat", "+messages-send-card",
"--group", "cid",
"--content", "完成",
"--yes",
})
err := root.Execute()
if err == nil || !strings.Contains(err.Error(), tc.wantError) {
t.Fatalf("error = %v, want substring %q", err, tc.wantError)
}
})
}
for _, args := range [][]string{
{"--group", "cid", "--content", "x", "--flow-status", "6"},
{"--group", "cid", "--flow-status", "2"},
} {
helpers.InitDeps(&larkAlignmentCaller{})
root := newPlatformCoverageRoot()
root.SetArgs(append([]string{"chat", "+messages-send-card"}, args...))
if err := root.Execute(); err == nil {
t.Fatalf("invalid card args succeeded: %v", args)
}
}
}
func TestCrossPlatformCoverageFindCardBizIDResponseShapes(t *testing.T) {
for _, tc := range []struct {
value any
want string
}{
{map[string]any{"bizId": "direct"}, "direct"},
{map[string]any{"bizID": "caps"}, "caps"},
{map[string]any{"biz_id": "snake"}, "snake"},
{map[string]any{"result": []any{map[string]any{"bizId": "nested"}}}, "nested"},
{`{"result":{"bizId":"json"}}`, "json"},
{`[{"bizId":"array-json"}]`, "array-json"},
{`{"bizId":`, ""},
{"plain", ""},
{42, ""},
} {
if got := findCardBizID(tc.value); got != tc.want {
t.Errorf("findCardBizID(%#v) = %q, want %q", tc.value, got, tc.want)
}
}
}
func TestCrossPlatformCoverageMessageResourceDownloadDeduplicatesAndUsesFallbackConversation(t *testing.T) {
resetResourceDownloadHooks(t)
t.Chdir(t.TempDir())
resourceDownload = func(
_ context.Context,
_ *http.Client,
_ string,
_ map[string]string,
dest string,
_ bool,
) (int64, error) {
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return 0, err
}
return 7, nil
}
fake := &larkAlignmentCaller{responses: map[string]string{
"im/get_resource_download_url": `{"result":{"resourceUrl":"https://example.test/resource.bin"}}`,
}}
helpers.InitDeps(fake)
message := map[string]any{
"openMessageId": "msg",
"content": `{"mediaId":"@same","nested":{"mediaId":"@same"}}`,
}
secondMessage := map[string]any{
"openMessageId": "msg-2",
"content": `{"mediaId":"@same"}`,
}
var ledger map[string]any
shortcut.Register(shortcut.Shortcut{
Service: "chat",
Command: "+gap-resource-download",
Flags: MessageResourceDownloadFlags(),
Execute: func(rt *shortcut.RuntimeContext) error {
ledger = DownloadMessageResources(rt, []map[string]any{message, secondMessage}, "cid-fallback")
return nil
},
})
root := newPlatformCoverageRoot()
root.SetArgs([]string{"chat", "+gap-resource-download", "--output-dir", "./downloads"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if ledger["discoveredCount"] != 2 || ledger["requestedCount"] != 1 ||
ledger["deduplicatedCount"] != 1 || ledger["downloadedCount"] != 1 {
t.Fatalf("download ledger = %#v", ledger)
}
if len(fake.calls) != 1 || fake.calls[0].args["openConversationId"] != "cid-fallback" {
t.Fatalf("resource lookup = %#v", fake.calls)
}
}
func TestCrossPlatformCoverageMessageFileResourceDownloadUsesDriveAndPreservesName(t *testing.T) {
resetResourceDownloadHooks(t)
t.Chdir(t.TempDir())
resourceDownload = func(
_ context.Context,
_ *http.Client,
_ string,
_ map[string]string,
dest string,
_ bool,
) (int64, error) {
if err := os.WriteFile(dest, []byte("drive-file"), 0o600); err != nil {
return 0, err
}
return int64(len("drive-file")), nil
}
fake := &larkAlignmentCaller{responses: map[string]string{
"drive/download_file": `{"result":{"downloadUrl":"https://example.test/opaque","fileName":"fixture.txt"}}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetArgs([]string{
"chat", "+messages-resource-download",
"--type", "fileId",
"--resource-id", "drive-file",
"--output", "./downloads/",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
content, err := os.ReadFile(filepath.Join("downloads", "fixture.txt"))
if err != nil || string(content) != "drive-file" {
t.Fatalf("downloaded content = %q, err = %v", content, err)
}
if len(fake.calls) != 1 ||
fake.calls[0].product != "drive" ||
fake.calls[0].tool != "download_file" ||
fake.calls[0].args["fileId"] != "drive-file" {
t.Fatalf("drive call = %#v", fake.calls)
}
var ledger map[string]any
shortcut.Register(shortcut.Shortcut{
Service: "chat",
Command: "+gap-file-resource-download",
Flags: MessageResourceDownloadFlags(),
Execute: func(rt *shortcut.RuntimeContext) error {
ledger = DownloadMessageResources(rt, []map[string]any{{
"content": "[文件] fixture.txt fileId: drive-file",
}}, "")
return nil
},
})
root = newPlatformCoverageRoot()
root.SetArgs([]string{"chat", "+gap-file-resource-download", "--output-dir", "./batch-downloads"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if ledger["downloadedCount"] != 1 || ledger["failedCount"] != 0 {
t.Fatalf("file download ledger = %#v", ledger)
}
downloads := ledger["downloads"].([]map[string]any)
if downloads[0]["resourceType"] != "fileId" ||
downloads[0]["localPath"] != "batch-downloads/fixture.txt" ||
downloads[0]["messageId"] != "" {
t.Fatalf("file download = %#v", downloads[0])
}
}
func TestCrossPlatformCoverageMessageResourceDownloadDisambiguatesSameNames(t *testing.T) {
resetResourceDownloadHooks(t)
t.Chdir(t.TempDir())
destinations := make([]string, 0, 2)
resourceDownload = func(
_ context.Context,
_ *http.Client,
_ string,
_ map[string]string,
dest string,
_ bool,
) (int64, error) {
destinations = append(destinations, filepath.Base(dest))
return 1, nil
}
helpers.InitDeps(&larkAlignmentCaller{responses: map[string]string{
"drive/download_file": `{"result":{"downloadUrl":"https://example.test/opaque","fileName":"fixture.txt"}}`,
}})
var ledger map[string]any
shortcut.Register(shortcut.Shortcut{
Service: "chat",
Command: "+gap-colliding-resource-download",
Flags: MessageResourceDownloadFlags(),
Execute: func(rt *shortcut.RuntimeContext) error {
ledger = DownloadMessageResources(rt, []map[string]any{
{"fileId": "file-a"},
{"fileId": "file-b"},
}, "")
return nil
},
})
root := newPlatformCoverageRoot()
root.SetArgs([]string{"chat", "+gap-colliding-resource-download", "--output-dir", "./downloads"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(destinations, []string{"fixture.txt", "fixture (2).txt"}) {
t.Fatalf("download destinations = %#v", destinations)
}
if ledger["downloadedCount"] != 2 || ledger["failedCount"] != 0 {
t.Fatalf("download ledger = %#v", ledger)
}
}
func TestCrossPlatformCoverageMessageResourceDownloadRequiresMediaContextOnly(t *testing.T) {
helpers.InitDeps(&larkAlignmentCaller{})
root := newPlatformCoverageRoot()
root.SetArgs([]string{
"chat", "+messages-resource-download",
"--resource-id", "@media",
})
if err := root.Execute(); err == nil ||
!strings.Contains(err.Error(), "--type mediaId") {
t.Fatalf("missing media context error = %v", err)
}
root = newPlatformCoverageRoot()
root.SetArgs([]string{
"chat", "+messages-resource-download",
"--type", "fileId",
"--resource-id", "drive-file",
"--output", "../outside",
})
if err := root.Execute(); err == nil ||
!strings.Contains(err.Error(), "--output") {
t.Fatalf("unsafe output error = %v", err)
}
}
func TestCrossPlatformCoverageReadHelperRejectsWriteToolDirectly(t *testing.T) {
fake := &larkAlignmentCaller{}
helpers.InitDeps(fake)
if _, err := helpers.CallMCPReadToolTextOnServer("chat", "send_personal_message", nil); err == nil {
t.Fatal("write tool was accepted by the helper read channel")
}
if len(fake.calls) != 0 {
t.Fatalf("rejected write reached lower caller: %#v", fake.calls)
}
}
+74 -17
View File
@@ -54,16 +54,16 @@ var MessagesResourceDownload = shortcut.Shortcut{
Service: "chat",
Command: "+messages-resource-download",
Product: "im",
Description: "安全下载消息资源(图片/视频/语音)到本地",
Intent: "当你需要拿到消息里的实际图片、视频或语音文件,而不只是临时 URL 时使用;" +
"先用消息和会话身份换取下载地址,再安全写入工作目录内的相对路径。" +
Description: "安全下载消息资源(图片/视频/语音/文件)到本地",
Intent: "当你需要拿到消息里的实际图片、视频、语音或钉盘文件,而不只是资源 ID 时使用;" +
"mediaId 用消息和会话身份换取下载地址,fileId 复用钉盘下载能力,再安全写入工作目录内的相对路径。" +
"默认不覆盖已有文件,只有显式传 --overwrite 才覆盖。",
Risk: shortcut.RiskRead,
Flags: []shortcut.Flag{
{Name: "type", Type: shortcut.FlagString, Default: "mediaId", Desc: "资源类型", Enum: []string{"mediaId"}},
{Name: "resource-id", Type: shortcut.FlagString, Desc: "资源 ID(消息中的 mediaId)", Required: true},
{Name: "message-id", Type: shortcut.FlagString, Desc: "消息 openMessageId", Required: true},
{Name: "open-conversation-id", Type: shortcut.FlagString, Desc: "会话 openConversationId", Required: true},
{Name: "type", Type: shortcut.FlagString, Default: "mediaId", Desc: "资源类型", Enum: []string{"mediaId", "fileId"}},
{Name: "resource-id", Type: shortcut.FlagString, Desc: "消息中的 mediaId 或 fileId", Required: true},
{Name: "message-id", Type: shortcut.FlagString, Desc: "mediaId 所属消息的 openMessageId"},
{Name: "open-conversation-id", Type: shortcut.FlagString, Desc: "mediaId 所属会话的 openConversationId"},
{Name: "output", Type: shortcut.FlagString, Default: ".", Desc: "工作目录内的相对文件或目录路径"},
{Name: "overwrite", Type: shortcut.FlagBool, Desc: "允许覆盖已存在的目标文件(默认拒绝)"},
},
@@ -73,13 +73,28 @@ var MessagesResourceDownload = shortcut.Shortcut{
Flags: []string{"output"},
Description: "--output 必须是工作目录内的相对路径,不允许绝对路径或 .. 逃逸",
},
{
Kind: shortcut.ConstraintCustom,
Flags: []string{"type", "message-id", "open-conversation-id"},
Description: "--type mediaId 时必须同时提供 --message-id 和 --open-conversation-id;fileId 不需要消息上下文",
},
},
Tips: []string{
`dws chat +messages-resource-download --resource-id <mediaId> --message-id <openMessageId> --open-conversation-id <openConversationId>`,
`dws chat +messages-resource-download --type fileId --resource-id <fileId> --output ./downloads/`,
`dws chat +messages-resource-download --resource-id <mediaId> --message-id <openMessageId> --open-conversation-id <openConversationId> --output ./downloads/`,
},
Validate: func(rt *shortcut.RuntimeContext) error {
return validateResourceDownloadOutput(rt.Str("output"))
if err := validateResourceDownloadOutput(rt.Str("output")); err != nil {
return err
}
if rt.Str("type") == "mediaId" &&
(strings.TrimSpace(rt.Str("message-id")) == "" ||
strings.TrimSpace(rt.Str("open-conversation-id")) == "") {
return apperrors.NewValidation(
"--type mediaId 时必须同时提供 --message-id 和 --open-conversation-id")
}
return nil
},
Execute: func(rt *shortcut.RuntimeContext) error {
plan := map[string]any{
@@ -101,12 +116,13 @@ var MessagesResourceDownload = shortcut.Shortcut{
return rt.Output(plan)
}
data, err := rt.CallMCPData("im", "get_resource_download_url", map[string]any{
"resourceType": rt.Str("type"),
"resourceId": rt.Str("resource-id"),
"openMessageId": rt.Str("message-id"),
"openConversationId": rt.Str("open-conversation-id"),
})
data, err := resolveMessageResourceDownloadData(
rt,
rt.Str("type"),
rt.Str("resource-id"),
rt.Str("message-id"),
rt.Str("open-conversation-id"),
)
if err != nil {
return err
}
@@ -119,7 +135,12 @@ var MessagesResourceDownload = shortcut.Shortcut{
return apperrors.NewInternal(fmt.Sprintf("读取工作目录失败: %v", err))
}
destPath, relativePath, err := resolveResourceDownloadPath(
cwd, rt.Str("output"), resourceURL, rt.Bool("overwrite"))
cwd,
rt.Str("output"),
resourceURL,
rt.Bool("overwrite"),
resourceDownloadPreferredName(data),
)
if err != nil {
return err
}
@@ -138,6 +159,23 @@ var MessagesResourceDownload = shortcut.Shortcut{
},
}
func resolveMessageResourceDownloadData(
rt *shortcut.RuntimeContext,
resourceType, resourceID, messageID, conversationID string,
) (map[string]any, error) {
if resourceType == "fileId" {
return rt.CallMCPData("drive", "download_file", map[string]any{
"fileId": resourceID,
})
}
return rt.CallMCPData("im", "get_resource_download_url", map[string]any{
"resourceType": "mediaId",
"resourceId": resourceID,
"openMessageId": messageID,
"openConversationId": conversationID,
})
}
func validateResourceDownloadOutput(output string) error {
return validateResourceDownloadOutputFlag(output, "--output")
}
@@ -211,6 +249,18 @@ func resourceDownloadInfo(data map[string]any) (string, map[string]string, error
return resourceURL, headers, nil
}
func resourceDownloadPreferredName(data map[string]any) string {
for {
result, ok := data["result"].(map[string]any)
if !ok {
break
}
data = result
}
name, _ := data["fileName"].(string)
return strings.TrimSpace(name)
}
func isAliyunOSSHost(host string) bool {
host = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(host), "."))
return host == "aliyuncs.com" || strings.HasSuffix(host, ".aliyuncs.com")
@@ -219,6 +269,7 @@ func isAliyunOSSHost(host string) bool {
func resolveResourceDownloadPath(
baseDir, output, resourceURL string,
overwrite bool,
preferredName ...string,
) (absolutePath, relativePath string, err error) {
if err := validateResourceDownloadOutput(output); err != nil {
return "", "", err
@@ -242,7 +293,7 @@ func resolveResourceDownloadPath(
directoryIntent ||
output == "."
if isDirectory {
candidate = filepath.Join(candidate, resourceDownloadFilename(resourceURL))
candidate = filepath.Join(candidate, resourceDownloadFilename(resourceURL, preferredName...))
}
parent := filepath.Dir(candidate)
@@ -321,7 +372,13 @@ func ensureResourceDownloadParent(baseDir, parent string) error {
return nil
}
func resourceDownloadFilename(resourceURL string) string {
func resourceDownloadFilename(resourceURL string, preferredName ...string) string {
if len(preferredName) > 0 {
name := filepath.Base(strings.ReplaceAll(strings.TrimSpace(preferredName[0]), "\\", "/"))
if name != "" && name != "." && name != string(os.PathSeparator) {
return name
}
}
parsed, err := url.Parse(resourceURL)
if err == nil {
name, unescapeErr := url.PathUnescape(filepath.Base(parsed.Path))
+228 -21
View File
@@ -5,39 +5,49 @@
package chat
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"unicode/utf8"
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/shortcut"
)
// MessagesSend is the identity-aware common text/Markdown entry point. Media
// upload remains on the native message send command until DWS has one shared
// upload contract; this Shortcut does not pretend webhook/bot/current-user
// transports support identical capabilities.
const messagesSendFileUploadTimeout = 10 * time.Minute
// MessagesSend is the identity-aware common sending entry point. The current
// user branch reuses the native message leaf's reviewed file-upload flow and
// existing-mediaId image path. Bot and webhook remain text/Markdown-only
// because their lower transports do not expose equivalent media contracts.
var MessagesSend = shortcut.Shortcut{
Service: "chat",
Command: "+messages-send",
Product: "chat",
Description: "统一发送文本/Markdown(current user、bot、webhook)",
Intent: "当你希望用同一个入口选择 current-user、bot 或 webhook 身份发送文本/Markdown 时使用;命令会按身份校验目标和凭据并路由到真实下层。current-user 支持幂等键,bot 支持群聊或批量单聊,webhook 的目标由 token 所在群决定。媒体上传仍使用原生命令。",
Description: "统一发送文本、Markdown、当前用户文件或已有 mediaId 图片",
Intent: "当你希望用同一个入口选择 current-user、bot 或 webhook 身份发送消息时使用;命令会按身份校验目标、内容和凭据并路由到真实下层。current-user 支持文本/Markdown、已有 mediaId 图片、安全相对路径文件上传和幂等键;bot 支持群聊或批量单聊文本/Markdown;webhook 的目标由 token 所在群决定。不会把 user 文件能力伪装成 bot/webhook 等价能力。",
Risk: shortcut.RiskWrite,
Flags: []shortcut.Flag{
{Name: "identity", Type: shortcut.FlagString, Default: "user", Enum: []string{"user", "bot", "webhook"}, Desc: "发送身份"},
{Name: "identity", Type: shortcut.FlagString, Default: "user", Enum: []string{"user", "bot", "webhook"}, Desc: "发送身份;身份相关目标、内容和凭据不能为空,完整能力矩阵见命令说明"},
{Name: "as", Type: shortcut.FlagString, Enum: []string{"user", "bot", "webhook"}, Desc: "--identity 的 lark-cli 对齐别名"},
{Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId(user/bot 群聊)"},
{Name: "chat-id", Type: shortcut.FlagString, Desc: "--group 的 lark-cli 对齐别名"},
{Name: "user", Type: shortcut.FlagString, Desc: "单聊接收者 userId(user;自动解析 openDingTalkId)"},
{Name: "open-dingtalk-id", Type: shortcut.FlagString, Desc: "单聊接收者 openDingTalkId(user)"},
{Name: "users", Type: shortcut.FlagStringSlice, Desc: "批量单聊接收者 userId(bot)"},
{Name: "open-dingtalk-ids", Type: shortcut.FlagStringSlice, Desc: "批量单聊接收者 openDingTalkId(bot)"},
{Name: "robot-code", Type: shortcut.FlagString, Desc: "机器人 Code(bot 必填)"},
{Name: "webhook-token", Type: shortcut.FlagString, Desc: "自定义机器人 Webhook token(webhook 必填)"},
{Name: "robot-code", Type: shortcut.FlagString, Desc: "机器人 Code(identity=bot 时使用)"},
{Name: "webhook-token", Type: shortcut.FlagString, Desc: "自定义机器人 Webhook token(identity=webhook 时使用)"},
{Name: "title", Type: shortcut.FlagString, Desc: "消息标题(不传则从正文生成)"},
{Name: "text", Type: shortcut.FlagString, Desc: "纯文本正文"},
{Name: "markdown", Type: shortcut.FlagString, Desc: "Markdown 正文"},
{Name: "msg-type", Type: shortcut.FlagString, Enum: []string{"text", "markdown", "image", "file", "audio", "video"}, Desc: "内容类型;省略时根据正文、--media-id 或 --file 自动推断"},
{Name: "media-id", Type: shortcut.FlagString, Desc: "已有图片 mediaId(仅 user 的 image)"},
{Name: "file", Type: shortcut.FlagString, Desc: "工作目录内安全相对文件路径(仅 user 的 file/audio/video)"},
{Name: "file-path", Type: shortcut.FlagString, Desc: "--file 的兼容别名"},
{Name: "uuid", Type: shortcut.FlagString, Desc: "幂等键(仅 user)"},
{Name: "idempotency-key", Type: shortcut.FlagString, Desc: "--uuid 的 lark-cli 对齐别名(仅 user)"},
{Name: "at-open-dingtalk-ids", Type: shortcut.FlagStringSlice, Desc: "@ 的 openDingTalkId(user/bot 群聊)"},
@@ -47,19 +57,21 @@ var MessagesSend = shortcut.Shortcut{
shortcut.AIMessageTagFlag(),
},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintAtLeastOne, Flags: []string{"text", "markdown"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"text", "markdown"}},
{Kind: shortcut.ConstraintAtLeastOne, Flags: []string{"text", "markdown", "media-id", "file", "file-path"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"text", "markdown", "media-id", "file", "file-path"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"identity", "as"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"group", "chat-id"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"user", "open-dingtalk-id"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"uuid", "idempotency-key"}},
{
Kind: shortcut.ConstraintCustom,
Flags: []string{"identity", "as", "group", "chat-id", "open-dingtalk-id", "users", "open-dingtalk-ids", "robot-code", "webhook-token", "uuid", "idempotency-key"},
Description: "user 必须指定 group/open-dingtalk-id 之一;bot 必须指定 robot-code 和群聊/批量单聊目标之一;webhook 必须指定 webhook-token;幂等键仅 user 支持",
Flags: []string{"identity"},
Description: "身份相关目标、内容和凭据不能为空,完整能力矩阵见命令说明",
},
},
Tips: []string{
`dws chat +messages-send --as user --chat-id <openConversationId> --markdown "## 周报" --idempotency-key <key>`,
`dws chat +messages-send --as user --user <userId> --msg-type file --file ./report.pdf --idempotency-key <key>`,
`dws chat +messages-send --as bot --robot-code <robotCode> --users userId1,userId2 --text "请提交周报"`,
},
Validate: validateMessagesSend,
@@ -69,16 +81,28 @@ var MessagesSend = shortcut.Shortcut{
func validateMessagesSend(rt *shortcut.RuntimeContext) error {
identity := messagesSendIdentity(rt)
group := rt.StrFirst("chat-id", "group")
userID := rt.Str("user")
openID := rt.Str("open-dingtalk-id")
users := uniqueShortcutStrings(rt.StrSlice("users"))
openIDs := uniqueShortcutStrings(rt.StrSlice("open-dingtalk-ids"))
atOpenIDs := uniqueShortcutStrings(rt.StrSlice("at-open-dingtalk-ids"))
atUserIDs := uniqueShortcutStrings(rt.StrSlice("at-user-ids"))
atMobiles := uniqueShortcutStrings(rt.StrSlice("at-mobiles"))
contentType, err := messagesSendContentType(rt)
if err != nil {
return err
}
if contentType == "text" && rt.Str("markdown") != "" {
return apperrors.NewValidation("--msg-type text 必须与 --text 一起使用")
}
if contentType == "markdown" && rt.Str("text") != "" {
return apperrors.NewValidation("--msg-type markdown 必须与 --markdown 一起使用")
}
switch identity {
case "user":
if (group == "") == (openID == "") {
return apperrors.NewValidation("--identity user 时 --group 与 --open-dingtalk-id 必须且只能指定一个")
targetCount := nonEmptyStringCount(group, userID, openID)
if targetCount != 1 {
return apperrors.NewValidation("--identity user 时 --group、--user、--open-dingtalk-id 必须且只能指定一个")
}
if len(users) > 0 || len(openIDs) > 0 || rt.Str("robot-code") != "" || rt.Str("webhook-token") != "" {
return apperrors.NewValidation("--identity user 不接受 bot/webhook 凭据或批量目标")
@@ -86,9 +110,12 @@ func validateMessagesSend(rt *shortcut.RuntimeContext) error {
if len(atUserIDs) > 0 || len(atMobiles) > 0 {
return apperrors.NewValidation("--identity user 只接受 --at-open-dingtalk-ids")
}
if openID != "" && (len(atOpenIDs) > 0 || rt.Bool("at-all")) {
if (userID != "" || openID != "") && (len(atOpenIDs) > 0 || rt.Bool("at-all")) {
return apperrors.NewValidation("user 单聊不接受 @ 参数;@ 只适用于群聊")
}
if contentType != "text" && contentType != "markdown" && (len(atOpenIDs) > 0 || rt.Bool("at-all")) {
return apperrors.NewValidation("user image/file/audio/video 当前不接受 @ 参数")
}
case "bot":
if rt.Str("robot-code") == "" {
return apperrors.NewValidation("--identity bot 必须指定 --robot-code")
@@ -97,8 +124,8 @@ func validateMessagesSend(rt *shortcut.RuntimeContext) error {
if (group != "") == hasDirect {
return apperrors.NewValidation("--identity bot 时 --group 与批量单聊目标必须且只能指定一类")
}
if openID != "" || rt.Str("webhook-token") != "" {
return apperrors.NewValidation("--identity bot 不接受 --open-dingtalk-id 或 --webhook-token")
if userID != "" || openID != "" || rt.Str("webhook-token") != "" {
return apperrors.NewValidation("--identity bot 不接受 --user、--open-dingtalk-id 或 --webhook-token")
}
if len(atMobiles) > 0 {
return apperrors.NewValidation("--identity bot 不接受 --at-mobiles")
@@ -109,11 +136,14 @@ func validateMessagesSend(rt *shortcut.RuntimeContext) error {
if messagesSendIdempotencyKey(rt) != "" {
return apperrors.NewValidation("--uuid 当前仅 user 身份的下层支持")
}
if contentType != "text" && contentType != "markdown" {
return apperrors.NewValidation("--identity bot 当前下层只支持 text/markdown")
}
case "webhook":
if rt.Str("webhook-token") == "" {
return apperrors.NewValidation("--identity webhook 必须指定 --webhook-token")
}
if group != "" || openID != "" || len(users) > 0 || len(openIDs) > 0 || rt.Str("robot-code") != "" {
if group != "" || userID != "" || openID != "" || len(users) > 0 || len(openIDs) > 0 || rt.Str("robot-code") != "" {
return apperrors.NewValidation("--identity webhook 的目标由 token 所在群决定,不接受其他目标或 bot Code")
}
if len(atOpenIDs) > 0 {
@@ -122,6 +152,9 @@ func validateMessagesSend(rt *shortcut.RuntimeContext) error {
if messagesSendIdempotencyKey(rt) != "" {
return apperrors.NewValidation("--uuid 当前仅 user 身份的下层支持")
}
if contentType != "text" && contentType != "markdown" {
return apperrors.NewValidation("--identity webhook 当前下层只支持 text/markdown")
}
}
return nil
}
@@ -135,12 +168,35 @@ func executeMessagesSend(rt *shortcut.RuntimeContext) error {
}
switch identity {
case "user":
contentType, err := messagesSendContentType(rt)
if err != nil {
return err
}
group, openID, err := messagesSendUserTarget(rt)
if err != nil {
return err
}
if contentType == "image" {
content, _ := json.Marshal(map[string]string{"mediaId": rt.Str("media-id")})
params := rt.AddAIMessageTag(map[string]any{
"msgType": "image",
"content": string(content),
})
addMessagesSendUserTarget(params, group, openID)
if value := messagesSendIdempotencyKey(rt); value != "" {
params["uuid"] = value
}
return executeUnifiedMessageWrite(rt, "chat", "send_personal_message", params)
}
if contentType == "file" || contentType == "audio" || contentType == "video" {
return executeMessagesSendUserFile(rt, group, openID, contentType)
}
content, _ := json.Marshal(map[string]string{"title": title, "text": body})
params := rt.AddAIMessageTag(map[string]any{
"msgType": "markdown",
"content": string(content),
})
if group := rt.StrFirst("chat-id", "group"); group != "" {
if group != "" {
params["openConversationId"] = group
if values := uniqueShortcutStrings(rt.StrSlice("at-open-dingtalk-ids")); len(values) > 0 {
params["atOpenDingTalkIds"] = values
@@ -149,7 +205,7 @@ func executeMessagesSend(rt *shortcut.RuntimeContext) error {
params["atAll"] = true
}
} else {
params["receiverOpenDingTalkId"] = rt.Str("open-dingtalk-id")
params["receiverOpenDingTalkId"] = openID
}
if value := messagesSendIdempotencyKey(rt); value != "" {
params["uuid"] = value
@@ -241,6 +297,157 @@ func messagesSendBody(rt *shortcut.RuntimeContext) string {
return rt.StrFirst("markdown", "text")
}
func messagesSendContentType(rt *shortcut.RuntimeContext) (string, error) {
contentType := strings.ToLower(rt.Str("msg-type"))
switch {
case rt.Str("media-id") != "":
if contentType != "" && contentType != "image" {
return "", apperrors.NewValidation("--media-id 只支持 --msg-type image")
}
return "image", nil
case rt.StrFirst("file", "file-path") != "":
if contentType == "" {
return "file", nil
}
if contentType != "file" && contentType != "audio" && contentType != "video" {
return "", apperrors.NewValidation("--file 只支持 --msg-type file/audio/video")
}
return contentType, nil
default:
if contentType == "" {
if rt.Str("markdown") != "" {
return "markdown", nil
}
return "text", nil
}
if contentType != "text" && contentType != "markdown" {
return "", apperrors.NewValidation(fmt.Sprintf("--msg-type %s 需要匹配的 --media-id 或 --file", contentType))
}
return contentType, nil
}
}
func messagesSendUserTarget(rt *shortcut.RuntimeContext) (group, openID string, err error) {
group = rt.StrFirst("chat-id", "group")
openID = rt.Str("open-dingtalk-id")
if openID != "" || group != "" {
return group, openID, nil
}
userID := rt.Str("user")
data, err := rt.CallMCPData("contact", "get_user_info_by_user_ids", map[string]any{
"user_id_list": []string{userID},
})
if err != nil {
return "", "", fmt.Errorf("把 --user 解析为 openDingTalkId 失败: %w", err)
}
openID = findOpenDingTalkID(data)
if openID == "" {
return "", "", apperrors.NewValidation("无法把 --user 解析为 openDingTalkId;请改传 --open-dingtalk-id")
}
return "", openID, nil
}
func executeMessagesSendUserFile(
rt *shortcut.RuntimeContext,
group, openID, requestedType string,
) error {
rawPath := rt.StrFirst("file", "file-path")
safePath, err := apperrors.SafeInputPath(rawPath)
if err != nil {
return err
}
meta, err := helpers.BuildConversationLocalFileMeta(safePath, "", "")
if err != nil {
return err
}
targetArgs := map[string]any{}
addMessagesSendUserTarget(targetArgs, group, openID)
idempotencyKey := messagesSendIdempotencyKey(rt)
if rt.DryRun() {
return rt.Output(map[string]any{
"dry_run": true,
"executed": false,
"preview_kind": "plan",
"actionCount": 2,
"failedCount": 0,
"actions": []map[string]any{
{
"identity": "user",
"tool": "init/commit_conversation_file_upload",
"file": map[string]any{
"path": rawPath,
"name": meta.FileName,
"sizeBytes": meta.FileSize,
},
},
{
"identity": "user",
"tool": "send_personal_message",
"requestedMessageType": requestedType,
"effectiveMessageType": "file",
"target": targetArgs,
},
},
})
}
uploadContext, cancelUpload := context.WithTimeout(
rt.Command().Context(), messagesSendFileUploadTimeout)
defer cancelUpload()
commitText, err := helpers.UploadConversationLocalFile(
uploadContext, targetArgs, meta, idempotencyKey)
if err != nil {
return err
}
dentryID, spaceID, err := helpers.ParseConversationFileSendIDs(commitText)
if err != nil {
return err
}
content, _ := helpers.BuildConversationFileContent(dentryID, spaceID, meta)
params := rt.AddAIMessageTag(map[string]any{
"msgType": "file",
"content": content,
})
addMessagesSendUserTarget(params, group, openID)
if idempotencyKey != "" {
params["uuid"] = idempotencyKey
}
data, err := rt.CallMCPWriteData("chat", "send_personal_message", params)
if err != nil {
return err
}
return rt.Output(map[string]any{
"ok": true,
"identity": "user",
"tool": "send_personal_message",
"requestedMessageType": requestedType,
"effectiveMessageType": "file",
"file": map[string]any{
"path": rawPath,
"name": meta.FileName,
"sizeBytes": meta.FileSize,
},
"result": data,
})
}
func addMessagesSendUserTarget(params map[string]any, group, openID string) {
if group != "" {
params["openConversationId"] = group
return
}
params["receiverOpenDingTalkId"] = openID
}
func nonEmptyStringCount(values ...string) int {
count := 0
for _, value := range values {
if strings.TrimSpace(value) != "" {
count++
}
}
return count
}
func messagesSendIdempotencyKey(rt *shortcut.RuntimeContext) string {
return rt.StrFirst("idempotency-key", "uuid")
}
+45 -24
View File
@@ -207,21 +207,25 @@ func firstMessageValue(m map[string]any, keys ...string) any {
return nil
}
// Resources extracts actionable media references from both structured message
// fields and the textual mediaId notation returned by older DingTalk message
// APIs. Every reference publishes the exact Shortcut arguments already known
// from the message, plus ready=false and missing fields when a follow-up lookup
// is still required. This shared shape is used by list, search, mget, quoted
// messages and thread replies.
// Resources extracts actionable media and drive-file references from both
// structured message fields and the textual mediaId/fileId notation returned
// by older DingTalk message APIs. Every reference publishes the exact Shortcut
// arguments already known from the message, plus ready=false and missing fields
// when a follow-up lookup is still required. This shared shape is used by list,
// search, mget, quoted messages and thread replies.
func Resources(m map[string]any) []map[string]any {
if m == nil {
return nil
}
ids := make([]string, 0)
collectMediaIDs(m, &ids)
ids = uniqueMediaIDs(ids)
sort.Strings(ids)
if len(ids) == 0 {
mediaIDs := make([]string, 0)
collectResourceIDs(m, "mediaid", mediaIDTextRE, &mediaIDs)
mediaIDs = uniqueResourceIDs(mediaIDs)
sort.Strings(mediaIDs)
fileIDs := make([]string, 0)
collectResourceIDs(m, "fileid", fileIDTextRE, &fileIDs)
fileIDs = uniqueResourceIDs(fileIDs)
sort.Strings(fileIDs)
if len(mediaIDs) == 0 && len(fileIDs) == 0 {
return nil
}
@@ -234,8 +238,8 @@ func Resources(m map[string]any) []map[string]any {
conversationID = ""
}
out := make([]map[string]any, 0, len(ids))
for _, id := range ids {
out := make([]map[string]any, 0, len(mediaIDs)+len(fileIDs))
for _, id := range mediaIDs {
arguments := map[string]any{
"type": "mediaId",
"resource-id": id,
@@ -262,36 +266,53 @@ func Resources(m map[string]any) []map[string]any {
},
})
}
for _, id := range fileIDs {
out = append(out, map[string]any{
"type": "fileId",
"resourceId": id,
"download": map[string]any{
"shortcut": "+messages-resource-download",
"arguments": map[string]any{
"type": "fileId",
"resource-id": id,
},
"ready": true,
"missing": []string{},
},
})
}
return out
}
var mediaIDTextRE = regexp.MustCompile(`(?i)media[_-]?id\s*[:=]\s*["']?([^"'\s)\]}>,]+)`)
var fileIDTextRE = regexp.MustCompile(`(?i)\bfile[_-]?id\s*[:=]\s*["']?([^"'\s)\]}>,]+)`)
func collectMediaIDs(value any, out *[]string) {
func collectResourceIDs(value any, targetKey string, textPattern *regexp.Regexp, out *[]string) {
switch typed := value.(type) {
case map[string]any:
resourceType := strings.TrimSpace(fmt.Sprint(firstMessageValue(typed, "resourceType", "resource_type")))
for key, child := range typed {
normalizedKey := strings.ToLower(strings.NewReplacer("_", "", "-", "").Replace(key))
if normalizedKey == "mediaid" || (normalizedKey == "resourceid" && strings.EqualFold(resourceType, "mediaId")) {
if id := mediaIDScalar(child); id != "" {
if normalizedKey == targetKey ||
(normalizedKey == "resourceid" && strings.EqualFold(resourceType, targetKey)) {
if id := resourceIDScalar(child); id != "" {
*out = append(*out, id)
}
}
collectMediaIDs(child, out)
collectResourceIDs(child, targetKey, textPattern, out)
}
case []any:
for _, child := range typed {
collectMediaIDs(child, out)
collectResourceIDs(child, targetKey, textPattern, out)
}
case []map[string]any:
for _, child := range typed {
collectMediaIDs(child, out)
collectResourceIDs(child, targetKey, textPattern, out)
}
case string:
for _, match := range mediaIDTextRE.FindAllStringSubmatch(typed, -1) {
for _, match := range textPattern.FindAllStringSubmatch(typed, -1) {
if len(match) > 1 {
if id := mediaIDScalar(match[1]); id != "" {
if id := resourceIDScalar(match[1]); id != "" {
*out = append(*out, id)
}
}
@@ -300,13 +321,13 @@ func collectMediaIDs(value any, out *[]string) {
if strings.HasPrefix(trimmed, "{") || strings.HasPrefix(trimmed, "[") {
var decoded any
if json.Unmarshal([]byte(trimmed), &decoded) == nil {
collectMediaIDs(decoded, out)
collectResourceIDs(decoded, targetKey, textPattern, out)
}
}
}
}
func mediaIDScalar(value any) string {
func resourceIDScalar(value any) string {
text, ok := value.(string)
if !ok {
return ""
@@ -314,7 +335,7 @@ func mediaIDScalar(value any) string {
return strings.Trim(strings.TrimSpace(text), `"'`)
}
func uniqueMediaIDs(values []string) []string {
func uniqueResourceIDs(values []string) []string {
out := make([]string, 0, len(values))
seen := map[string]bool{}
for _, value := range values {
@@ -29,14 +29,27 @@ func TestCrossPlatformCoverageQuotedResourcesAndScalarVariants(t *testing.T) {
resources := Resources(map[string]any{
"attachments": []map[string]any{
{"resourceType": "mediaId", "resourceId": "@file-a"},
{"mediaId": 42},
{"resourceType": "fileId", "resourceId": "drive-file"},
{"mediaId": 42, "fileId": 42},
},
"content": `[文件] report.txt fileId: drive-file`,
})
if len(resources) != 1 || resources[0]["resourceId"] != "@file-a" {
if len(resources) != 2 ||
resources[0]["resourceId"] != "@file-a" ||
resources[1]["resourceId"] != "drive-file" ||
resources[1]["type"] != "fileId" {
t.Fatalf("resources = %#v", resources)
}
if got := mediaIDScalar(42); got != "" {
t.Fatalf("non-string media ID = %q", got)
fileDownload := resources[1]["download"].(map[string]any)
fileArguments := fileDownload["arguments"].(map[string]any)
if fileDownload["ready"] != true ||
fileDownload["shortcut"] != "+messages-resource-download" ||
fileArguments["type"] != "fileId" ||
fileArguments["resource-id"] != "drive-file" {
t.Fatalf("file download = %#v", fileDownload)
}
if got := resourceIDScalar(42); got != "" {
t.Fatalf("non-string resource ID = %q", got)
}
}
+1 -9
View File
@@ -172,15 +172,7 @@ func dryRunWriteError(product, tool string) error {
}
func looksReadTool(tool string) bool {
tool = strings.TrimSpace(strings.ToLower(tool))
for _, prefix := range []string{
"get_", "list_", "query_", "search_", "unread_",
} {
if strings.HasPrefix(tool, prefix) {
return true
}
}
return false
return helpers.IsReadToolName(tool)
}
func (rt *RuntimeContext) callMCPData(product, tool string, params map[string]any) (map[string]any, error) {
+9 -9
View File
@@ -435,7 +435,7 @@
},
"+messages-send": {
"disposition": "semantic_adapter",
"semantic_delta": "用统一 identity 参数路由 current-user、bot、webhook 文本/Markdown 发送;按身份校验目标与凭据,幂等键只在真实支持的 user 分支开放,媒体上传仍诚实留在 native leaf。",
"semantic_delta": "用统一 identity 参数路由 current-user、bot、webhook 发送;current-user 支持文本、Markdown、mediaId 图片、安全相对路径本地文件上传、userId 姓名解析与幂等键,bot/webhook 仍只暴露下层真实支持的文本/Markdown 能力。",
"risk": "write",
"public": true,
"reviewed": true
@@ -487,7 +487,7 @@
},
"+messages-mget": {
"disposition": "semantic_adapter",
"semantic_delta": "按最多 50 个消息 ID 批量读取并输出稳定消息投影、reaction 与 resourceRefs;可用 --download-resources 复用 HTTPS、相对路径、无覆盖和原子落盘防护,逐资源返回下载失败 ledger。",
"semantic_delta": "按最多 50 个消息 ID 批量读取并输出稳定消息投影、reaction 与 resourceRefs;可用 --download-resources 统一下载 mediaId 与 fileId,复用 HTTPS、相对路径、无覆盖和原子落盘防护,对重复资源去重并逐资源返回下载失败 ledger。",
"risk": "read",
"public": true,
"reviewed": true
@@ -542,8 +542,8 @@
"reviewed": true
},
"+messages-send-card": {
"disposition": "schema_leaf",
"semantic_delta": "创建流式卡片是一对一写入;完整卡片生命周期需由 send/update leaf 明确编排。",
"disposition": "semantic_adapter",
"semantic_delta": "既可只创建流式卡片,也可在一次调用中创建、提取 bizId、写入内容并设置流式状态;dry-run 输出两步执行计划,更新失败时保留已创建的 bizId。",
"risk": "write",
"public": true,
"reviewed": true
@@ -564,7 +564,7 @@
},
"+messages-resource-download": {
"disposition": "primary_smart",
"semantic_delta": "把临时资源 URL 解析、工作目录内安全路径、默认不覆盖、临时文件下载和原子发布封装为结构化单步结果。",
"semantic_delta": "统一承接消息 mediaId 与钉盘 fileId:分别复用 IM 临时资源 URL 和 drive.download_file,再通过工作目录内安全路径、默认不覆盖、临时文件下载和原子发布输出结构化结果。",
"risk": "read",
"public": true,
"reviewed": true
@@ -627,7 +627,7 @@
},
"+at-me": {
"disposition": "primary_smart",
"semantic_delta": "自动构造时间窗,分页拉取跨会话 @我 消息,并保留身份、引用、reaction、resourceRefs 与完整性。",
"semantic_delta": "自动构造时间窗,分页拉取跨会话 @我 消息,并保留身份、引用、reaction、resourceRefs 与完整性;可选对资源去重后安全落盘并返回逐项失败 ledger。",
"risk": "read",
"public": true,
"reviewed": true
@@ -641,7 +641,7 @@
},
"+chat-messages": {
"disposition": "primary_smart",
"semantic_delta": "统一群聊与两类单聊目标,输出稳定消息身份、引用、reaction、resourceRefs、时间边界翻页和可读正文。",
"semantic_delta": "统一群聊与两类单聊目标;省略时间时自动以当前时间向前读取最近消息,并输出稳定消息身份、引用、reaction、resourceRefs、时间边界翻页和可读正文;可选对列表内资源去重后安全落盘并返回逐项失败 ledger。",
"risk": "read",
"public": true,
"reviewed": true
@@ -677,7 +677,7 @@
},
"+search-msg": {
"disposition": "primary_smart",
"semantic_delta": "统一关键词、发送者、@对象、会话、消息类型和机器人来源过滤;支持精确时间窗、page-all、50 条一组 mget 富化,并以 failure ledger 显式报告截断或富化失败。",
"semantic_delta": "统一关键词、发送者、@对象、会话、消息类型和机器人来源过滤;展开下层按会话分组的 conversationMessagesList,支持精确时间窗、page-all、50 条一组 mget 富化,可选安全下载命中消息资源,并以 failure ledger 显式报告截断、富化或下载失败。",
"risk": "read",
"public": true,
"reviewed": true
@@ -691,7 +691,7 @@
},
"+thread-replies": {
"disposition": "primary_smart",
"semantic_delta": "接受消息列表直接返回的 threadId(兼容 topicId),拉取回复并输出稳定身份、引用、reaction、resourceRefs、可读正文和时间边界分页。",
"semantic_delta": "接受消息列表直接返回的 threadId(兼容 topicId),拉取回复并输出稳定身份、引用、reaction、resourceRefs、可读正文和时间边界分页;可选对回复资源去重后安全落盘并返回逐项失败 ledger。",
"risk": "read",
"public": true,
"reviewed": true
+8 -2
View File
@@ -19,6 +19,7 @@ import (
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
chatshortcut "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chat"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg"
)
@@ -53,16 +54,18 @@ var AtMe = shortcut.Shortcut{
"再在本地把每条消息投影成发送人、时间、内容、所在会话四个关键字段。" +
"这是纯只读操作,只做搜索与本地投影,不会发送、撤回或标记任何消息。",
Risk: shortcut.RiskRead,
Flags: []shortcut.Flag{
Flags: append([]shortcut.Flag{
{Name: "days", Type: shortcut.FlagInt, Desc: "回溯天数(可选,默认 7)", Default: "7", Required: false},
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页返回数量(默认 50)", Default: "50"},
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,翻页传上次的 nextCursor", Default: "0"},
{Name: "no-reactions", Type: shortcut.FlagBool, Desc: "不输出消息 reaction(默认输出)"},
},
}, chatshortcut.MessageResourceDownloadFlags()...),
Constraints: chatshortcut.MessageResourceDownloadConstraints(),
Tips: []string{
`dws chat +at-me`,
`dws chat +at-me --days 3`,
},
Validate: chatshortcut.ValidateMessageResourceDownload,
Execute: func(rt *shortcut.RuntimeContext) error {
// Step 1 — look-back window [now-Nd, now] in epoch millis. days defaults
// to 7; guard against non-positive overrides so the window stays sane.
@@ -99,6 +102,9 @@ var AtMe = shortcut.Shortcut{
}
payload := map[string]any{"messages": results}
chatmsg.ApplyPagination(payload, data)
if rt.Bool("download-resources") {
payload["resourceDownloads"] = chatshortcut.DownloadMessageResources(rt, items, "")
}
return rt.Output(payload)
},
}
+19 -7
View File
@@ -15,8 +15,10 @@ package smart
import (
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
chatshortcut "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chat"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg"
)
@@ -47,39 +49,43 @@ var ChatMessages = shortcut.Shortcut{
Description: "拉取某个会话(群聊或单聊)的消息列表并投影出发言人/文本/时间",
Intent: "当你想快速看某个会话里的消息(谁在什么时间说了什么),而不想拿到一大坨原始消息字段时使用;" +
"群聊传 --group(群会话 ID,openConversationId),单聊传 --user(对方 userId),两者互斥且必须二选一。" +
"可选 --time 指定起始时间、--limit 指定每页条数、--direction newer/older 控制时间方向(newer 从给定时间往现在拉,older 往以前拉)。" +
"省略 --time 时默认从当前时间向前读取最近消息;也可指定时间边界并用 --direction newer/older 控制方向。" +
"内部据此调用群聊或单聊的消息列表接口,再在本地投影出每条消息的发言人、文本和时间。" +
"这是纯只读操作,只做拉取与本地投影,不会发送或修改任何消息。",
Risk: shortcut.RiskRead,
Flags: []shortcut.Flag{
Flags: append([]shortcut.Flag{
{Name: "group", Type: shortcut.FlagString, Desc: "群会话 ID(openConversationId),与 --user 互斥"},
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "--group 的别名", Hidden: true},
{Name: "id", Type: shortcut.FlagString, Desc: "--group 的别名", Hidden: true},
{Name: "user", Type: shortcut.FlagString, Desc: "单聊对方的 userId,与 --group 互斥"},
{Name: "open-dingtalk-id", Type: shortcut.FlagString, Desc: "单聊对方的 openDingTalkId,与 --group/--user 互斥"},
{Name: "time", Type: shortcut.FlagString, Desc: "起始时间,如 \"2025-03-01 00:00:00\"(可选)"},
{Name: "time", Type: shortcut.FlagString, Desc: "时间边界,如 \"2025-03-01 00:00:00\";省略时从当前时间向前读取最近消息"},
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页拉取的消息条数(可选)"},
{Name: "size", Type: shortcut.FlagInt, Desc: "--limit 的旧版别名", Hidden: true},
{Name: "direction", Type: shortcut.FlagString, Desc: "时间方向 newer/older(可选,newer 从给定时间往现在拉,older 往以前拉)"},
{Name: "direction", Type: shortcut.FlagString, Enum: []string{"newer", "older"}, Desc: "时间方向 newer/older;省略时为 older,从时间边界向前读取"},
{Name: "no-reactions", Type: shortcut.FlagBool, Desc: "不输出消息 reaction(默认输出)"},
},
Constraints: []shortcut.Constraint{
}, chatshortcut.MessageResourceDownloadFlags()...),
Constraints: append([]shortcut.Constraint{
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"group", "conversation-id", "id", "user", "open-dingtalk-id"}},
},
}, chatshortcut.MessageResourceDownloadConstraints()...),
Tips: []string{
`dws chat +chat-messages --group <openconversation_id> --time "2025-03-01 00:00:00"`,
`dws chat +chat-messages --user <userId> --time "2025-03-01 00:00:00" --limit 50`,
`dws chat +chat-messages --group <openconversation_id> --direction older`,
},
Validate: chatshortcut.ValidateMessageResourceDownload,
Execute: func(rt *shortcut.RuntimeContext) error {
// Step 1 — build params and pick the right tool. Param keys
// (openconversation_id / userId / time / forward / limit) match the MCP server
// schema for group and direct message listing.
var tool string
params := map[string]any{}
fallbackConversationID := ""
if rt.Changed("time") && rt.Str("time") != "" {
params["time"] = rt.Str("time")
} else {
params["time"] = time.Now().Format("2006-01-02 15:04:05")
}
if limit := rt.IntFirst("limit", "size"); limit > 0 {
params["limit"] = limit
@@ -94,11 +100,14 @@ var ChatMessages = shortcut.Shortcut{
case "older":
params["forward"] = false
}
} else {
params["forward"] = false
}
if group := rt.StrFirst("group", "conversation-id", "id"); group != "" {
tool = "list_conversation_message_v2"
params["openconversation_id"] = group
fallbackConversationID = group
} else if openID := strings.TrimSpace(rt.Str("open-dingtalk-id")); openID != "" {
tool = "list_individual_chat_message"
params["openDingTalkId"] = openID
@@ -126,6 +135,9 @@ var ChatMessages = shortcut.Shortcut{
}
direction := strings.TrimSpace(strings.ToLower(rt.Str("direction")))
chatmsg.ApplyMessagePagination(payload, data, items, direction)
if rt.Bool("download-resources") {
payload["resourceDownloads"] = chatshortcut.DownloadMessageResources(rt, items, fallbackConversationID)
}
return rt.Output(payload)
},
}
+188
View File
@@ -0,0 +1,188 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package smart
import (
"bytes"
"encoding/json"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
)
func TestCrossPlatformCoverageMessageReadShortcutsPublishResourceDownloadPlans(t *testing.T) {
message := `{"openMessageId":"msg","openConversationId":"cid","content":"{\"mediaId\":\"@image\"}"}`
tests := []struct {
name string
tool string
response string
args []string
resultKey string
}{
{
name: "chat messages",
tool: "chat/list_conversation_message_v2",
response: `{"result":{"messages":[` + message + `]}}`,
args: []string{"chat", "+chat-messages", "--group", "cid"},
resultKey: "messages",
},
{
name: "search",
tool: "im/search_messages",
response: `{"result":{"messages":[` + message + `],"hasMore":false}}`,
args: []string{"chat", "+search-msg", "--query", "x", "--no-enrich"},
resultKey: "messages",
},
{
name: "at me",
tool: "chat/search_at_me_message",
response: `{"result":{"conversationMessagesList":[{"openConversationId":"cid","messages":[` + message + `]}]}}`,
args: []string{"chat", "+at-me"},
resultKey: "messages",
},
{
name: "thread replies",
tool: "chat/list_topic_replies",
response: `{"result":{"messages":[` + message + `]}}`,
args: []string{"chat", "+thread-replies", "--group", "cid", "--thread-id", "thread"},
resultKey: "replies",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
caller := &smartCoverageCaller{responses: map[string][]string{
tc.tool: {tc.response},
}}
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
args := append([]string{}, tc.args...)
args = append(args, "--download-resources", "--output-dir", "./downloads", "--dry-run")
root.SetArgs(args)
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if caller.counts[tc.tool] != 1 {
t.Fatalf("lower calls = %#v", caller.counts)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatalf("decode output: %v\n%s", err, output.String())
}
if _, ok := payload[tc.resultKey]; !ok {
t.Fatalf("payload missing %s: %#v", tc.resultKey, payload)
}
ledger, _ := payload["resourceDownloads"].(map[string]any)
if ledger["dryRun"] != true || ledger["requestedCount"] != float64(1) {
t.Fatalf("resource plan = %#v", ledger)
}
})
}
}
func TestCrossPlatformCoverageMessageReadShortcutResourceOutputValidation(t *testing.T) {
for _, args := range [][]string{
{"chat", "+chat-messages", "--group", "cid"},
{"chat", "+search-msg", "--query", "x", "--no-enrich"},
{"chat", "+at-me"},
{"chat", "+thread-replies", "--group", "cid", "--thread-id", "thread"},
} {
helpers.InitDeps(&smartCoverageCaller{})
root := newPlatformCoverageRoot()
root.SetArgs(append(args, "--download-resources", "--output-dir", "../outside"))
if err := root.Execute(); err == nil {
t.Fatalf("unsafe output accepted: %v", args)
}
}
}
func TestCrossPlatformCoverageChatMessagesDefaultsToRecentHistory(t *testing.T) {
caller := &platformCoverageCaller{}
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
before := time.Now().Add(-2 * time.Second)
root.SetArgs([]string{"chat", "+chat-messages", "--group", "cid", "--limit", "5"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
after := time.Now().Add(2 * time.Second)
if len(caller.calls) != 1 {
t.Fatalf("calls = %#v", caller.calls)
}
call := caller.calls[0]
if call.args["forward"] != false {
t.Fatalf("default forward = %#v, want false", call.args["forward"])
}
boundary, err := time.ParseInLocation("2006-01-02 15:04:05", call.args["time"].(string), time.Local)
if err != nil {
t.Fatal(err)
}
if boundary.Before(before) || boundary.After(after) {
t.Fatalf("default time = %s, want current boundary", boundary)
}
}
func TestCrossPlatformCoverageChatMessagesPreservesExplicitTime(t *testing.T) {
caller := &platformCoverageCaller{}
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
root.SetArgs([]string{
"chat", "+chat-messages",
"--group", "cid",
"--time", "2026-07-01 12:34:56",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 ||
caller.calls[0].args["time"] != "2026-07-01 12:34:56" {
t.Fatalf("explicit time call = %#v", caller.calls)
}
}
func TestCrossPlatformCoverageSearchMsgFlattensRealGroupedResponse(t *testing.T) {
original := map[string]any{"openMessageId": "m1"}
items := searchMsgItems(map[string]any{
"result": map[string]any{
"conversationMessagesList": []any{
"invalid-group",
map[string]any{"messages": "invalid"},
map[string]any{
"openConversationId": "cid",
"title": "会话",
"singleChat": true,
"messages": []any{
"invalid-message",
original,
map[string]any{
"openMessageId": "m2",
"openConversationId": "own-cid",
"conversationTitle": "own-title",
"singleChat": false,
},
},
},
},
},
})
if len(items) != 2 {
t.Fatalf("grouped items = %#v", items)
}
if items[0]["openConversationId"] != "cid" || items[0]["conversationTitle"] != "会话" ||
items[0]["singleChat"] != true {
t.Fatalf("injected group context = %#v", items[0])
}
if items[1]["openConversationId"] != "own-cid" || items[1]["conversationTitle"] != "own-title" ||
items[1]["singleChat"] != false {
t.Fatalf("message context was overwritten: %#v", items[1])
}
if _, mutated := original["openConversationId"]; mutated {
t.Fatalf("source message mutated: %#v", original)
}
if searchMsgChildMap(map[string]any{"result": "invalid"}, "result") != nil {
t.Fatal("non-map child was accepted")
}
}
+80 -9
View File
@@ -21,6 +21,7 @@ import (
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
chatshortcut "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chat"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg"
)
@@ -37,7 +38,7 @@ var SearchMsg = shortcut.Shortcut{
Intent: "当你要按关键词、发送者、@对象、会话、消息类型或机器人来源组合搜索 IM 消息时使用;默认查询近 7 天," +
"也可指定精确起止时间。--page-all 会连续拉取游标页,默认再按消息 ID 分批富化详情;任何续页或富化失败都会保留已取得结果并返回逐项失败 ledger,绝不把截断结果标成完整。",
Risk: shortcut.RiskRead,
Flags: []shortcut.Flag{
Flags: append([]shortcut.Flag{
{Name: "query", Type: shortcut.FlagString, Desc: "搜索关键词"},
{Name: "keyword", Type: shortcut.FlagString, Desc: "--query 的别名", Hidden: true},
{Name: "group", Type: shortcut.FlagString, Desc: "单个会话 openConversationId"},
@@ -65,17 +66,22 @@ var SearchMsg = shortcut.Shortcut{
{Name: "page-limit", Type: shortcut.FlagInt, Desc: "--page-all 的最大页数(1-40)", Default: "20"},
{Name: "no-enrich", Type: shortcut.FlagBool, Desc: "不再按消息 ID 批量查询完整详情"},
{Name: "no-reactions", Type: shortcut.FlagBool, Desc: "不输出命中消息的 reaction(默认输出)"},
},
Constraints: []shortcut.Constraint{
}, chatshortcut.MessageResourceDownloadFlags()...),
Constraints: append([]shortcut.Constraint{
{
Kind: shortcut.ConstraintCustom,
Flags: []string{"query", "group", "groups", "chat-id", "senders", "sender", "at-me", "is-at-me", "at-ids", "message-type", "only-robot", "conversation-type", "chat-type"},
Kind: shortcut.ConstraintAtLeastOne,
Flags: []string{"query", "keyword", "group", "conversation-id", "id", "groups", "chat-id", "senders", "sender", "at-me", "is-at-me", "at-ids", "message-type", "only-robot", "conversation-type", "chat-type"},
Description: "至少指定一个内容、身份、会话或消息类型过滤条件",
},
{
Kind: shortcut.ConstraintCustom,
Flags: []string{"start", "end", "days"},
Description: "--start 与 --end 必须同时指定;否则使用 --days 时间窗",
Flags: []string{"start"},
Description: "需与 --end 一起传",
},
{
Kind: shortcut.ConstraintCustom,
Flags: []string{"end"},
Description: "需与 --start 一起传",
},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"groups", "chat-id"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"senders", "sender"}},
@@ -83,12 +89,12 @@ var SearchMsg = shortcut.Shortcut{
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"conversation-type", "chat-type"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"limit", "page-size"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"cursor", "page-token"}},
},
}, chatshortcut.MessageResourceDownloadConstraints()...),
Tips: []string{
`dws chat +search-msg --group <openConversationId> --query "changefree"`,
`dws chat +search-msg --senders <openDingTalkId> --at-me --days 3 --page-all`,
},
Validate: validateSearchMsg,
Validate: validateSearchMsgWithResources,
Execute: func(rt *shortcut.RuntimeContext) error {
params, err := searchMsgParams(rt)
if err != nil {
@@ -200,10 +206,20 @@ var SearchMsg = shortcut.Shortcut{
if hasMore && nextCursor != "" && nextCursor != "<nil>" {
payload["nextCursor"] = nextCursor
}
if rt.Bool("download-resources") {
payload["resourceDownloads"] = chatshortcut.DownloadMessageResources(rt, messages, "")
}
return rt.Output(payload)
},
}
func validateSearchMsgWithResources(rt *shortcut.RuntimeContext) error {
if err := validateSearchMsg(rt); err != nil {
return err
}
return chatshortcut.ValidateMessageResourceDownload(rt)
}
func validateSearchMsg(rt *shortcut.RuntimeContext) error {
hasFilter := rt.StrFirst("query", "keyword", "group", "conversation-id", "id", "message-type", "conversation-type", "chat-type") != "" ||
len(rt.StrSlice("groups")) > 0 ||
@@ -392,6 +408,14 @@ func searchMsgItems(data map[string]any) []map[string]any {
if data == nil {
return nil
}
for _, root := range []map[string]any{data, searchMsgChildMap(data, "result")} {
if root == nil {
continue
}
if groups, ok := root["conversationMessagesList"].([]any); ok {
return searchMsgFlattenGroups(groups)
}
}
keys := []string{"list", "messages", "messageList", "items", "data", "records", "result"}
for _, key := range keys {
if arr, ok := data[key].([]any); ok {
@@ -408,6 +432,53 @@ func searchMsgItems(data map[string]any) []map[string]any {
return nil
}
func searchMsgChildMap(data map[string]any, key string) map[string]any {
if value, ok := data[key].(map[string]any); ok {
return value
}
return nil
}
func searchMsgFlattenGroups(groups []any) []map[string]any {
out := make([]map[string]any, 0)
for _, rawGroup := range groups {
group, ok := rawGroup.(map[string]any)
if !ok {
continue
}
messages, ok := group["messages"].([]any)
if !ok {
continue
}
conversationID := strings.TrimSpace(fmt.Sprint(group["openConversationId"]))
conversationTitle := strings.TrimSpace(fmt.Sprint(group["title"]))
singleChat, hasSingleChat := group["singleChat"]
for _, rawMessage := range messages {
message, ok := rawMessage.(map[string]any)
if !ok {
continue
}
item := make(map[string]any, len(message)+3)
for key, value := range message {
item[key] = value
}
if _, exists := item["openConversationId"]; !exists &&
conversationID != "" && conversationID != "<nil>" {
item["openConversationId"] = conversationID
}
if _, exists := item["conversationTitle"]; !exists &&
conversationTitle != "" && conversationTitle != "<nil>" {
item["conversationTitle"] = conversationTitle
}
if _, exists := item["singleChat"]; !exists && hasSingleChat {
item["singleChat"] = singleChat
}
out = append(out, item)
}
}
return out
}
func searchMsgToMaps(arr []any) []map[string]any {
out := make([]map[string]any, 0, len(arr))
for _, it := range arr {
+9 -4
View File
@@ -15,6 +15,7 @@ package smart
import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
chatshortcut "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chat"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg"
)
@@ -43,25 +44,26 @@ var ThreadReplies = shortcut.Shortcut{
"可选 --time 指定起始时间、--limit 指定每页条数,再在本地投影出每条回复的发言人、文本和回复时间。" +
"这是纯只读操作,只做拉取与本地投影,不会发送或修改任何消息。",
Risk: shortcut.RiskRead,
Flags: []shortcut.Flag{
Flags: append([]shortcut.Flag{
{Name: "group", Type: shortcut.FlagString, Desc: "群会话 ID(openConversationId,必填)", Required: true},
{Name: "thread-id", Type: shortcut.FlagString, Desc: "话题/线程 ID(可直接使用消息列表返回的 threadId)"},
{Name: "topic-id", Type: shortcut.FlagString, Desc: "--thread-id 的兼容别名"},
{Name: "time", Type: shortcut.FlagString, Desc: "起始时间,如 \"2025-03-01 00:00:00\"(可选)"},
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页拉取的回复条数(可选)"},
{Name: "no-reactions", Type: shortcut.FlagBool, Desc: "不输出回复 reaction(默认输出)"},
},
Constraints: []shortcut.Constraint{
}, chatshortcut.MessageResourceDownloadFlags()...),
Constraints: append([]shortcut.Constraint{
{
Kind: shortcut.ConstraintExactlyOne,
Flags: []string{"thread-id", "topic-id"},
Description: "--thread-id 与兼容参数 --topic-id 必须且只能指定一个",
},
},
}, chatshortcut.MessageResourceDownloadConstraints()...),
Tips: []string{
`dws chat +thread-replies --group <openconversationId> --thread-id <threadId>`,
`dws chat +thread-replies --group <openconversationId> --thread-id <threadId> --time "2025-03-01 00:00:00" --limit 20`,
},
Validate: chatshortcut.ValidateMessageResourceDownload,
Execute: func(rt *shortcut.RuntimeContext) error {
// Step 1 — fetch the topic replies. Param keys (openconversationId /
// topicId / startTime / pageSize) are copied verbatim from chat.go's
@@ -96,6 +98,9 @@ var ThreadReplies = shortcut.Shortcut{
"count": len(results),
}
chatmsg.ApplyMessagePagination(payload, data, items, "older")
if rt.Bool("download-resources") {
payload["resourceDownloads"] = chatshortcut.DownloadMessageResources(rt, items, rt.Str("group"))
}
return rt.Output(payload)
},
}