feat(im): close shortcut capability gaps
This commit is contained in:
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
|
||||
},
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user