Compare commits

..
Author SHA1 Message Date
chichuan 9dbbd64f3c Merge branch 'main' into codex/im-shortcut-gap-fill 2026-07-30 11:31:19 +08:00
github-actions[bot] 7ba12a8e4c chore: update formula for v1.0.55 [skip ci] 2026-07-30 03:11:41 +00:00
Dennis 41372b0597 Merge remote-tracking branch 'origin/main' into codex/im-shortcut-gap-fill 2026-07-30 10:32:58 +08:00
Dennis cffc48406c fix(im): resolve direct recipients via contact search 2026-07-30 10:29:39 +08:00
Dennis f9e3476d42 Merge remote-tracking branch 'origin/main' into codex/im-shortcut-gap-fill 2026-07-30 10:02:34 +08:00
Dennis 2b48f27a4b fix(im): harden shortcut review follow-ups 2026-07-29 23:35:52 +08:00
Dennis bf79a67efe fix(im): close shortcut review gaps 2026-07-29 21:25:24 +08:00
Dennis 95d262bbb2 Merge remote-tracking branch 'origin/main' into codex/im-shortcut-gap-fill 2026-07-29 19:32:18 +08:00
Dennis d5c260c7c0 fix(im): address shortcut review regressions 2026-07-29 19:31:48 +08:00
Dennis 9c297d0520 Merge remote-tracking branch 'origin/main' into codex/im-shortcut-gap-fill 2026-07-29 18:02:49 +08:00
Dennis 37230d2d4d chore(schema): refresh shortcut skill source hashes 2026-07-29 18:01:54 +08:00
Dennis fde6b59074 Merge remote-tracking branch 'origin/main' into codex/im-shortcut-gap-fill
# Conflicts:
#	internal/app/schema_shortcut_contract_test.go
#	internal/cli/schema_agent_metadata/index.json
#	internal/cli/schema_agent_metadata_audit.json
#	internal/cli/schema_catalog/catalog.json
#	internal/cli/schema_command_registry/products/chat.json
#	internal/cli/schema_hints/runtime-surface-completeness.json
#	skills/multi/dingtalk-chat/SKILL.md
#	skills/multi/dingtalk-chat/references/chat.md
2026-07-29 17:51:27 +08:00
wxianfeng 4567dd1cd6 fix(im): gate optional resource downloads at runtime 2026-07-29 15:33:01 +08:00
Dennis 75bb01bb64 docs(skill): align IM shortcut routing 2026-07-29 14:45:28 +08:00
Dennis 11a7ab8b7c fix(im): harden shortcut downloads and message context 2026-07-29 14:20:39 +08:00
Dennis 41e0fb381a feat(im): close shortcut capability gaps 2026-07-29 13:29:53 +08:00
46 changed files with 14356 additions and 1116 deletions
+11 -11
View File
@@ -1,33 +1,33 @@
class DingtalkWorkspaceCli < Formula
desc "Automate DingTalk workspace tasks from the terminal"
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
version "1.0.54"
version "1.0.55"
license "Apache-2.0"
on_macos do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.54/dws-darwin-arm64.tar.gz"
sha256 "8ae0e52cf973f6fb3df61c67a41fd11e2df417a0c815762b6060cbcb5e600c08"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55/dws-darwin-arm64.tar.gz"
sha256 "dd753bbd051e5dd007cf433b8aa211c4a221dd73dfcb0b3783fa924d09f12351"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.54/dws-darwin-amd64.tar.gz"
sha256 "11b711b9d70dea62304bf5f8206c56b4e7ea91148dafe97fb7c0f844a2a61da3"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55/dws-darwin-amd64.tar.gz"
sha256 "f465eb7ac38a8a84eac4eb821fd15424bfc6f6245a60fa695ba97a639970dd77"
end
end
on_linux do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.54/dws-linux-arm64.tar.gz"
sha256 "9c7ecb4c8cd55644b2faa73f6ce7843c0279b23793e23deb5061692ea71a0cf1"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55/dws-linux-arm64.tar.gz"
sha256 "5961be0fd551ec8e69b6fff2b1609f73486f7e6c3ffe8eb4bb99fa1ed691b401"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.54/dws-linux-amd64.tar.gz"
sha256 "8a0bc245747fc3facf98c8103c06da46852a30bff31ac93b0aa874e8c7e46db7"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55/dws-linux-amd64.tar.gz"
sha256 "051ba404a5f6a8fb15def0e0f5d9d273cf9d63f881df2fffe159f2c4ea3366e7"
end
end
resource "skills" do
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.54/dws-skills.zip"
sha256 "7450fd0115c75bfe6820c7099f348973d9353cca9d8d647c9cddcd70978a7ec0"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55/dws-skills.zip"
sha256 "bd35f674f184001f5a03c7b5fa6029ebcda54f0054e15cd608b5b5e213ce2d05"
end
def install
@@ -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)), 124; got != want {
if got, want := int(product["count"].(float64)), 129; 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)
}
}
@@ -388,6 +388,7 @@ func shortcutCustomConstraintEvidence(description string) []string {
"绝对路径",
"..",
"最多 15 个字符",
"能力矩阵",
}
evidence := make([]string, 0, len(probes))
for _, probe := range probes {
File diff suppressed because it is too large Load Diff
@@ -1,17 +1,17 @@
{
"version": 1,
"source_hash": "sha256:eb6e44775a6e85f92ca31c90c876f67543ab928d8a8bda04f07a33dc802f7028",
"surface_hash": "sha256:29d4f5f43688cc01bc2277a4528b597de4fbab89a7b2f13fe2e56df85d1c66c4",
"source_hash": "sha256:f8cb29bdbeaceae6f04cbac8a93ea77f2a84f93f8304d25878c827b959bc0ba0",
"surface_hash": "sha256:60eee8e2f37d6d9d60689efce85082798eb9ad38b7ba7c0b471c3de676a85a16",
"coverage": {
"surface_products": 26,
"products_with_metadata": 26,
"surface_tools": 840,
"tools_with_metadata": 840,
"tools_with_agent_summary": 840,
"tools_with_use_when": 840,
"tools_with_avoid_when": 840,
"tools_with_examples": 840,
"tools_with_interface_mode": 840,
"surface_tools": 845,
"tools_with_metadata": 845,
"tools_with_agent_summary": 845,
"tools_with_use_when": 845,
"tools_with_avoid_when": 845,
"tools_with_examples": 845,
"tools_with_interface_mode": 845,
"unmatched_skill_tools": 122,
"unreviewed_skill_tools": 11
},
File diff suppressed because it is too large Load Diff
+143 -13
View File
@@ -1,17 +1,17 @@
{
"version": 1,
"surface_hash": "sha256:29d4f5f43688cc01bc2277a4528b597de4fbab89a7b2f13fe2e56df85d1c66c4",
"source_hash": "sha256:c71b9bacfe2d73f5ad45151fada6fdc18bac69b266058a8b05647b6847c99835",
"surface_hash": "sha256:60eee8e2f37d6d9d60689efce85082798eb9ad38b7ba7c0b471c3de676a85a16",
"source_hash": "sha256:735bbf8e04cfc44db741e9b14ffe1698f80aac5d2662d2c1c41b0aadcee9098d",
"catalog": {
"agent_metadata": {
"products_with_metadata": 26,
"source": "embedded-skill-metadata",
"source_hash": "sha256:eb6e44775a6e85f92ca31c90c876f67543ab928d8a8bda04f07a33dc802f7028",
"surface_hash": "sha256:29d4f5f43688cc01bc2277a4528b597de4fbab89a7b2f13fe2e56df85d1c66c4",
"source_hash": "sha256:f8cb29bdbeaceae6f04cbac8a93ea77f2a84f93f8304d25878c827b959bc0ba0",
"surface_hash": "sha256:60eee8e2f37d6d9d60689efce85082798eb9ad38b7ba7c0b471c3de676a85a16",
"surface_products": 26,
"surface_tools": 840,
"tools_with_agent_summary": 840,
"tools_with_metadata": 840,
"surface_tools": 845,
"tools_with_agent_summary": 845,
"tools_with_metadata": 845,
"unmatched_skill_tools": 122,
"version": 1
},
@@ -7722,7 +7722,7 @@
"id": "chat",
"name": "群聊 / 消息 / 机器人",
"runtime": true,
"tool_count": 124,
"tool_count": 129,
"tools": [
{
"agent_metadata_source": "embedded-skill-metadata",
@@ -9800,7 +9800,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 调整回溯天数)的时间窗,搜索这段时间内 @我 的消息,再在本地把每条消息投影成发送人、时间、内容、所在会话四个关键字段。默认只读且不会发送、撤回或标记任何消息;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘,按既有安全下载约定无需交互确认。\n\n参数约束:\n - --output-dir 必须是工作目录内的相对路径,不允许绝对路径或 .. 逃逸",
"effect": "read",
"idempotency": "idempotent",
"interface_mode": "composite",
@@ -9811,7 +9811,7 @@
"risk": "low",
"title": "查最近 @我 的消息(自动算时间窗,投影发送人/时间/内容/会话)",
"use_when": [
"当你想快速看回最近谁在群里或单聊里 @了你、但不想手动把起止时间换算成毫秒、也不想记 list-mentions 的一堆参数时使用;内部按本地时区算出「最近 N 天」(默认 7 天,可用 --days 调整回溯天数)的时间窗,搜索这段时间内 @我 的消息,再在本地把每条消息投影成发送人、时间、内容、所在会话四个关键字段。这是纯只读操作,只做搜索与本地投影,不会发送、撤回或标记任何消息。"
"当你想快速看回最近谁在群里或单聊里 @了你、但不想手动把起止时间换算成毫秒、也不想记 list-mentions 的一堆参数时使用;内部按本地时区算出「最近 N 天」(默认 7 天,可用 --days 调整回溯天数)的时间窗,搜索这段时间内 @我 的消息,再在本地把每条消息投影成发送人、时间、内容、所在会话四个关键字段。默认只读且不会发送、撤回或标记任何消息;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘,按既有安全下载约定无需交互确认。"
]
},
{
@@ -10152,6 +10152,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 控制方向。内部据此调用群聊或单聊的消息列表接口,再在本地投影出每条消息的发言人、文本和时间。默认只读且不会发送或修改任何消息;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘,按既有安全下载约定无需交互确认。\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 控制方向。内部据此调用群聊或单聊的消息列表接口,再在本地投影出每条消息的发言人、文本和时间。默认只读且不会发送或修改任何消息;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘,按既有安全下载约定无需交互确认。"
]
},
{
"agent_metadata_source": "embedded-skill-metadata",
"agent_summary": "全员禁言 / 取消全员禁言",
@@ -10684,7 +10710,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",
@@ -10695,7 +10721,7 @@
"risk": "low",
"title": "根据消息 ID 批量查询消息(最多 50 条)",
"use_when": [
"当你已有一批消息 openMsgId、需要批量取回完整详情、reaction 和可执行资源引用时使用;一次最多 50 条。--download-resources 可把所有可识别 mediaId 安全下载到工作目录内,并逐资源返回成功/失败 ledger。"
"当你已有一批消息 openMsgId、需要批量取回完整详情、reaction 和可执行资源引用时使用;一次最多 50 条。--download-resources 可把所有可识别 mediaId/fileId 安全下载到工作目录内,并逐资源返回成功/失败 ledger;本地下载路径受限于工作目录、默认不覆盖同名文件,按既有安全下载约定无需交互确认。"
]
},
{
@@ -10750,6 +10776,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 图片、安全相对路径文件上传和幂等键;--user 传 userId 时包括在 --dry-run 中也会先通过通讯录关键词搜索并按 userId 精确匹配 openDingTalkId。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 - 目标、凭据和幂等参数受发送身份能力矩阵约束:user 必须指定一个群聊或单聊目标;bot 必须指定 robot-code 和一类目标;webhook 必须指定 webhook-token;幂等键仅 user 支持",
"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 live-compatible contact search with exact userId matching, 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 图片、安全相对路径文件上传和幂等键;--user 传 userId 时包括在 --dry-run 中也会先通过通讯录关键词搜索并按 userId 精确匹配 openDingTalkId。bot 支持群聊或批量单聊文本/Markdown;webhook 的目标由 token 所在群决定。不会把 user 文件能力伪装成 bot/webhook 等价能力。"
]
},
{
"agent_metadata_source": "embedded-skill-metadata",
"agent_summary": "自定义机器人 Webhook 发送群消息",
@@ -10776,6 +10828,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、单聊 openDingTalkId 严格三选一,分别使用 --group、--receiver、--receiver-open-dingtalk-id。--receiver 始终按 userId 通过通讯录关键词搜索做精确匹配,即使值以 D/d 开头也不会猜成 openDingTalkId;已有 openDingTalkId 时必须用显式参数直传。userId 包括在 --dry-run 时也会先解析。只传目标时创建卡片并返回 bizId,供后续 messages-update-card 流式更新;同时传 --content 时会自动串联创建和更新,默认以 flowStatus=3 完成,避免卡片停留在加载中。\n\n参数约束:\n - --group、--receiver、--receiver-open-dingtalk-id 必须且只能指定一个\n - --flow-status 必须在 1-5 之间,且显式指定时必须同时提供 --content",
"effect": "write",
"idempotency": "unknown",
"interface_mode": "composite",
"interface_reason": "Reviewed card lifecycle adapter: it can resolve a userId through contact search with exact matching, call create_and_send_card alone, or compose creation 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、单聊 openDingTalkId 严格三选一,分别使用 --group、--receiver、--receiver-open-dingtalk-id。--receiver 始终按 userId 通过通讯录关键词搜索做精确匹配,即使值以 D/d 开头也不会猜成 openDingTalkId;已有 openDingTalkId 时必须用显式参数直传。userId 包括在 --dry-run 时也会先解析。只传目标时创建卡片并返回 bizId,供后续 messages-update-card 流式更新;同时传 --content 时会自动串联创建和更新,默认以 flowStatus=3 完成,避免卡片停留在加载中。"
]
},
{
"agent_metadata_source": "embedded-skill-metadata",
"agent_summary": "流式更新卡片内容(最后一次 --flow-status 应为 3)",
@@ -10828,6 +10906,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,绝不把截断结果标成完整。--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘,按既有安全下载约定无需交互确认。\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,绝不把截断结果标成完整。--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘,按既有安全下载约定无需交互确认。"
]
},
{
"agent_metadata_source": "embedded-skill-metadata",
"agent_summary": "按群名直接给群发消息(自动搜群解析 openConversationId)",
@@ -10854,6 +10958,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 指定每页条数,再在本地投影出每条回复的发言人、文本和回复时间。默认只读且不会发送或修改任何消息;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘,按既有安全下载约定无需交互确认。\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 指定每页条数,再在本地投影出每条回复的发言人、文本和回复时间。默认只读且不会发送或修改任何消息;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘,按既有安全下载约定无需交互确认。"
]
},
{
"agent_metadata_source": "embedded-skill-metadata",
"agent_summary": "列出我有未读消息的会话(投影会话名/未读数/会话ID)",
@@ -26514,6 +26644,6 @@
}
],
"source": "embedded-command-catalog",
"tool_count": 840
"tool_count": 845
}
}
File diff suppressed because it is too large Load Diff
+1 -6
View File
@@ -168,7 +168,6 @@
"chat +chat-get-by-id",
"chat +chat-members-get",
"chat +chat-members-list",
"chat +chat-messages",
"chat +chat-mute-member",
"chat +chat-quit",
"chat +chat-remove-bot",
@@ -205,15 +204,11 @@
"chat +messages-reply",
"chat +messages-resource-download",
"chat +messages-resource-url",
"chat +messages-send",
"chat +messages-send-by-bot",
"chat +messages-send-card",
"chat +messages-set-pin",
"chat +messages-set-top",
"chat +messages-unset-pin",
"chat +messages-unset-top",
"chat +search-msg",
"chat +thread-replies"
"chat +messages-unset-top"
]
}
]
@@ -496,6 +496,26 @@
{
"canonical_path": "chat.set_top_conversation",
"cli_path": "chat set-top"
},
{
"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_card",
"cli_path": "chat +messages-send-card"
},
{
"canonical_path": "chat.shortcut_search_msg",
"cli_path": "chat +search-msg"
},
{
"canonical_path": "chat.shortcut_thread_replies",
"cli_path": "chat +thread-replies"
}
]
}
+73 -2
View File
@@ -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 and group/direct lower read tools. Optional downloads stay read/not_required under the existing safe local-download convention: workspace-relative paths, no overwrite by default, and atomic publication.",
"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 live-compatible contact search with exact userId matching, mediaId images, and the native init/upload/commit local-file flow.",
"reviewed": true,
"review_reason": "Reviewed against every executable identity/content/target validation branch, authenticated contact-search response shape, 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,25 @@
"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 resolve a userId through contact search with exact matching, call create_and_send_card alone, or compose creation with update_streaming_card after extracting the returned bizId.",
"parameters": {
"receiver-open-dingtalk-id": {
"property": "receiverOpenDingTalkId",
"required": false
}
},
"reviewed": true,
"review_reason": "Reviewed against authenticated contact-search resolution, create-only, two-action dry-run, create/update success, missing bizId, and partial-failure behavior that preserves the created bizId; the explicit openDingTalkId flag is bound to the lower interface's exact receiverOpenDingTalkId property.",
"cli_path": "chat +messages-send-card",
"runtime_gate": "typed_yes"
},
"chat.shortcut_messages_list_direct": {
"effect": "read",
"risk": "low",
@@ -1200,7 +1245,7 @@
"availability": "available",
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
"reviewed": true,
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command. Optional downloads stay read/not_required under the existing safe local-download convention and therefore do not create a non-interactive confirmation no-op.",
"cli_path": "chat +messages-mget",
"runtime_gate": "none"
},
@@ -1265,7 +1310,7 @@
"availability": "available",
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
"reviewed": true,
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command. Optional downloads stay read/not_required under the existing safe local-download convention and therefore do not create a non-interactive confirmation no-op.",
"cli_path": "chat +at-me",
"runtime_gate": "none"
},
@@ -1321,6 +1366,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 plus page and enrichment ledgers. Optional downloads stay read/not_required under the existing safe local-download convention and therefore do not create a non-interactive confirmation no-op.",
"cli_path": "chat +search-msg",
"runtime_gate": "none"
},
"chat.shortcut_send_to_group": {
"effect": "write",
"risk": "medium",
@@ -1334,6 +1392,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 and real list_topic_replies mapping. Optional downloads stay read/not_required under the existing safe local-download convention and therefore do not create a non-interactive confirmation no-op.",
"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": 840,
"source_tools": 845,
"matched_tools": 71
},
"tools": {
+102 -2
View File
@@ -2110,6 +2110,46 @@
"ShortcutRegistry:chat +chat-role-query-user"
]
},
"chat.shortcut_chat_messages": {
"agent_summary": "拉取某个会话(群聊或单聊)的消息列表并投影出发言人/文本/时间",
"use_when": [
"当你想快速看某个会话里的消息(谁在什么时间说了什么),而不想拿到一大坨原始消息字段时使用;群聊传 --group(群会话 ID,openConversationId),单聊传 --user(对方 userId),两者互斥且必须二选一。省略 --time 时默认从当前时间向前读取最近消息;也可指定时间边界并用 --direction newer/older 控制方向。内部据此调用群聊或单聊的消息列表接口,再在本地投影出每条消息的发言人、文本和时间。默认只读且不会发送或修改任何消息;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘,按既有安全下载约定无需交互确认。"
],
"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 图片、安全相对路径文件上传和幂等键;--user 传 userId 时包括在 --dry-run 中也会先通过通讯录关键词搜索并按 userId 精确匹配 openDingTalkId。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, live-compatible contact search with exact userId matching, 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、单聊 openDingTalkId 严格三选一,分别使用 --group、--receiver、--receiver-open-dingtalk-id。--receiver 始终按 userId 通过通讯录关键词搜索做精确匹配,即使值以 D/d 开头也不会猜成 openDingTalkId;已有 openDingTalkId 时必须用显式参数直传。userId 包括在 --dry-run 时也会先解析。只传目标时创建卡片并返回 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, live-compatible contact search with exact userId matching, explicit userId/openDingTalkId routing, 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 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"
@@ -2265,7 +2325,7 @@
"chat.shortcut_at_me": {
"agent_summary": "查最近 @我 的消息(自动算时间窗,投影发送人/时间/内容/会话)",
"use_when": [
"当你想快速看回最近谁在群里或单聊里 @了你、但不想手动把起止时间换算成毫秒、也不想记 list-mentions 的一堆参数时使用;内部按本地时区算出「最近 N 天」(默认 7 天,可用 --days 调整回溯天数)的时间窗,搜索这段时间内 @我 的消息,再在本地把每条消息投影成发送人、时间、内容、所在会话四个关键字段。这是纯只读操作,只做搜索与本地投影,不会发送、撤回或标记任何消息。"
"当你想快速看回最近谁在群里或单聊里 @了你、但不想手动把起止时间换算成毫秒、也不想记 list-mentions 的一堆参数时使用;内部按本地时区算出「最近 N 天」(默认 7 天,可用 --days 调整回溯天数)的时间窗,搜索这段时间内 @我 的消息,再在本地把每条消息投影成发送人、时间、内容、所在会话四个关键字段。默认只读且不会发送、撤回或标记任何消息;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘,按既有安全下载约定无需交互确认。"
],
"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,绝不把截断结果标成完整。--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘,按既有安全下载约定无需交互确认。"
],
"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 指定每页条数,再在本地投影出每条回复的发言人、文本和回复时间。默认只读且不会发送或修改任何消息;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘,按既有安全下载约定无需交互确认。"
],
"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": [
+42
View File
@@ -13,6 +13,7 @@ import (
"strconv"
"strings"
"time"
"unicode"
"unicode/utf8"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
@@ -289,6 +290,47 @@ func normalizeAtPlaceholders(text string, ids []string, wrapAngle bool) string {
return text
}
// NormalizeMessageMentions applies the placeholder convention required by the
// selected sender identity and ensures a declared @all is present in the body.
// Current-user messages use <@id>/<@all>; bot and webhook messages use
// @id/@mobile/@all.
func NormalizeMessageMentions(text string, ids []string, atAll, wrapAngle bool) string {
text = normalizeAtPlaceholders(text, ids, wrapAngle)
allPlaceholder := "@all"
if wrapAngle {
text = normalizeAtPlaceholders(text, []string{"all"}, true)
allPlaceholder = "<@all>"
} else {
text = strings.ReplaceAll(text, "<@all>", "@all")
}
if atAll && !containsMessageMention(text, allPlaceholder) {
text = allPlaceholder + " " + text
}
return text
}
func containsMessageMention(text, placeholder string) bool {
if strings.HasPrefix(placeholder, "<") {
return strings.Contains(text, placeholder)
}
for searchFrom := 0; ; {
offset := strings.Index(text[searchFrom:], placeholder)
if offset < 0 {
return false
}
end := searchFrom + offset + len(placeholder)
if end == len(text) {
return true
}
next, _ := utf8.DecodeRuneInString(text[end:])
if !unicode.IsLetter(next) && !unicode.IsDigit(next) &&
next != '_' && next != '-' {
return true
}
searchFrom = end
}
}
func resolveOpenDingTalkID(ctx context.Context, value string) (string, error) {
ids, err := resolveOpenDingTalkIDs(ctx, []string{value})
if err != nil {
@@ -114,6 +114,18 @@ func TestCrossPlatformCoverageChatDirectionAndScalarCoverage(t *testing.T) {
for _, wrap := range []bool{true, false} {
_ = normalizeAtPlaceholders("hello @u1 <@u2>", []string{"", "u1", "u2"}, wrap)
}
if got := NormalizeMessageMentions("hello @u1", []string{"u1"}, true, true); got != "<@all> hello <@u1>" {
t.Fatalf("current-user mention normalization = %q", got)
}
if got := NormalizeMessageMentions("<@all> <@u1>", []string{"u1"}, true, false); got != "@all @u1" {
t.Fatalf("bot mention normalization = %q", got)
}
if got := NormalizeMessageMentions("@alliance hello", nil, true, false); got != "@all @alliance hello" {
t.Fatalf("bot @all token detection = %q", got)
}
if got := NormalizeMessageMentions("hello @all", nil, true, false); got != "hello @all" {
t.Fatalf("trailing bot @all detection = %q", got)
}
}
func TestCrossPlatformCoverageChatContactMappingCoverage(t *testing.T) {
+43
View File
@@ -0,0 +1,43 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
package helpers
import "context"
// ConversationLocalFileMeta exposes the already-reviewed native chat upload
// metadata to built-in semantic Shortcuts. It remains an alias so the native
// Cobra leaf and Shortcut use exactly the same upload implementation.
type ConversationLocalFileMeta = conversationLocalFileMeta
// BuildConversationLocalFileMeta validates a local file and computes the
// metadata required by DingTalk's conversation-file upload flow.
func BuildConversationLocalFileMeta(filePath, fileName, md5Value string) (ConversationLocalFileMeta, error) {
return buildConversationLocalFileMeta(filePath, fileName, md5Value)
}
// UploadConversationLocalFile executes the existing init -> HTTP upload ->
// commit flow and returns the commit response for message-content assembly.
func UploadConversationLocalFile(
ctx context.Context,
targetArgs map[string]any,
meta ConversationLocalFileMeta,
uuid string,
) (string, error) {
return uploadConversationLocalFile(ctx, targetArgs, meta, uuid)
}
// ParseConversationFileSendIDs extracts the committed dentry and space IDs.
func ParseConversationFileSendIDs(text string) (int64, int64, error) {
return parseConversationFileSendIDs(text)
}
// BuildConversationFileContent renders the exact file-message content accepted
// by send_personal_message.
func BuildConversationFileContent(
dentryID, spaceID int64,
meta ConversationLocalFileMeta,
) (string, error) {
return buildConversationFileContent(dentryID, spaceID, meta)
}
+87
View File
@@ -0,0 +1,87 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package helpers
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
)
func TestCrossPlatformCoverageConversationFileShortcutWrappersReuseNativeFlow(t *testing.T) {
file := filepath.Join(t.TempDir(), "report.txt")
if err := os.WriteFile(file, []byte("content"), 0o600); err != nil {
t.Fatal(err)
}
meta, err := BuildConversationLocalFileMeta(file, "", "")
if err != nil {
t.Fatal(err)
}
if meta.FileName != "report.txt" || meta.FileSize != 7 {
t.Fatalf("meta = %#v", meta)
}
content, err := BuildConversationFileContent(11, 22, meta)
if err != nil || !strings.Contains(content, `"dentryId":11`) {
t.Fatalf("content = %q, %v", content, err)
}
dentryID, spaceID, err := ParseConversationFileSendIDs(`{"result":{"dentryId":11,"spaceId":22}}`)
if err != nil || dentryID != 11 || spaceID != 22 {
t.Fatalf("ids = %d/%d, %v", dentryID, spaceID, err)
}
oldPut := httpPutFile
t.Cleanup(func() { httpPutFile = oldPut })
var putPath string
httpPutFile = func(_ context.Context, _ string, _ map[string]string, localPath string, size int64) error {
putPath = localPath
if size != 7 {
t.Errorf("upload size = %d", size)
}
return nil
}
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"resourceUrl":"https://upload.example.test/file","uploadKey":"key"}`},
{text: `{"result":{"dentryId":11,"spaceId":22}}`},
}}
installScriptedCaller(t, caller)
commit, err := UploadConversationLocalFile(
context.Background(),
map[string]any{"openConversationId": "cid"},
meta,
"uuid",
)
if err != nil || !strings.Contains(commit, `"dentryId":11`) || putPath != file || caller.calls != 2 {
t.Fatalf("upload = %q, path=%q calls=%d err=%v", commit, putPath, caller.calls, err)
}
}
func TestCrossPlatformCoverageReadToolNameContractAndHelperBoundary(t *testing.T) {
for tool, want := range map[string]bool{
" get_conversation ": true,
"LIST_MESSAGES": true,
"query_send_status": true,
"search_messages": true,
"unread_message_conversation_list": true,
"send_personal_message": false,
"": false,
} {
if got := IsReadToolName(tool); got != want {
t.Errorf("IsReadToolName(%q) = %v, want %v", tool, got, want)
}
}
caller := &helpersReadCaller{
helpersCoreCaller: &helpersCoreCaller{format: "json", dry: true},
readResult: textToolResult(`{"success":true}`),
}
installHelpersCoreDeps(t, caller)
if _, err := CallMCPReadToolTextOnServer("chat", "send_personal_message", nil); err == nil {
t.Fatal("write tool was accepted by the read helper boundary")
}
if caller.readCalls != 0 || caller.calls != 0 {
t.Fatalf("rejected write reached caller: read=%d regular=%d", caller.readCalls, caller.calls)
}
}
+22
View File
@@ -156,6 +156,12 @@ func callMCPToolReturnTextOnServer(ctx context.Context, serverID, toolName strin
// returning a synthetic dry-run envelope that looks like business data.
func CallMCPReadToolTextOnServer(serverID, toolName string, args map[string]any) (string, error) {
ctx := context.Background()
if !IsReadToolName(toolName) {
return "", &CLIError{
Code: CodeMCPToolError,
Message: fmt.Sprintf("tool %q is not allowed on the dry-run read channel", toolName),
}
}
if deps == nil || deps.Caller == nil {
return "", &CLIError{
Code: CodeMCPToolError,
@@ -176,6 +182,22 @@ func CallMCPReadToolTextOnServer(serverID, toolName string, args map[string]any)
return parseMCPToolTextResult(serverID, toolName, result, err)
}
// IsReadToolName is the fail-closed naming contract for the dry-run read
// channel. Both the Shortcut runtime and the helper boundary enforce it so a
// future direct helper caller cannot accidentally route a write tool through
// ReadToolCaller.
func IsReadToolName(toolName string) bool {
toolName = strings.TrimSpace(strings.ToLower(toolName))
for _, prefix := range []string{
"get_", "list_", "query_", "search_", "unread_",
} {
if strings.HasPrefix(toolName, prefix) {
return true
}
}
return false
}
func parseMCPToolTextResult(serverID, toolName string, result *edition.ToolResult, err error) (string, error) {
if err != nil {
if patErr := reclassifyPATFromError(err); patErr != nil {
+335 -86
View File
@@ -14,9 +14,10 @@
package chat
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
@@ -318,7 +319,7 @@ func listMessageProjectOneWithReactions(m map[string]any, includeReactions bool)
if quoted := chatmsg.QuotedMessage(m); len(quoted) > 0 {
row["quotedMessage"] = quoted
}
if resources := chatmsg.Resources(m); len(resources) > 0 {
if resources := chatmsg.ResourcesDeep(m); len(resources) > 0 {
row["resourceRefs"] = resources
}
projectForwarded := func(item map[string]any) map[string]any {
@@ -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,194 @@ 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)...)
resources = append(resources, chatmsg.ResourcesDeep(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"]))
if canonicalType, ok := canonicalMessageResourceType(resourceType); ok {
resourceType = canonicalType
}
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 strings.EqualFold(resourceType, "mediaId") {
conversationID := strings.TrimSpace(fmt.Sprint(arguments["open-conversation-id"]))
key += "\x00" + messageID + "\x00" + conversationID
}
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,
}
}
if len(resources) == 0 {
return map[string]any{
"ok": true,
"partial": false,
"discoveredCount": discoveredCount,
"requestedCount": 0,
"deduplicatedCount": discoveredCount,
"downloadedCount": 0,
"failedCount": 0,
"downloads": []map[string]any{},
"failures": []map[string]any{},
}
}
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": len(resources),
"downloads": []map[string]any{},
"failures": []map[string]any{{
"stage": "output-directory",
"affectedCount": len(resources),
"error": fmt.Sprintf("读取工作目录失败: %v", err),
}},
}
}
outputDir := strings.TrimRight(rt.Str("output-dir"), `/\`) + string(os.PathSeparator)
outputDir := strings.TrimRight(rt.Str("output-dir"), `/\`)
downloads := make([]map[string]any, 0, len(resources))
failures := make([]map[string]any, 0)
downloadedNames := map[string]bool{}
for _, resource := range resources {
resourceType := strings.TrimSpace(fmt.Sprint(resource["type"]))
if canonicalType, ok := canonicalMessageResourceType(resourceType); ok {
resourceType = canonicalType
}
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)
filename := resourceDownloadFilename(resourceURL, preferredName)
filename = disambiguateResourceDownloadFilename(filename, downloadedNames)
output := filepath.Join(outputDir, filename)
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 +770,47 @@ 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
}
downloadedNames[strings.ToLower(filepath.Base(relativePath))] = true
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 disambiguateResourceDownloadFilename(filename string, used map[string]bool) string {
if !used[strings.ToLower(filename)] {
return filename
}
extension := filepath.Ext(filename)
stem := strings.TrimSuffix(filename, extension)
for sequence := 2; ; sequence++ {
candidate := fmt.Sprintf("%s (%d)%s", stem, sequence, extension)
if !used[strings.ToLower(candidate)] {
return candidate
}
}
}
func uniqueShortcutStrings(values []string) []string {
@@ -888,41 +1008,170 @@ 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、单聊 openDingTalkId 严格三选一,分别使用 --group、--receiver、--receiver-open-dingtalk-id。--receiver 始终按 userId 通过通讯录关键词搜索做精确匹配,即使值以 D/d 开头也不会猜成 openDingTalkId;已有 openDingTalkId 时必须用显式参数直传。userId 包括在 --dry-run 时也会先解析。只传目标时创建卡片并返回 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: "group", Type: shortcut.FlagString, Desc: "群 openConversationId(与两个单聊接收者参数互斥)"},
{Name: "receiver", Type: shortcut.FlagString, Desc: "单聊接收者 userId(与 --group/--receiver-open-dingtalk-id 互斥);始终通过通讯录搜索精确匹配 openDingTalkId,包括 --dry-run 和 D/d 开头的 userId"},
{Name: "receiver-open-dingtalk-id", Type: shortcut.FlagString, Desc: "单聊接收者 openDingTalkId(与 --group/--receiver 互斥);显式直传且不做通讯录解析"},
{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.ConstraintExactlyOne, Flags: []string{"group", "receiver", "receiver-open-dingtalk-id"}},
{
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")
if group == "" && receiver == "" {
return fmt.Errorf("--group 或 --receiver 必填其一")
}
if group != "" && receiver != "" {
return fmt.Errorf("--group 与 --receiver 互斥")
}
receiverOpenID := rt.Str("receiver-open-dingtalk-id")
params := map[string]any{}
if group != "" {
switch {
case group != "":
params["openConversationId"] = group
} else {
params["receiverUid"] = receiver
case receiver != "":
openID, err := resolveUserOpenDingTalkID(rt, receiver)
if err != nil {
return err
}
params["receiverOpenDingTalkId"] = openID
default:
params["receiverOpenDingTalkId"] = receiverOpenID
}
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:
directKeys := []string{"bizId", "bizID", "biz_id"}
for _, key := range directKeys {
if candidate, ok := typed[key].(string); ok && strings.TrimSpace(candidate) != "" {
candidate = strings.TrimSpace(candidate)
return candidate
}
}
// Prefer documented response envelopes before scanning extension fields.
// Map iteration order is deliberately randomized by Go, so an unordered
// recursive walk could select a stale metadata bizId.
envelopeKeys := []string{"result", "data", "card", "response"}
visited := make(map[string]struct{}, len(directKeys)+len(envelopeKeys))
for _, key := range directKeys {
visited[key] = struct{}{}
}
for _, key := range envelopeKeys {
visited[key] = struct{}{}
if candidate := findCardBizID(typed[key]); candidate != "" {
return candidate
}
}
remainingKeys := make([]string, 0, len(typed))
for key := range typed {
if _, ok := visited[key]; !ok {
remainingKeys = append(remainingKeys, key)
}
}
sort.Strings(remainingKeys)
for _, key := range remainingKeys {
if candidate := findCardBizID(typed[key]); 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",
@@ -4,10 +4,11 @@
package chat
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
@@ -25,7 +26,7 @@ func TestCrossPlatformCoverageListMessageRichProjection(t *testing.T) {
"content": `{"mediaId":"@image"}`,
"quotedMessage": map[string]any{
"openMessageId": "quoted",
"content": "quoted",
"content": `{"mediaId":"@quoted-image"}`,
},
},
}}})
@@ -37,13 +38,23 @@ func TestCrossPlatformCoverageListMessageRichProjection(t *testing.T) {
t.Errorf("projection missing %s: %#v", key, rows[0])
}
}
resources := rows[0]["resourceRefs"].([]map[string]any)
if len(resources) != 2 {
t.Fatalf("projected resources = %#v", resources)
}
quotedArgs := resources[1]["download"].(map[string]any)["arguments"].(map[string]any)
if resources[1]["resourceId"] != "@quoted-image" ||
quotedArgs["message-id"] != "quoted" ||
quotedArgs["open-conversation-id"] != "cid" {
t.Fatalf("quoted resource context = %#v", resources[1])
}
}
func TestCrossPlatformCoverageMgetResourceDownloadOutcomes(t *testing.T) {
baseArgs := []string{"chat", "+messages-mget", "--msg-ids", "msg", "--download-resources"}
baseArgs := []string{"chat", "+messages-mget", "--msg-ids", "msg", "--download-resources", "--yes"}
readyMget := `{"result":[{"openMessageId":"msg","openConversationId":"cid","content":"{\"mediaId\":\"@file\"}"}]}`
missingContextMget := `{"result":[{"content":"{\"mediaId\":\"@file\"}"}]}`
validInfo := `{"result":{"resourceUrl":"https://example.test/resource.bin"}}`
validInfo := `{"result":{"resourceUrl":"https://download.dingtalk.com/resource.bin"}}`
t.Run("dry run", func(t *testing.T) {
helpers.InitDeps(&larkAlignmentCaller{responses: map[string]string{
@@ -62,9 +73,53 @@ func TestCrossPlatformCoverageMgetResourceDownloadOutcomes(t *testing.T) {
"im/list_messages_by_ids": readyMget,
}})
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
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)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
ledger, _ := payload["resourceDownloads"].(map[string]any)
if ledger["requestedCount"] != float64(1) ||
ledger["failedCount"] != ledger["requestedCount"] {
t.Fatalf("getwd ledger = %#v", ledger)
}
})
t.Run("zero resources skip getwd", func(t *testing.T) {
resetResourceDownloadHooks(t)
getwdCalled := false
resourceGetwd = func() (string, error) {
getwdCalled = true
return "", errors.New("getwd")
}
helpers.InitDeps(&larkAlignmentCaller{responses: map[string]string{
"im/list_messages_by_ids": `{"result":[{"openMessageId":"msg","openConversationId":"cid","content":"plain text"}]}`,
}})
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs(baseArgs)
if err := root.Execute(); err != nil {
t.Fatalf("zero-resource download error = %v", err)
}
if getwdCalled {
t.Fatal("zero-resource download unnecessarily read the working directory")
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
ledger, _ := payload["resourceDownloads"].(map[string]any)
failures, _ := ledger["failures"].([]any)
if ledger["ok"] != true ||
ledger["requestedCount"] != float64(0) ||
ledger["failedCount"] != float64(0) ||
len(failures) != 0 {
t.Fatalf("zero-resource ledger = %#v", ledger)
}
})
@@ -120,3 +175,48 @@ func TestCrossPlatformCoverageMgetResourceDownloadOutcomes(t *testing.T) {
})
}
}
func TestCrossPlatformCoverageMgetDownloadRunsWithoutConfirmation(t *testing.T) {
resetResourceDownloadHooks(t)
t.Chdir(t.TempDir())
resourceDownload = func(
_ context.Context,
_ *http.Client,
_ string,
_ map[string]string,
dest string,
_ bool,
) (int64, error) {
return 7, nil
}
fake := &larkAlignmentCaller{responses: map[string]string{
"im/list_messages_by_ids": `{"result":[{"openMessageId":"msg","openConversationId":"cid","content":"{\"mediaId\":\"@file\"}"}]}`,
"im/get_resource_download_url": `{"result":{"resourceUrl":"https://download.dingtalk.com/resource.bin"}}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetIn(bytes.NewBuffer(nil))
root.SetArgs([]string{
"chat", "+messages-mget",
"--msg-ids", "msg",
"--download-resources",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 2 ||
fake.calls[0].tool != "list_messages_by_ids" ||
fake.calls[1].tool != "get_resource_download_url" {
t.Fatalf("download calls = %#v", fake.calls)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
ledger, _ := payload["resourceDownloads"].(map[string]any)
if ledger["downloadedCount"] != float64(1) || ledger["failedCount"] != float64(0) {
t.Fatalf("download ledger = %#v", ledger)
}
}
@@ -120,17 +120,17 @@ func TestCrossPlatformCoverageChatCreateAndReplyFailures(t *testing.T) {
},
{
name: "explicit sender lookup",
caller: &larkAlignmentCaller{failProductTool: "contact/get_user_info_by_user_ids"},
caller: &larkAlignmentCaller{failProductTool: "contact/search_contact_by_key_word"},
args: []string{"chat", "+messages-reply", "--conversation-id", "cid", "--message-id", "msg", "--ref-sender", "user-id", "--text", "收到", "--yes"},
wantError: "解析为 openDingTalkId",
},
{
name: "explicit sender unresolved",
caller: &larkAlignmentCaller{responses: map[string]string{
"contact/get_user_info_by_user_ids": `{"result":[]}`,
"contact/search_contact_by_key_word": `{"result":[]}`,
}},
args: []string{"chat", "+messages-reply", "--conversation-id", "cid", "--message-id", "msg", "--ref-sender", "user-id", "--text", "收到", "--yes"},
wantError: "无法把 --ref-sender",
wantError: "没有精确匹配",
},
{
name: "referenced message lookup",
@@ -234,7 +234,7 @@ func TestCrossPlatformCoverageFlagAndMgetValidation(t *testing.T) {
fake := &larkAlignmentCaller{failProductTool: "im/list_messages_by_ids"}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetArgs([]string{"chat", "+messages-mget", "--msg-ids", "msg"})
root.SetArgs([]string{"chat", "+messages-mget", "--msg-ids", "msg", "--yes"})
if err := root.Execute(); err == nil {
t.Fatal("mget lower error was swallowed")
}
+887
View File
@@ -0,0 +1,887 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package chat
import (
"bytes"
"context"
"encoding/json"
"errors"
"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 TestMessagesSendPublishesCompleteIdentityConstraintInputs(t *testing.T) {
want := []string{
"identity", "as", "group", "chat-id", "user", "open-dingtalk-id",
"users", "open-dingtalk-ids", "robot-code", "webhook-token",
"uuid", "idempotency-key",
}
var got []string
for _, constraint := range MessagesSend.Constraints {
if constraint.Kind == shortcut.ConstraintCustom {
got = constraint.Flags
break
}
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("identity constraint flags = %#v, want %#v", got, want)
}
wantSet := make(map[string]bool, len(want))
for _, name := range want {
wantSet[name] = true
}
for _, flag := range MessagesSend.Flags {
if !wantSet[flag.Name] {
continue
}
if !strings.Contains(flag.Desc, "能力矩阵") {
t.Errorf("--%s does not publish identity matrix semantics: %q", flag.Name, flag.Desc)
}
if flag.Name == "user" && !strings.Contains(flag.Desc, "--dry-run") {
t.Errorf("--user does not publish dry-run contact resolution: %q", flag.Desc)
}
}
}
func TestCrossPlatformCoverageSafeResourceDownloadsStayReadOnly(t *testing.T) {
for _, command := range []shortcut.Shortcut{MessagesMget, MessagesResourceDownload} {
if command.Risk != shortcut.RiskRead {
t.Errorf("%s risk contract = %q", command.Command, command.Risk)
}
}
}
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 != "search_contact_by_key_word" {
t.Fatalf("resolution call = %#v", fake.calls[0])
}
if got := fake.calls[0].args["keyword"]; got != "user-id" {
t.Fatalf("resolution keyword = %#v, want user-id", got)
}
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/search_contact_by_key_word"}},
{"no exact user id", &larkAlignmentCaller{responses: map[string]string{
"contact/search_contact_by_key_word": `{"result":[{"userId":"other-user","openDingTalkId":"D-other"}]}`,
}}},
{"missing open id", &larkAlignmentCaller{responses: map[string]string{
"contact/search_contact_by_key_word": `{"result":[{"userId":"user-id","name":"Resolved User"}]}`,
}}},
{"ambiguous open id", &larkAlignmentCaller{responses: map[string]string{
"contact/search_contact_by_key_word": `{"result":[{"userId":"user-id","openDingTalkId":"D-one"},{"userId":"user-id","openDingTalkId":"D-two"}]}`,
}}},
} {
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 TestCrossPlatformCoverageMessagesSendCardResolvesReceiverForLowerTool(t *testing.T) {
fake := &larkAlignmentCaller{}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetArgs([]string{
"chat", "+messages-send-card",
"--receiver", "d-user-id",
"--yes",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 2 ||
fake.calls[0].product != "contact" ||
fake.calls[0].tool != "search_contact_by_key_word" ||
fake.calls[1].product != "im" ||
fake.calls[1].tool != "create_and_send_card" {
t.Fatalf("card receiver calls = %#v", fake.calls)
}
if got := fake.calls[1].args["receiverOpenDingTalkId"]; got != "D-resolved" {
t.Fatalf("receiverOpenDingTalkId = %#v, want D-resolved", got)
}
if got := fake.calls[0].args["keyword"]; got != "d-user-id" {
t.Fatalf("D/d-prefixed userId resolution args = %#v", fake.calls[0].args)
}
if _, exists := fake.calls[1].args["receiverUid"]; exists {
t.Fatalf("obsolete receiverUid leaked to lower tool: %#v", fake.calls[1].args)
}
}
func TestCrossPlatformCoverageMessagesSendCardUsesExplicitOpenReceiver(t *testing.T) {
fake := &larkAlignmentCaller{}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetArgs([]string{
"chat", "+messages-send-card",
"--receiver-open-dingtalk-id", "D-direct",
"--yes",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 1 ||
fake.calls[0].product != "im" ||
fake.calls[0].tool != "create_and_send_card" {
t.Fatalf("card open receiver calls = %#v", fake.calls)
}
if got := fake.calls[0].args["receiverOpenDingTalkId"]; got != "D-direct" {
t.Fatalf("receiverOpenDingTalkId = %#v, want D-direct", got)
}
}
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) != 1 ||
fake.calls[0].product != "contact" ||
fake.calls[0].tool != "search_contact_by_key_word" {
t.Fatalf("card dry-run receiver resolution calls = %#v", fake.calls)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
actions, _ := payload["actions"].([]any)
create, _ := actions[0].(map[string]any)
arguments, _ := create["arguments"].(map[string]any)
if payload["actionCount"] != float64(2) ||
arguments["receiverOpenDingTalkId"] != "D-resolved" {
t.Fatalf("card plan = %#v", payload)
}
})
t.Run("receiver resolution error", func(t *testing.T) {
fake := &larkAlignmentCaller{failProductTool: "contact/search_contact_by_key_word"}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetArgs([]string{
"chat", "+messages-send-card",
"--receiver", "user-id",
"--yes",
})
err := root.Execute()
if err == nil || !strings.Contains(err.Error(), "解析为 openDingTalkId 失败") {
t.Fatalf("receiver resolution error = %v", err)
}
if len(fake.calls) != 1 || fake.calls[0].tool != "search_contact_by_key_word" {
t.Fatalf("receiver resolution calls = %#v", fake.calls)
}
})
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"},
{"--group", "cid", "--receiver-open-dingtalk-id", "D-direct"},
{"--receiver", "user-id", "--receiver-open-dingtalk-id", "D-direct"},
} {
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"},
{map[string]any{
"metadata": map[string]any{"bizId": "stale"},
"result": map[string]any{"bizId": "current"},
}, "current"},
{map[string]any{
"extension": map[string]any{"bizId": "fallback"},
}, "fallback"},
{map[string]any{
"bizId": map[string]any{"x": 1},
"result": map[string]any{"bizId": "typed"},
}, "typed"},
{map[string]any{"bizId": map[string]any{"x": 1}}, ""},
{`{"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 TestCrossPlatformCoverageMessageResourceDownloadKeepsNestedMessageContext(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://download.dingtalk.com/resource.bin"}}`,
}}
helpers.InitDeps(fake)
message := map[string]any{
"openMessageId": "msg",
"content": `{"mediaId":"@same","nested":{"mediaId":"@same"}}`,
"quotedMessage": map[string]any{
"openMessageId": "msg-quoted",
"content": `{"mediaId":"@quoted"}`,
},
}
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, 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"] != 5 || ledger["requestedCount"] != 3 ||
ledger["deduplicatedCount"] != 2 || ledger["downloadedCount"] != 3 {
t.Fatalf("download ledger = %#v", ledger)
}
if len(fake.calls) != 3 {
t.Fatalf("resource lookup = %#v", fake.calls)
}
gotMessageIDs := map[string]bool{}
for _, call := range fake.calls {
if call.args["openConversationId"] != "cid-fallback" {
t.Errorf("resource conversation = %#v", call.args)
}
gotMessageIDs[call.args["openMessageId"].(string)] = true
}
for _, messageID := range []string{"msg", "msg-quoted", "msg-2"} {
if !gotMessageIDs[messageID] {
t.Errorf("missing resource lookup for %q: %#v", messageID, 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://download.dingtalk.com/opaque","fileName":"fixture.txt"}}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetArgs([]string{
"chat", "+messages-resource-download",
"--type", "fileId",
"--resource-id", "drive-file",
"--output", "./downloads/",
"--yes",
})
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://download.dingtalk.com/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 TestCrossPlatformCoverageResourceDownloadFilenameCollisionSequence(t *testing.T) {
used := map[string]bool{}
first := disambiguateResourceDownloadFilename("a.txt", used)
used[strings.ToLower(first)] = true
second := disambiguateResourceDownloadFilename("a.txt", used)
used[strings.ToLower(second)] = true
third := disambiguateResourceDownloadFilename("a (2).txt", used)
if first != "a.txt" || second != "a (2).txt" || third != "a (2) (2).txt" {
t.Fatalf("collision sequence = %q, %q, %q", first, second, third)
}
}
func TestCrossPlatformCoverageFailedResourceDownloadDoesNotConsumeFilename(t *testing.T) {
resetResourceDownloadHooks(t)
t.Chdir(t.TempDir())
destinations := make([]string, 0, 2)
attempt := 0
resourceDownload = func(
_ context.Context,
_ *http.Client,
_ string,
_ map[string]string,
dest string,
_ bool,
) (int64, error) {
destinations = append(destinations, filepath.Base(dest))
attempt++
if attempt == 1 {
return 0, errors.New("fixture download failed")
}
return 1, nil
}
helpers.InitDeps(&larkAlignmentCaller{responses: map[string]string{
"drive/download_file": `{"result":{"downloadUrl":"https://download.dingtalk.com/opaque","fileName":"fixture.txt"}}`,
}})
var ledger map[string]any
shortcut.Register(shortcut.Shortcut{
Service: "chat",
Command: "+gap-failed-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-failed-colliding-resource-download", "--output-dir", "./downloads"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(destinations, []string{"fixture.txt", "fixture.txt"}) {
t.Fatalf("failed download consumed a filename: %#v", destinations)
}
if ledger["downloadedCount"] != 1 || ledger["failedCount"] != 1 {
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)
}
}
+4 -9
View File
@@ -139,13 +139,13 @@ var MessagesReply = shortcut.Shortcut{
Command: "+messages-reply",
Product: "chat",
Description: "以当前用户身份引用回复消息(自动补全原发送者)",
Intent: "当你要以当前用户身份对已有消息发送纯文本引用回复时使用;提供会话和被引用消息即可,默认通过 mget 自动读取原发送者,也可显式传 openDingTalkId/userId。它不支持 bot 身份、富媒体、卡片或 thread 内回复。",
Intent: "当你要以当前用户身份对已有消息发送纯文本引用回复时使用;提供会话和被引用消息即可,默认通过 mget 自动读取原发送者,也可显式传 openDingTalkId/userId;userId 会通过通讯录搜索精确匹配 openDingTalkId。它不支持 bot 身份、富媒体、卡片或 thread 内回复。",
Risk: shortcut.RiskWrite,
Flags: []shortcut.Flag{
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "会话 openConversationId", Required: true},
{Name: "ref-msg-id", Type: shortcut.FlagString, Desc: "被引用消息 openMessageId"},
{Name: "message-id", Type: shortcut.FlagString, Desc: "--ref-msg-id 的 lark-cli 对齐别名"},
{Name: "ref-sender", Type: shortcut.FlagString, Desc: "原消息发送者 openDingTalkId/userId(不传则自动读取)"},
{Name: "ref-sender", Type: shortcut.FlagString, Desc: "原消息发送者 openDingTalkId/userId(userId 通过通讯录搜索精确匹配;不传则自动读取)"},
{Name: "text", Type: shortcut.FlagString, Desc: "纯文本回复内容", Required: true},
{Name: "uuid", Type: shortcut.FlagString, Desc: "幂等键(可选)"},
{Name: "idempotency-key", Type: shortcut.FlagString, Desc: "--uuid 的 lark-cli 对齐别名"},
@@ -184,16 +184,11 @@ func resolveReplySender(rt *shortcut.RuntimeContext) (string, error) {
if isOpenID(value) {
return value, nil
}
data, err := rt.CallMCPData("contact", "get_user_info_by_user_ids", map[string]any{
"user_id_list": []string{value},
})
openID, err := resolveUserOpenDingTalkID(rt, value)
if err != nil {
return "", fmt.Errorf("把 --ref-sender userId 解析为 openDingTalkId 失败: %w", err)
}
if openID := findOpenDingTalkID(data); openID != "" {
return openID, nil
}
return "", apperrors.NewValidation("无法把 --ref-sender 解析为 openDingTalkId")
return openID, nil
}
messageID := replyMessageID(rt)
+42 -6
View File
@@ -44,7 +44,24 @@ func (f *larkAlignmentCaller) CallTool(_ context.Context, product, tool string,
case "contact/get_current_user_profile":
text = `{"result":[{"orgEmployeeModel":{"userId":"self-user"}}]}`
case "contact/get_user_info_by_user_ids":
text = `{"result":[{"userId":"user-id","openDingTalkId":"D-resolved"}]}`
text = `{"result":[{"orgEmployeeModel":{"orgUserId":"user-id","orgUserName":"Resolved User"}}]}`
case "contact/search_contact_by_key_word":
keyword, _ := args["keyword"].(string)
payload, _ := json.Marshal(map[string]any{
"result": []map[string]any{
{
"name": "Fuzzy Neighbor",
"userId": keyword + "-other",
"openDingTalkId": "D-other",
},
{
"name": "Resolved User",
"userId": keyword,
"openDingTalkId": "D-resolved",
},
},
})
text = string(payload)
case "im/create_group_conversation":
text = `{"result":{"cid":"internal-cid","openCid":"open-cid"}}`
case "im/list_messages_by_ids":
@@ -117,27 +134,35 @@ func TestMessagesSendRoutesIdentitySpecificTransports(t *testing.T) {
product string
tool string
want map[string]any
bodyKey string
body string
}{
{
name: "user",
args: []string{"chat", "+messages-send", "--as", "user", "--chat-id", "cid", "--markdown", "hello", "--idempotency-key", "u1", "--yes"},
args: []string{"chat", "+messages-send", "--as", "user", "--chat-id", "cid", "--markdown", "hello @D1", "--at-open-dingtalk-ids", "D1", "--at-all", "--idempotency-key", "u1", "--yes"},
product: "chat",
tool: "send_personal_message",
want: map[string]any{"openConversationId": "cid", "msgType": "markdown", "uuid": "u1"},
bodyKey: "content",
body: "<@all> hello <@D1>",
},
{
name: "bot",
args: []string{"chat", "+messages-send", "--identity", "bot", "--robot-code", "robot", "--group", "cid", "--text", "hello", "--at-all", "--yes"},
args: []string{"chat", "+messages-send", "--identity", "bot", "--robot-code", "robot", "--group", "cid", "--text", "<@u1> hello", "--at-user-ids", "u1", "--at-all", "--yes"},
product: "bot",
tool: "send_robot_group_message",
want: map[string]any{"robotCode": "robot", "openConversationId": "cid", "isAtAll": "true"},
bodyKey: "markdown",
body: "@all @u1 hello",
},
{
name: "webhook",
args: []string{"chat", "+messages-send", "--identity", "webhook", "--webhook-token", "token", "--text", "hello", "--at-all", "--yes"},
args: []string{"chat", "+messages-send", "--identity", "webhook", "--webhook-token", "token", "--text", "<@13800000000> hello", "--at-mobiles", "13800000000", "--at-all", "--yes"},
product: "bot",
tool: "send_message_by_custom_robot",
want: map[string]any{"robotToken": "token", "isAtAll": true},
bodyKey: "text",
body: "@all @13800000000 hello",
},
}
for _, tt := range tests {
@@ -161,6 +186,17 @@ func TestMessagesSendRoutesIdentitySpecificTransports(t *testing.T) {
t.Errorf("%s = %#v, want %#v", key, call.args[key], want)
}
}
if tt.bodyKey == "content" {
var content map[string]string
if err := json.Unmarshal([]byte(call.args[tt.bodyKey].(string)), &content); err != nil {
t.Fatal(err)
}
if content["text"] != tt.body {
t.Errorf("content text = %q, want %q", content["text"], tt.body)
}
} else if call.args[tt.bodyKey] != tt.body {
t.Errorf("%s = %#v, want %q", tt.bodyKey, call.args[tt.bodyKey], tt.body)
}
})
}
}
@@ -384,7 +420,7 @@ func TestMessagesMgetDryRunPublishesMultiResourceDownloadPlan(t *testing.T) {
}
}
func TestMessagesReplyResolvesUserIDBeforeExecution(t *testing.T) {
func TestCrossPlatformCoverageMessagesReplyResolvesUserIDBeforeExecution(t *testing.T) {
fake := &larkAlignmentCaller{}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
@@ -401,7 +437,7 @@ func TestMessagesReplyResolvesUserIDBeforeExecution(t *testing.T) {
}
if len(fake.calls) != 2 ||
fake.calls[0].product != "contact" ||
fake.calls[0].tool != "get_user_info_by_user_ids" ||
fake.calls[0].tool != "search_contact_by_key_word" ||
fake.calls[1].tool != "send_personal_message" {
t.Fatalf("calls = %#v", fake.calls)
}
+200 -29
View File
@@ -18,6 +18,7 @@ import (
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
@@ -54,16 +55,16 @@ var MessagesResourceDownload = shortcut.Shortcut{
Service: "chat",
Command: "+messages-resource-download",
Product: "im",
Description: "安全下载消息资源(图片/视频/语音)到本地",
Intent: "当你需要拿到消息里的实际图片、视频或语音文件,而不只是临时 URL 时使用;" +
"先用消息和会话身份换取下载地址,再安全写入工作目录内的相对路径。" +
"默认不覆盖已有文件,只有显式传 --overwrite 才覆盖。",
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,17 +74,34 @@ 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
}
resourceType, _ := canonicalMessageResourceType(rt.Str("type"))
if resourceType == "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 {
resourceType, _ := canonicalMessageResourceType(rt.Str("type"))
plan := map[string]any{
"resourceType": rt.Str("type"),
"resourceType": resourceType,
"resourceId": rt.Str("resource-id"),
"messageId": rt.Str("message-id"),
"openConversationId": rt.Str("open-conversation-id"),
@@ -101,12 +119,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,
resourceType,
rt.Str("resource-id"),
rt.Str("message-id"),
rt.Str("open-conversation-id"),
)
if err != nil {
return err
}
@@ -119,7 +138,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
}
@@ -131,13 +155,46 @@ var MessagesResourceDownload = shortcut.Shortcut{
return rt.Output(map[string]any{
"messageId": rt.Str("message-id"),
"resourceId": rt.Str("resource-id"),
"resourceType": rt.Str("type"),
"resourceType": resourceType,
"localPath": filepath.ToSlash(relativePath),
"sizeBytes": size,
})
},
}
func resolveMessageResourceDownloadData(
rt *shortcut.RuntimeContext,
resourceType, resourceID, messageID, conversationID string,
) (map[string]any, error) {
resourceType, ok := canonicalMessageResourceType(resourceType)
if !ok {
return nil, apperrors.NewValidation(fmt.Sprintf(
"不支持的消息资源类型 %q;仅支持 mediaId 或 fileId", resourceType))
}
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 canonicalMessageResourceType(resourceType string) (string, bool) {
switch {
case strings.EqualFold(strings.TrimSpace(resourceType), "mediaId"):
return "mediaId", true
case strings.EqualFold(strings.TrimSpace(resourceType), "fileId"):
return "fileId", true
default:
return strings.TrimSpace(resourceType), false
}
}
func validateResourceDownloadOutput(output string) error {
return validateResourceDownloadOutputFlag(output, "--output")
}
@@ -195,10 +252,12 @@ func resourceDownloadInfo(data map[string]any) (string, map[string]string, error
parsed.Scheme = "https"
resourceURL = parsed.String()
}
if parsed.Scheme != "https" {
return "", nil, apperrors.NewAPI(
"资源下载接口未返回合法的 HTTPS 下载地址")
parsed, err = validateResourceDownloadURL(resourceURL)
if err != nil {
return "", nil, apperrors.NewAPI(fmt.Sprintf(
"资源下载接口返回了不受信任的下载地址: %v", err))
}
resourceURL = parsed.String()
headers := map[string]string{}
if values, ok := data["headers"].(map[string]any); ok {
@@ -211,14 +270,65 @@ 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")
if !strings.HasSuffix(host, ".aliyuncs.com") {
return false
}
for _, label := range strings.Split(strings.TrimSuffix(host, ".aliyuncs.com"), ".") {
if (label == "oss" || strings.HasPrefix(label, "oss-")) &&
!strings.Contains(label, "internal") {
return true
}
}
return false
}
func validateResourceDownloadURL(rawURL string) (*url.URL, error) {
parsed, err := url.Parse(strings.TrimSpace(rawURL))
if err != nil ||
parsed.Scheme != "https" ||
strings.TrimSpace(parsed.Host) == "" ||
parsed.User != nil {
return nil, apperrors.NewValidation("资源下载地址必须是受信任域名上的 HTTPS URL")
}
host := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(parsed.Hostname()), "."))
if host == "" || net.ParseIP(host) != nil || !isResourceDownloadAllowedHost(host) {
return nil, apperrors.NewValidation(fmt.Sprintf(
"资源下载地址域名 %q 不属于受信任的钉钉或 OSS 域名", host))
}
if port := parsed.Port(); port != "" && port != "443" {
return nil, apperrors.NewValidation("资源下载地址只允许使用 HTTPS 默认端口")
}
return parsed, nil
}
func isResourceDownloadAllowedHost(host string) bool {
host = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(host), "."))
// Lower tools may return signed headers with the URL. Keep those headers
// confined to platform-owned public download families; extend this list
// only after observing another official production download host.
return isAliyunOSSHost(host) ||
host == "dingtalk.com" ||
strings.HasSuffix(host, ".dingtalk.com")
}
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 +352,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,13 +431,17 @@ func ensureResourceDownloadParent(baseDir, parent string) error {
return nil
}
func resourceDownloadFilename(resourceURL string) string {
func resourceDownloadFilename(resourceURL string, preferredName ...string) string {
if len(preferredName) > 0 {
if name := safeResourceDownloadFilename(preferredName[0]); name != "" {
return name
}
}
parsed, err := url.Parse(resourceURL)
if err == nil {
name, unescapeErr := url.PathUnescape(filepath.Base(parsed.Path))
if unescapeErr == nil {
name = filepath.Base(strings.ReplaceAll(name, "\\", "/"))
if name != "" && name != "." && name != string(os.PathSeparator) {
if name = safeResourceDownloadFilename(name); name != "" {
return name
}
}
@@ -335,6 +449,43 @@ func resourceDownloadFilename(resourceURL string) string {
return "download"
}
// safeResourceDownloadFilename returns a portable basename or an empty string
// for names that are unsafe or unusable on a supported platform. Server-provided
// file names are untrusted input, and downloads may be prepared on one OS then
// consumed on another.
func safeResourceDownloadFilename(raw string) string {
normalized := strings.ReplaceAll(raw, "\\", "/")
if strings.TrimSpace(normalized) != normalized {
return ""
}
name := filepath.Base(normalized)
if name == "" || name == "." || name == ".." ||
strings.HasSuffix(name, ".") || strings.HasSuffix(name, " ") {
return ""
}
for _, char := range name {
if char < 0x20 || char == 0x7f || strings.ContainsRune(`<>:"/\|?*`, char) {
return ""
}
}
stem := name
if index := strings.IndexByte(stem, '.'); index >= 0 {
stem = stem[:index]
}
stem = strings.ToUpper(strings.TrimRight(stem, " ."))
switch stem {
case "CON", "PRN", "AUX", "NUL":
return ""
}
if len(stem) == 4 &&
(stem[:3] == "COM" || stem[:3] == "LPT") &&
stem[3] >= '1' && stem[3] <= '9' {
return ""
}
return name
}
func downloadResourceAtomically(
ctx context.Context,
client *http.Client,
@@ -346,22 +497,42 @@ func downloadResourceAtomically(
if client == nil {
client = &http.Client{Timeout: resourceDownloadTimeout}
}
parsedResourceURL, err := validateResourceDownloadURL(resourceURL)
if err != nil {
return 0, err
}
clientCopy := *client
client = &clientCopy
originalRedirect := client.CheckRedirect
initialHost := strings.ToLower(parsedResourceURL.Hostname())
headersConfinedToInitialHost := true
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
if req.URL.Scheme != "https" || strings.TrimSpace(req.URL.Host) == "" {
return apperrors.NewValidation("资源下载重定向必须使用 HTTPS")
if _, redirectErr := validateResourceDownloadURL(req.URL.String()); redirectErr != nil {
return apperrors.NewValidation(fmt.Sprintf(
"资源下载重定向指向了不受信任的地址: %v", redirectErr))
}
if len(via) >= 10 {
return apperrors.NewAPI("资源下载重定向次数过多")
}
if !strings.EqualFold(req.URL.Hostname(), initialHost) {
headersConfinedToInitialHost = false
}
// net/http rebuilds redirect headers from the initial request on every
// hop. Once a chain leaves the original host, strip lower-service
// headers on every later hop so a same-host redirect on the new origin
// cannot silently restore them.
if !headersConfinedToInitialHost {
for key := range headers {
req.Header.Del(key)
}
}
if originalRedirect != nil {
return originalRedirect(req, via)
}
return nil
}
request, err := http.NewRequestWithContext(ctx, http.MethodGet, resourceURL, nil)
request, err := http.NewRequestWithContext(
ctx, http.MethodGet, parsedResourceURL.String(), nil)
if err != nil {
return 0, apperrors.NewValidation(fmt.Sprintf("创建资源下载请求失败: %v", err))
}
@@ -9,7 +9,6 @@ import (
"errors"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
@@ -58,6 +57,7 @@ func TestCrossPlatformCoverageResourceDownloadCommandOutcomes(t *testing.T) {
"--resource-id", "@image",
"--message-id", "msg",
"--open-conversation-id", "cid",
"--yes",
}
t.Run("dry run", func(t *testing.T) {
helpers.InitDeps(&larkAlignmentCaller{})
@@ -89,7 +89,7 @@ func TestCrossPlatformCoverageResourceDownloadCommandOutcomes(t *testing.T) {
resetResourceDownloadHooks(t)
resourceGetwd = func() (string, error) { return "", errors.New("getwd") }
helpers.InitDeps(&larkAlignmentCaller{responses: map[string]string{
"im/get_resource_download_url": `{"result":{"resourceUrl":"https://example.test/file"}}`,
"im/get_resource_download_url": `{"result":{"resourceUrl":"https://download.dingtalk.com/file"}}`,
}})
root := newPlatformCoverageRoot()
root.SetArgs(baseArgs)
@@ -101,7 +101,7 @@ func TestCrossPlatformCoverageResourceDownloadCommandOutcomes(t *testing.T) {
resetResourceDownloadHooks(t)
resourceAbs = func(string) (string, error) { return "", errors.New("path") }
helpers.InitDeps(&larkAlignmentCaller{responses: map[string]string{
"im/get_resource_download_url": `{"result":{"resourceUrl":"https://example.test/file"}}`,
"im/get_resource_download_url": `{"result":{"resourceUrl":"https://download.dingtalk.com/file"}}`,
}})
root := newPlatformCoverageRoot()
root.SetArgs(baseArgs)
@@ -132,7 +132,7 @@ func TestCrossPlatformCoverageResourceDownloadCommandOutcomes(t *testing.T) {
resetResourceDownloadHooks(t)
resourceDownload = tc.download
helpers.InitDeps(&larkAlignmentCaller{responses: map[string]string{
"im/get_resource_download_url": `{"result":{"resourceUrl":"https://example.test/file"}}`,
"im/get_resource_download_url": `{"result":{"resourceUrl":"https://download.dingtalk.com/file"}}`,
}})
root := newPlatformCoverageRoot()
root.SetArgs(append(append([]string{}, baseArgs...), "--output", filepath.Join(t.TempDir(), "file")))
@@ -164,7 +164,7 @@ func TestCrossPlatformCoverageResourceDownloadValidationAndInfo(t *testing.T) {
}
}
resourceURL, headers, err := resourceDownloadInfo(map[string]any{
"resourceUrl": "https://example.test/file",
"resourceUrl": "https://download.dingtalk.com/file",
"headers": map[string]any{
"": "ignored",
"X-Count": 3,
@@ -175,14 +175,29 @@ func TestCrossPlatformCoverageResourceDownloadValidationAndInfo(t *testing.T) {
t.Fatalf("resource info = %q %#v %v", resourceURL, headers, err)
}
for host, want := range map[string]bool{
"ALIYUNCS.COM.": true,
"bucket.aliyuncs.com": true,
"example.com": false,
"ALIYUNCS.COM.": false,
"bucket.aliyuncs.com": false,
"bucket.oss-cn-hangzhou.aliyuncs.com": true,
"bucket.oss-internal.aliyuncs.com": false,
"bucket.oss-cn-hangzhou-internal.aliyuncs.com": false,
"example.com": false,
} {
if got := isAliyunOSSHost(host); got != want {
t.Errorf("isAliyunOSSHost(%q) = %v, want %v", host, got, want)
}
}
for host, want := range map[string]bool{
"DINGTALK.COM.": true,
"download.dingtalk.com": true,
"bucket.oss-cn-hangzhou.aliyuncs.com": true,
"aliyuncs.com.evil.test": false,
"evildingtalk.com": false,
"download.example.invalid": false,
} {
if got := isResourceDownloadAllowedHost(host); got != want {
t.Errorf("isResourceDownloadAllowedHost(%q) = %v, want %v", host, got, want)
}
}
}
func TestCrossPlatformCoverageResourceDownloadPathErrors(t *testing.T) {
@@ -348,6 +363,24 @@ func TestCrossPlatformCoverageResourceDownloadFilenameFallbacks(t *testing.T) {
if got := resourceDownloadFilename("https://example.test/a%20b.txt"); got != "a b.txt" {
t.Fatalf("decoded filename = %q", got)
}
for _, unsafeName := range []string{
"..",
"CON",
"nul.txt",
"COM1.log",
"trailing.",
"trailing ",
"line\nbreak.txt",
`bad:name.txt`,
} {
got := resourceDownloadFilename(
"https://download.dingtalk.com/fallback.bin",
unsafeName,
)
if got != "fallback.bin" {
t.Errorf("unsafe preferred name %q produced %q", unsafeName, got)
}
}
}
type resourceRoundTripper func(*http.Request) (*http.Response, error)
@@ -375,63 +408,105 @@ func TestCrossPlatformCoverageDownloadResourceHTTPFailures(t *testing.T) {
errorClient := &http.Client{Transport: resourceRoundTripper(func(*http.Request) (*http.Response, error) {
return nil, errors.New("transport")
})}
if _, err := downloadResourceAtomically(context.Background(), errorClient, "https://example.test/file", nil, dest, false); err == nil {
if _, err := downloadResourceAtomically(context.Background(), errorClient, "https://download.dingtalk.com/file", nil, dest, false); err == nil {
t.Fatal("transport error was swallowed")
}
if _, err := downloadResourceAtomically(context.Background(), resourceResponseClient(500, "", 0), "https://example.test/file", nil, dest, false); err == nil {
if _, err := downloadResourceAtomically(context.Background(), resourceResponseClient(500, "", 0), "https://download.dingtalk.com/file", nil, dest, false); err == nil {
t.Fatal("HTTP 500 was accepted")
}
if _, err := downloadResourceAtomically(context.Background(), resourceResponseClient(200, "x", 2), "https://example.test/file", nil, dest, false); err == nil {
if _, err := downloadResourceAtomically(context.Background(), resourceResponseClient(200, "x", 2), "https://download.dingtalk.com/file", nil, dest, false); err == nil {
t.Fatal("content-length mismatch was accepted")
}
plain := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
if request.Header.Get("X-Test") != "ok" {
t.Errorf("missing forwarded header")
}
_, _ = w.Write([]byte("body"))
}))
t.Cleanup(plain.Close)
nilClientDest := filepath.Join(t.TempDir(), "nil-client")
if _, err := downloadResourceAtomically(context.Background(), nil, plain.URL, map[string]string{"X-Test": "ok"}, nilClientDest, true); err != nil {
t.Fatal(err)
if _, err := downloadResourceAtomically(
context.Background(), nil, "https://evil.example/file",
map[string]string{"X-Test": "ok"}, nilClientDest, true,
); err == nil {
t.Fatal("nil-client path accepted an untrusted URL")
}
}
func TestCrossPlatformCoverageDownloadResourceRedirectGuards(t *testing.T) {
httpRedirect := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Redirect(w, &http.Request{}, "http://example.test/file", http.StatusFound)
}))
t.Cleanup(httpRedirect.Close)
httpRedirect := &http.Client{Transport: resourceRoundTripper(func(*http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusFound,
Body: io.NopCloser(strings.NewReader("")),
Header: http.Header{"Location": []string{"http://evil.example/file"}},
}, nil
})}
if _, err := downloadResourceAtomically(
context.Background(), httpRedirect.Client(), httpRedirect.URL, nil,
context.Background(), httpRedirect, "https://download.dingtalk.com/start", nil,
filepath.Join(t.TempDir(), "http-redirect"), false,
); err == nil {
t.Fatal("HTTP redirect was accepted")
}
var loop *httptest.Server
loop = httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Redirect(w, &http.Request{}, loop.URL, http.StatusFound)
}))
t.Cleanup(loop.Close)
loop := &http.Client{Transport: resourceRoundTripper(func(*http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusFound,
Body: io.NopCloser(strings.NewReader("")),
Header: http.Header{"Location": []string{"https://download.dingtalk.com/loop"}},
}, nil
})}
if _, err := downloadResourceAtomically(
context.Background(), loop.Client(), loop.URL, nil,
context.Background(), loop, "https://download.dingtalk.com/loop", nil,
filepath.Join(t.TempDir(), "loop"), false,
); err == nil {
t.Fatal("redirect loop was accepted")
}
original := loop.Client()
original := *loop
original.CheckRedirect = func(*http.Request, []*http.Request) error {
return errors.New("original redirect policy")
}
if _, err := downloadResourceAtomically(
context.Background(), original, loop.URL, nil,
context.Background(), &original, "https://download.dingtalk.com/loop", nil,
filepath.Join(t.TempDir(), "original"), false,
); err == nil {
t.Fatal("original redirect rejection was swallowed")
}
redirectCount := 0
crossHost := &http.Client{Transport: resourceRoundTripper(func(request *http.Request) (*http.Response, error) {
redirectCount++
switch redirectCount {
case 1:
if request.Header.Get("X-Resource-Token") != "secret" {
t.Fatal("initial signed header missing")
}
return &http.Response{
StatusCode: http.StatusFound,
Body: io.NopCloser(strings.NewReader("")),
Header: http.Header{"Location": []string{"https://bucket.oss-cn-hangzhou.aliyuncs.com/intermediate"}},
}, nil
case 2:
if request.Header.Get("X-Resource-Token") != "" {
t.Fatal("server-supplied header leaked on first cross-host hop")
}
return &http.Response{
StatusCode: http.StatusFound,
Body: io.NopCloser(strings.NewReader("")),
Header: http.Header{"Location": []string{"https://bucket.oss-cn-hangzhou.aliyuncs.com/final"}},
}, nil
default:
if request.Header.Get("X-Resource-Token") != "" {
t.Fatal("server-supplied header was restored on later same-host hop")
}
}
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader("ok")),
ContentLength: 2,
Header: make(http.Header),
}, nil
})}
if _, err := downloadResourceAtomically(
context.Background(), crossHost, "https://download.dingtalk.com/start",
map[string]string{"X-Resource-Token": "secret"},
filepath.Join(t.TempDir(), "cross-host"), false,
); err != nil {
t.Fatal(err)
}
}
func TestCrossPlatformCoverageDownloadResourceFileFailures(t *testing.T) {
@@ -439,7 +514,7 @@ func TestCrossPlatformCoverageDownloadResourceFileFailures(t *testing.T) {
run := func(t *testing.T, overwrite bool) error {
t.Helper()
_, err := downloadResourceAtomically(
context.Background(), client, "https://example.test/file", nil,
context.Background(), client, "https://download.dingtalk.com/file", nil,
filepath.Join(t.TempDir(), "resource"), overwrite)
return err
}
@@ -516,7 +591,7 @@ func TestCrossPlatformCoverageDownloadResourceCopySuccessWithBuffer(t *testing.T
dest := filepath.Join(t.TempDir(), "resource")
if _, err := downloadResourceAtomically(
context.Background(), resourceResponseClient(200, "ok", 2),
"https://example.test/file", nil, dest, true,
"https://download.dingtalk.com/file", nil, dest, true,
); err != nil {
t.Fatal(err)
}
@@ -15,18 +15,41 @@ package chat
import (
"context"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
func TestResourceDownloadInfo(t *testing.T) {
func TestCrossPlatformCoverageCanonicalMessageResourceType(t *testing.T) {
for input, want := range map[string]string{
"mediaId": "mediaId",
"MEDIAID": "mediaId",
" fileid ": "fileId",
"FileId": "fileId",
} {
got, ok := canonicalMessageResourceType(input)
if !ok || got != want {
t.Errorf("canonicalMessageResourceType(%q) = %q, %v; want %q, true",
input, got, ok, want)
}
}
if got, ok := canonicalMessageResourceType("attachment"); ok || got != "attachment" {
t.Errorf("unsupported resource type = %q, %v", got, ok)
}
if _, err := resolveMessageResourceDownloadData(
nil, "attachment", "resource", "message", "conversation",
); err == nil || !strings.Contains(err.Error(), "不支持的消息资源类型") {
t.Fatalf("unsupported resolver type error = %v", err)
}
}
func TestCrossPlatformCoverageResourceDownloadInfo(t *testing.T) {
url, headers, err := resourceDownloadInfo(map[string]any{
"result": map[string]any{
"resourceUrl": []any{"https://download.example.test/path/image.png"},
"resourceUrl": []any{"https://download.dingtalk.com/path/image.png"},
"headers": map[string]any{
"X-Test": "value",
},
@@ -35,7 +58,7 @@ func TestResourceDownloadInfo(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if url != "https://download.example.test/path/image.png" {
if url != "https://download.dingtalk.com/path/image.png" {
t.Fatalf("url = %q", url)
}
if headers["X-Test"] != "value" {
@@ -57,6 +80,19 @@ func TestResourceDownloadInfo(t *testing.T) {
}); err == nil {
t.Fatal("plain HTTP URL unexpectedly accepted")
}
for _, resourceURL := range []string{
"https://evil.example/file",
"https://aliyuncs.com.evil.example/file",
"https://127.0.0.1/file",
"https://user:secret@download.dingtalk.com/file",
"https://download.dingtalk.com:8443/file",
} {
if _, _, err := resourceDownloadInfo(
map[string]any{"resourceUrl": resourceURL},
); err == nil {
t.Fatalf("untrusted URL %q unexpectedly accepted", resourceURL)
}
}
}
func TestResolveResourceDownloadPath(t *testing.T) {
@@ -115,16 +151,23 @@ func TestResolveResourceDownloadPathRejectsSymlinkParentBeforeCreatingOutside(t
}
}
func TestDownloadResourceAtomically(t *testing.T) {
func TestCrossPlatformCoverageDownloadResourceAtomically(t *testing.T) {
const body = "verified download bytes"
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(body))
}))
t.Cleanup(server.Close)
client := &http.Client{Transport: resourceRoundTripper(func(request *http.Request) (*http.Response, error) {
if request.URL.String() != "https://download.dingtalk.com/resource.bin" {
t.Fatalf("request URL = %q", request.URL)
}
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(body)),
ContentLength: int64(len(body)),
Header: make(http.Header),
}, nil
})}
dest := filepath.Join(t.TempDir(), "resource.bin")
size, err := downloadResourceAtomically(
context.Background(), server.Client(), server.URL, nil, dest, false)
context.Background(), client, "https://download.dingtalk.com/resource.bin", nil, dest, false)
if err != nil {
t.Fatal(err)
}
@@ -139,7 +182,7 @@ func TestDownloadResourceAtomically(t *testing.T) {
t.Fatalf("body = %q", got)
}
if _, err := downloadResourceAtomically(
context.Background(), server.Client(), server.URL, nil, dest, false,
context.Background(), client, "https://download.dingtalk.com/resource.bin", nil, dest, false,
); err == nil || !strings.Contains(err.Error(), "已存在") {
t.Fatalf("no-clobber error = %v", err)
}
+296 -29
View File
@@ -5,41 +5,51 @@
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 图片、安全相对路径文件上传和幂等键;--user 传 userId 时包括在 --dry-run 中也会先通过通讯录关键词搜索并按 userId 精确匹配 openDingTalkId。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: "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: "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: "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;包括 --dry-run 也会先通过通讯录搜索精确匹配 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(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: "uuid", Type: shortcut.FlagString, Desc: "幂等键(仅 user)"},
{Name: "idempotency-key", Type: shortcut.FlagString, Desc: "--uuid 的 lark-cli 对齐别名(仅 user)"},
{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 群聊)"},
{Name: "at-user-ids", Type: shortcut.FlagStringSlice, Desc: "@ 的 userId(bot/webhook)"},
{Name: "at-mobiles", Type: shortcut.FlagStringSlice, Desc: "@ 的手机号(webhook)"},
@@ -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", "as", "group", "chat-id", "user", "open-dingtalk-id", "users", "open-dingtalk-ids", "robot-code", "webhook-token", "uuid", "idempotency-key"},
Description: "目标、凭据和幂等参数受发送身份能力矩阵约束:user 必须指定一个群聊或单聊目标;bot 必须指定 robot-code 和一类目标;webhook 必须指定 webhook-token;幂等键仅 user 支持",
},
},
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,43 @@ 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)
}
if group != "" {
body = helpers.NormalizeMessageMentions(
body,
uniqueShortcutStrings(rt.StrSlice("at-open-dingtalk-ids")),
rt.Bool("at-all"),
true,
)
}
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,13 +213,22 @@ 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
}
return executeUnifiedMessageWrite(rt, "chat", "send_personal_message", params)
case "bot":
body = helpers.NormalizeMessageMentions(
body,
append(
uniqueShortcutStrings(rt.StrSlice("at-user-ids")),
uniqueShortcutStrings(rt.StrSlice("at-open-dingtalk-ids"))...,
),
rt.Bool("at-all"),
false,
)
params := map[string]any{
"robotCode": rt.Str("robot-code"),
"title": title,
@@ -185,6 +258,15 @@ func executeMessagesSend(rt *shortcut.RuntimeContext) error {
}
return executeUnifiedMessageWrite(rt, "bot", "batch_send_robot_msg_to_users", params)
case "webhook":
body = helpers.NormalizeMessageMentions(
body,
append(
uniqueShortcutStrings(rt.StrSlice("at-user-ids")),
uniqueShortcutStrings(rt.StrSlice("at-mobiles"))...,
),
rt.Bool("at-all"),
false,
)
params := map[string]any{
"robotToken": rt.Str("webhook-token"),
"title": title,
@@ -241,6 +323,191 @@ 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
}
openID, err = resolveUserOpenDingTalkID(rt, rt.Str("user"))
if err != nil {
return "", "", err
}
return "", openID, nil
}
func resolveUserOpenDingTalkID(rt *shortcut.RuntimeContext, userID string) (string, error) {
userID = strings.TrimSpace(userID)
data, err := rt.CallMCPData("contact", "search_contact_by_key_word", map[string]any{
"keyword": userID,
})
if err != nil {
return "", fmt.Errorf("通过通讯录搜索把 userId %q 解析为 openDingTalkId 失败: %w", userID, err)
}
exactRows := 0
openIDs := make([]string, 0, 1)
for _, user := range shortcutMapSlice(data["result"]) {
if shortcutString(user, "userId", "userID") != userID {
continue
}
exactRows++
if openID := shortcutString(user, "openDingTalkId", "openDingtalkId"); openID != "" {
openIDs = appendUniqueShortcutString(openIDs, openID)
}
}
switch {
case len(openIDs) == 1:
return openIDs[0], nil
case len(openIDs) > 1:
return "", apperrors.NewValidation(fmt.Sprintf(
"通讯录中 userId %q 精确匹配到多个不同的 openDingTalkId,无法安全选择",
userID,
))
case exactRows > 0:
return "", apperrors.NewValidation(fmt.Sprintf(
"通讯录已精确匹配 userId %q,但结果未返回 openDingTalkId",
userID,
))
default:
return "", apperrors.NewValidation(fmt.Sprintf(
"通讯录搜索结果中没有精确匹配的 userId %q",
userID,
))
}
}
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")
}
+156 -26
View File
@@ -207,21 +207,25 @@ func firstMessageValue(m map[string]any, keys ...string) any {
return nil
}
// Resources extracts actionable media references from both structured message
// fields and the textual mediaId notation returned by older DingTalk message
// APIs. Every reference publishes the exact Shortcut arguments already known
// from the message, plus ready=false and missing fields when a follow-up lookup
// is still required. This shared shape is used by list, search, mget, quoted
// messages and thread replies.
// Resources extracts actionable media and drive-file references from both
// structured message fields and the textual mediaId/fileId notation returned
// by older DingTalk message APIs. Every reference publishes the exact Shortcut
// arguments already known from the message, plus ready=false and missing fields
// when a follow-up lookup is still required. This shared shape is used by list,
// search, mget, quoted messages and thread replies.
func Resources(m map[string]any) []map[string]any {
if m == nil {
return nil
}
ids := make([]string, 0)
collectMediaIDs(m, &ids)
ids = uniqueMediaIDs(ids)
sort.Strings(ids)
if len(ids) == 0 {
mediaIDs := make([]string, 0)
collectResourceIDs(m, "mediaid", mediaIDTextRE, &mediaIDs)
mediaIDs = uniqueResourceIDs(mediaIDs)
sort.Strings(mediaIDs)
fileIDs := make([]string, 0)
collectResourceIDs(m, "fileid", fileIDTextRE, &fileIDs)
fileIDs = uniqueResourceIDs(fileIDs)
sort.Strings(fileIDs)
if len(mediaIDs) == 0 && len(fileIDs) == 0 {
return nil
}
@@ -234,8 +238,8 @@ func Resources(m map[string]any) []map[string]any {
conversationID = ""
}
out := make([]map[string]any, 0, len(ids))
for _, id := range ids {
out := make([]map[string]any, 0, len(mediaIDs)+len(fileIDs))
for _, id := range mediaIDs {
arguments := map[string]any{
"type": "mediaId",
"resource-id": id,
@@ -262,36 +266,94 @@ 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)\]}>,]+)`)
// ResourcesDeep returns resources from a message and each nested quoted,
// replied-to or forwarded message. Every nested resource is projected from the
// child message that owns it, so its download arguments never reuse the parent
// message ID. A missing child conversation ID inherits the enclosing
// conversation because quoted and forwarded records often omit that duplicate
// field.
func ResourcesDeep(m map[string]any) []map[string]any {
return resourcesDeep(m, "", 0)
}
func collectMediaIDs(value any, out *[]string) {
const maxResourceMessageDepth = 32
func resourcesDeep(m map[string]any, inheritedConversationID string, depth int) []map[string]any {
if m == nil || depth > maxResourceMessageDepth {
return nil
}
conversationID := strings.TrimSpace(fmt.Sprint(ConversationID(m)))
if conversationID == "" || conversationID == "<nil>" {
conversationID = inheritedConversationID
}
owned := m
if ConversationID(m) == nil && conversationID != "" {
owned = make(map[string]any, len(m)+1)
for key, value := range m {
owned[key] = value
}
owned["openConversationId"] = conversationID
}
out := append([]map[string]any(nil), Resources(owned)...)
if depth == maxResourceMessageDepth {
return out
}
for _, child := range nestedMessageChildren(m) {
out = append(out, resourcesDeep(child, conversationID, depth+1)...)
}
return out
}
var mediaIDTextRE = regexp.MustCompile(`(?i)\bmedia[_-]?id\s*[:=]\s*["']?([^"'\s)\]}>,]+)`)
var fileIDTextRE = regexp.MustCompile(`(?i)\bfile[_-]?id\s*[:=]\s*["']?([^"'\s)\]}>,]+)`)
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 != "" {
normalizedKey := normalizeMessageKey(key)
if normalizedKey == targetKey ||
(normalizedKey == "resourceid" && strings.EqualFold(resourceType, targetKey)) {
if id := resourceIDScalar(child); id != "" {
*out = append(*out, id)
}
}
collectMediaIDs(child, out)
if isNestedMessageBoundaryKey(normalizedKey) {
continue
}
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 +362,81 @@ 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 normalizeMessageKey(key string) string {
return strings.ToLower(strings.NewReplacer("_", "", "-", "").Replace(key))
}
func isNestedMessageBoundaryKey(key string) bool {
switch key {
case "quotedmessage", "replymessage", "quoted", "replytomessage",
"forwardmessages", "forwardedmessages", "forwarded":
return true
default:
return false
}
}
func nestedMessageMaps(value any) []map[string]any {
switch typed := value.(type) {
case map[string]any:
return []map[string]any{typed}
case []map[string]any:
return typed
case []any:
out := make([]map[string]any, 0, len(typed))
for _, item := range typed {
if child, ok := item.(map[string]any); ok {
out = append(out, child)
}
}
return out
case string:
var decoded any
if json.Unmarshal([]byte(strings.TrimSpace(typed)), &decoded) == nil {
return nestedMessageMaps(decoded)
}
}
return nil
}
func nestedMessageChildren(value any) []map[string]any {
out := make([]map[string]any, 0)
switch typed := value.(type) {
case map[string]any:
for key, child := range typed {
if isNestedMessageBoundaryKey(normalizeMessageKey(key)) {
out = append(out, nestedMessageMaps(child)...)
continue
}
out = append(out, nestedMessageChildren(child)...)
}
case []any:
for _, child := range typed {
out = append(out, nestedMessageChildren(child)...)
}
case []map[string]any:
for _, child := range typed {
out = append(out, nestedMessageChildren(child)...)
}
case string:
trimmed := strings.TrimSpace(typed)
if strings.HasPrefix(trimmed, "{") || strings.HasPrefix(trimmed, "[") {
var decoded any
if json.Unmarshal([]byte(trimmed), &decoded) == nil {
out = append(out, nestedMessageChildren(decoded)...)
}
}
}
return out
}
func resourceIDScalar(value any) string {
text, ok := value.(string)
if !ok {
return ""
@@ -314,7 +444,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 {
+116
View File
@@ -189,6 +189,111 @@ func TestQuotedMessageIsBoundedAndSemantic(t *testing.T) {
}
}
func TestCrossPlatformCoverageResourcesRespectNestedMessageOwnership(t *testing.T) {
message := map[string]any{
"openMessageId": "parent",
"openConversationId": "cid-parent",
"content": map[string]any{"mediaId": "media-parent"},
"quotedMessage": map[string]any{
"openMessageId": "quoted",
"content": map[string]any{"mediaId": "media-quoted"},
},
"forwardMessages": []any{
map[string]any{
"openMessageId": "forwarded",
"openConversationId": "cid-forwarded",
"content": map[string]any{"mediaId": "media-forwarded"},
},
},
}
owned := Resources(message)
if len(owned) != 1 || owned[0]["resourceId"] != "media-parent" {
t.Fatalf("parent resources crossed message boundary: %#v", owned)
}
deep := ResourcesDeep(message)
if len(deep) != 3 {
t.Fatalf("deep resources = %#v", deep)
}
argumentsByResource := map[string]map[string]any{}
for _, resource := range deep {
download := resource["download"].(map[string]any)
argumentsByResource[resource["resourceId"].(string)] = download["arguments"].(map[string]any)
}
for resourceID, want := range map[string][2]string{
"media-parent": {"parent", "cid-parent"},
"media-quoted": {"quoted", "cid-parent"},
"media-forwarded": {"forwarded", "cid-forwarded"},
} {
arguments := argumentsByResource[resourceID]
if arguments["message-id"] != want[0] ||
arguments["open-conversation-id"] != want[1] {
t.Errorf("%s arguments = %#v, want message=%q conversation=%q",
resourceID, arguments, want[0], want[1])
}
}
}
func TestCrossPlatformCoverageResourceBoundaryHelpers(t *testing.T) {
encoded := map[string]any{
"openMessageId": "parent",
"openConversationId": "cid",
"content": `{"quotedMessage":{"openMessageId":"encoded-child","mediaId":"nested"}}`,
}
if got := Resources(encoded); got != nil {
t.Fatalf("encoded nested resource bound to parent: %#v", got)
}
if got := ResourcesDeep(encoded); len(got) != 1 {
t.Fatalf("encoded nested resources = %#v", got)
} else {
arguments := got[0]["download"].(map[string]any)["arguments"].(map[string]any)
if arguments["message-id"] != "encoded-child" ||
arguments["open-conversation-id"] != "cid" {
t.Fatalf("encoded child arguments = %#v", arguments)
}
}
if got := resourcesDeep(map[string]any{"mediaId": "x"}, "", maxResourceMessageDepth+1); got != nil {
t.Fatalf("over-depth resources = %#v", got)
}
if got := resourcesDeep(
map[string]any{"mediaId": "x"},
"",
maxResourceMessageDepth,
); len(got) != 1 {
t.Fatalf("max-depth owned resources = %#v", got)
}
if got := nestedMessageMaps([]map[string]any{{"id": "a"}}); len(got) != 1 {
t.Fatalf("map slice = %#v", got)
}
if got := nestedMessageMaps(`[{"id":"a"}]`); len(got) != 1 {
t.Fatalf("encoded message list = %#v", got)
}
if got := nestedMessageMaps([]any{"bad", map[string]any{"id": "a"}}); len(got) != 1 {
t.Fatalf("mixed message list = %#v", got)
}
if got := nestedMessageMaps("{"); got != nil {
t.Fatalf("invalid encoded messages = %#v", got)
}
if got := nestedMessageChildren([]map[string]any{
{"content": "plain"},
{"content": `{"forwardedMessages":[{"openMessageId":"m"}]}`},
}); len(got) != 1 {
t.Fatalf("nested message children = %#v", got)
}
if got := nestedMessageChildren([]any{
"plain",
map[string]any{"quoted": map[string]any{"openMessageId": "m"}},
}); len(got) != 1 {
t.Fatalf("mixed nested message children = %#v", got)
}
if got := nestedMessageChildren("{"); len(got) != 0 {
t.Fatalf("invalid nested children = %#v", got)
}
if isNestedMessageBoundaryKey("content") {
t.Fatal("ordinary content treated as a message boundary")
}
}
func TestUpdateTimeOmitsUneditedEcho(t *testing.T) {
if got := UpdateTime(map[string]any{
"createTime": "2026-07-19 13:37:03",
@@ -326,6 +431,17 @@ func TestResourcesReportsMissingDownloadContext(t *testing.T) {
}
}
func TestResourcesTextMediaIDRequiresWordBoundary(t *testing.T) {
resources := Resources(map[string]any{
"openMessageId": "msg-1",
"openConversationId": "cid-1",
"content": `notmediaId=@false mediaId=@real`,
})
if len(resources) != 1 || resources[0]["resourceId"] != "@real" {
t.Fatalf("resources = %#v, want only the bounded mediaId", resources)
}
}
func TestForwarded(t *testing.T) {
var project func(m map[string]any) map[string]any
project = func(m map[string]any) map[string]any {
@@ -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)
}
}
+4 -11
View File
@@ -172,15 +172,7 @@ func dryRunWriteError(product, tool string) error {
}
func looksReadTool(tool string) bool {
tool = strings.TrimSpace(strings.ToLower(tool))
for _, prefix := range []string{
"get_", "list_", "query_", "search_", "unread_",
} {
if strings.HasPrefix(tool, prefix) {
return true
}
}
return false
return helpers.IsReadToolName(tool)
}
func (rt *RuntimeContext) callMCPData(product, tool string, params map[string]any) (map[string]any, error) {
@@ -469,10 +461,11 @@ func validateConstraints(rt *RuntimeContext, s Shortcut) error {
// --dry-run is set. Read-only shortcuts never prompt. Returns false when the
// user declines.
func confirmRisk(rt *RuntimeContext, s Shortcut) bool {
if s.risk() == RiskRead || rt.DryRun() || rt.Yes() {
risk := s.risk()
if risk == RiskRead || rt.DryRun() || rt.Yes() {
return true
}
fmt.Fprintf(rt.cmd.ErrOrStderr(), "即将执行 %s %s(%s),确认继续?(yes/no): ", s.Service, s.Command, s.risk())
fmt.Fprintf(rt.cmd.ErrOrStderr(), "即将执行 %s %s(%s),确认继续?(yes/no): ", s.Service, s.Command, risk)
reader := bufio.NewReader(os.Stdin)
answer, _ := reader.ReadString('\n')
answer = strings.TrimSpace(strings.ToLower(answer))
+9 -9
View File
@@ -435,7 +435,7 @@
},
"+messages-send": {
"disposition": "semantic_adapter",
"semantic_delta": "用统一 identity 参数路由 current-user、bot、webhook 文本/Markdown 发送;按身份校验目标与凭据,幂等键只在真实支持的 user 分支开放,媒体上传仍诚实留在 native leaf。",
"semantic_delta": "用统一 identity 参数路由 current-user、bot、webhook 发送;current-user 支持文本、Markdown、mediaId 图片、安全相对路径本地文件上传、userId 姓名解析与幂等键,bot/webhook 仍只暴露下层真实支持的文本/Markdown 能力。",
"risk": "write",
"public": true,
"reviewed": true
@@ -487,7 +487,7 @@
},
"+messages-mget": {
"disposition": "semantic_adapter",
"semantic_delta": "按最多 50 个消息 ID 批量读取并输出稳定消息投影、reaction 与 resourceRefs;可用 --download-resources 复用 HTTPS、相对路径、无覆盖和原子落盘防护,逐资源返回下载失败 ledger。",
"semantic_delta": "按最多 50 个消息 ID 批量读取并输出稳定消息投影、reaction 与 resourceRefs;可用 --download-resources 统一下载 mediaId 与 fileId,复用受信任下载域、相对路径、无覆盖和原子落盘防护,对重复资源去重并逐资源返回下载失败 ledger;安全本地下载沿用 read/not_required 契约,不产生非交互确认盲区。",
"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,只允许钉钉/OSS HTTPS 下载域且重定向复验并隔离跨域凭据,再通过工作目录内安全路径、默认不覆盖、临时文件下载和原子发布输出结构化结果。",
"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
+37
View File
@@ -15,6 +15,7 @@ package shortcut
import (
"bytes"
"os"
"strings"
"testing"
@@ -40,6 +41,42 @@ func TestCrossPlatformCoverageRiskDefaultsToRead(t *testing.T) {
}
}
func TestCrossPlatformCoverageConfirmRiskPromptsForStaticWrite(t *testing.T) {
cmd := &cobra.Command{}
var stderr bytes.Buffer
cmd.SetErr(&stderr)
rt := &RuntimeContext{cmd: cmd}
s := Shortcut{
Service: "chat",
Command: "+messages-send",
Risk: RiskWrite,
}
reader, writer, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
previousStdin := os.Stdin
os.Stdin = reader
t.Cleanup(func() {
os.Stdin = previousStdin
_ = reader.Close()
_ = writer.Close()
})
if _, err := writer.WriteString("yes\n"); err != nil {
t.Fatal(err)
}
if err := writer.Close(); err != nil {
t.Fatal(err)
}
if !confirmRisk(rt, s) {
t.Fatal("static write risk was not confirmed")
}
if got := stderr.String(); !strings.Contains(got, "chat +messages-send(write)") {
t.Fatalf("confirmation prompt = %q", got)
}
}
func TestCrossPlatformCoverageMountRegistersFlagsAndUse(t *testing.T) {
s := Shortcut{
Service: "contact",
+13 -7
View File
@@ -19,6 +19,7 @@ import (
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
chatshortcut "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chat"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg"
)
@@ -37,9 +38,9 @@ import (
// so it honours --format/--jq/--fields. When the response carries no
// recognisable message list we fall back to printing the raw payload.
//
// This replaces manually working out the millisecond time window and copying the
// list-mentions incantation. Read-only: it only searches and reshapes, never
// sends, recalls or marks anything.
// This replaces manually working out the millisecond time window and copying
// the list-mentions incantation. The default path only searches and reshapes;
// --download-resources additionally writes resource files locally.
//
// dws chat +at-me
// dws chat +at-me --days 3
@@ -51,18 +52,20 @@ var AtMe = shortcut.Shortcut{
Intent: "当你想快速看回最近谁在群里或单聊里 @了你、但不想手动把起止时间换算成毫秒、也不想记 list-mentions 的一堆参数时使用;" +
"内部按本地时区算出「最近 N 天」(默认 7 天,可用 --days 调整回溯天数)的时间窗,搜索这段时间内 @我 的消息," +
"再在本地把每条消息投影成发送人、时间、内容、所在会话四个关键字段。" +
"这是纯只读操作,只做搜索与本地投影,不会发送、撤回或标记任何消息。",
"默认只读且不会发送、撤回或标记任何消息;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘,按既有安全下载约定无需交互确认。",
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)
},
}
@@ -233,7 +239,7 @@ func atMeProjectWithReactions(m map[string]any, includeReactions bool) map[strin
if quoted := chatmsg.QuotedMessage(m); len(quoted) > 0 {
row["quotedMessage"] = quoted
}
if resources := chatmsg.Resources(m); len(resources) > 0 {
if resources := chatmsg.ResourcesDeep(m); len(resources) > 0 {
row["resourceRefs"] = resources
}
projectForwarded := func(item map[string]any) map[string]any {
+29 -11
View File
@@ -15,11 +15,19 @@ 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"
)
var dingTalkMessageLocation = time.FixedZone("CST", 8*60*60)
func formatDingTalkMessageBoundary(now time.Time) string {
return now.In(dingTalkMessageLocation).Format("2006-01-02 15:04:05")
}
// ChatMessages: fetch the message list of one conversation (group OR single
// chat) and print a clean projected list (speaker / text / time) instead of a
// raw dump.
@@ -35,8 +43,8 @@ import (
// aliases and one level of nesting;
// 3. print via rt.Output as {messages, count} so it honours --format/--jq/--fields.
//
// Read-only: it only reads a conversation's messages and reshapes them locally,
// never posts or mutates anything.
// The default path only reads and reshapes conversation messages;
// --download-resources additionally writes resource files locally.
//
// 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
@@ -47,39 +55,43 @@ var ChatMessages = shortcut.Shortcut{
Description: "拉取某个会话(群聊或单聊)的消息列表并投影出发言人/文本/时间",
Intent: "当你想快速看某个会话里的消息(谁在什么时间说了什么),而不想拿到一大坨原始消息字段时使用;" +
"群聊传 --group(群会话 ID,openConversationId),单聊传 --user(对方 userId),两者互斥且必须二选一。" +
"可选 --time 指定起始时间、--limit 指定每页条数、--direction newer/older 控制时间方向(newer 从给定时间往现在拉,older 往以前拉)。" +
"省略 --time 时默认从当前时间向前读取最近消息;也可指定时间边界并用 --direction newer/older 控制方向。" +
"内部据此调用群聊或单聊的消息列表接口,再在本地投影出每条消息的发言人、文本和时间。" +
"这是纯只读操作,只做拉取与本地投影,不会发送或修改任何消息。",
"默认只读且不会发送或修改任何消息;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘,按既有安全下载约定无需交互确认。",
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"] = formatDingTalkMessageBoundary(time.Now())
}
if limit := rt.IntFirst("limit", "size"); limit > 0 {
params["limit"] = limit
@@ -94,11 +106,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 +141,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)
},
}
@@ -198,7 +216,7 @@ func projectChatMessageWithReactions(m map[string]any, includeReactions bool) ma
if quoted := chatmsg.QuotedMessage(m); len(quoted) > 0 {
row["quotedMessage"] = quoted
}
if resources := chatmsg.Resources(m); len(resources) > 0 {
if resources := chatmsg.ResourcesDeep(m); len(resources) > 0 {
row["resourceRefs"] = resources
}
projectForwarded := func(item map[string]any) map[string]any {
@@ -67,7 +67,7 @@ func TestCrossPlatformCoverageRichMessageProjections(t *testing.T) {
"emotionReplyList": []any{map[string]any{"emojiName": "赞", "replyCount": 1}},
"quotedMessage": map[string]any{
"openMessageId": "quoted",
"content": "quoted body",
"content": `{"mediaId":"@quoted-image"}`,
},
}
for name, row := range map[string]map[string]any{
@@ -80,6 +80,16 @@ func TestCrossPlatformCoverageRichMessageProjections(t *testing.T) {
t.Errorf("%s projection missing %s: %#v", name, key, row)
}
}
resources := row["resourceRefs"].([]map[string]any)
if len(resources) != 2 {
t.Fatalf("%s projected resources = %#v", name, resources)
}
quotedArgs := resources[1]["download"].(map[string]any)["arguments"].(map[string]any)
if resources[1]["resourceId"] != "@quoted-image" ||
quotedArgs["message-id"] != "quoted" ||
quotedArgs["open-conversation-id"] != "cid" {
t.Fatalf("%s quoted resource context = %#v", name, resources[1])
}
}
}
@@ -89,7 +99,7 @@ func TestCrossPlatformCoverageChatMessagesOpenIDRoute(t *testing.T) {
}}
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
root.SetArgs([]string{"chat", "+chat-messages", "--open-dingtalk-id", "D-user", "--limit", "1"})
root.SetArgs([]string{"chat", "+chat-messages", "--open-dingtalk-id", "D-user", "--limit", "1", "--yes"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
@@ -202,7 +212,7 @@ func TestCrossPlatformCoverageSearchValidationAndTimeErrors(t *testing.T) {
for _, tail := range cases {
helpers.InitDeps(&smartCoverageCaller{})
root := newPlatformCoverageRoot()
root.SetArgs(append([]string{"chat", "+search-msg"}, tail...))
root.SetArgs(append([]string{"chat", "+search-msg", "--yes"}, tail...))
if err := root.Execute(); err == nil {
t.Errorf("invalid search args succeeded: %v", tail)
}
@@ -247,7 +257,7 @@ func TestCrossPlatformCoverageSearchPaginationFailureModes(t *testing.T) {
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs(append([]string{"chat", "+search-msg"}, tc.args...))
root.SetArgs(append([]string{"chat", "+search-msg", "--yes"}, tc.args...))
err := root.Execute()
if (err != nil) != tc.wantError {
t.Fatalf("error = %v, wantError=%v", err, tc.wantError)
+217
View File
@@ -0,0 +1,217 @@
// 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"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
func TestCrossPlatformCoverageSafeResourceQueryDownloadsStayReadOnly(t *testing.T) {
for _, command := range []shortcut.Shortcut{AtMe, ChatMessages, SearchMsg, ThreadReplies} {
if command.Risk != shortcut.RiskRead {
t.Errorf("%s risk contract = %q", command.Command, command.Risk)
}
}
}
func TestCrossPlatformCoverageMessageReadShortcutsPublishResourceDownloadPlans(t *testing.T) {
message := `{"openMessageId":"msg","openConversationId":"cid","content":"{\"mediaId\":\"@image\"}","quotedMessage":{"openMessageId":"quoted","content":"{\"fileId\":\"@quoted-file\"}"}}`
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())
}
rows, ok := payload[tc.resultKey].([]any)
if !ok || len(rows) != 1 {
t.Fatalf("payload missing %s: %#v", tc.resultKey, payload)
}
row, _ := rows[0].(map[string]any)
resources, _ := row["resourceRefs"].([]any)
if len(resources) != 2 {
t.Fatalf("visible resources = %#v", resources)
}
ledger, _ := payload["resourceDownloads"].(map[string]any)
if ledger["dryRun"] != true ||
ledger["discoveredCount"] != float64(2) ||
ledger["requestedCount"] != float64(2) {
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", "--yes"))
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),
dingTalkMessageLocation,
)
if err != nil {
t.Fatal(err)
}
if boundary.Before(before) || boundary.After(after) {
t.Fatalf("default time = %s, want current boundary", boundary)
}
}
func TestFormatDingTalkMessageBoundaryDoesNotDependOnProcessTimezone(t *testing.T) {
now := time.Date(2026, time.July, 29, 1, 2, 3, 0, time.UTC)
if got := formatDingTalkMessageBoundary(now); got != "2026-07-29 09:02:03" {
t.Fatalf("UTC process boundary = %q, want DingTalk UTC+8 wall time", got)
}
}
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",
"--yes",
})
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")
}
}
+83 -11
View File
@@ -21,6 +21,7 @@ import (
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
chatshortcut "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chat"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg"
)
@@ -35,9 +36,10 @@ var SearchMsg = shortcut.Shortcut{
Product: "im",
Description: "多维搜索消息,可全量翻页并批量富化详情",
Intent: "当你要按关键词、发送者、@对象、会话、消息类型或机器人来源组合搜索 IM 消息时使用;默认查询近 7 天," +
"也可指定精确起止时间。--page-all 会连续拉取游标页,默认再按消息 ID 分批富化详情;任何续页或富化失败都会保留已取得结果并返回逐项失败 ledger,绝不把截断结果标成完整。",
"也可指定精确起止时间。--page-all 会连续拉取游标页,默认再按消息 ID 分批富化详情;任何续页或富化失败都会保留已取得结果并返回逐项失败 ledger,绝不把截断结果标成完整。" +
"--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘,按既有安全下载约定无需交互确认。",
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 +67,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 +90,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 +207,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 +409,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 +433,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 {
@@ -453,7 +525,7 @@ func searchMsgProjectWithReactions(m map[string]any, includeReactions bool) map[
if quoted := chatmsg.QuotedMessage(m); len(quoted) > 0 {
row["quotedMessage"] = quoted
}
if resources := chatmsg.Resources(m); len(resources) > 0 {
if resources := chatmsg.ResourcesDeep(m); len(resources) > 0 {
row["resourceRefs"] = resources
}
projectForwarded := func(item map[string]any) map[string]any {
@@ -73,7 +73,7 @@ func executeSearchMsg(t *testing.T, caller *searchMsgExecutionCaller, args ...st
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs(append([]string{"chat", "+search-msg"}, args...))
root.SetArgs(append([]string{"chat", "+search-msg", "--yes"}, args...))
if err := root.Execute(); err != nil {
t.Fatal(err)
}
+12 -7
View File
@@ -15,6 +15,7 @@ package smart
import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
chatshortcut "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chat"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg"
)
@@ -29,8 +30,8 @@ import (
// project each reply to {sender, text, createTime} tolerating field aliases;
// 3. print via rt.Output as {replies, count} so it honours --format/--jq/--fields.
//
// Read-only: it only reads a topic's replies and reshapes them locally, never
// posts or mutates anything.
// The default path only reads and reshapes topic replies;
// --download-resources additionally writes resource files locally.
//
// dws chat +thread-replies --group <openconversationId> --thread-id <threadId>
var ThreadReplies = shortcut.Shortcut{
@@ -41,27 +42,28 @@ var ThreadReplies = shortcut.Shortcut{
Intent: "当你已经拿到某个群里一条「话题消息」的 threadId/topicId、想快速看这条话题下的全部回复(谁在什么时间回复了什么)," +
"而不想拿到一大坨原始消息字段时使用;内部按 --group(群会话 ID)和 --thread-id(兼容 --topic-id)拉取该话题的回复列表," +
"可选 --time 指定起始时间、--limit 指定每页条数,再在本地投影出每条回复的发言人、文本和回复时间。" +
"这是纯只读操作,只做拉取与本地投影,不会发送或修改任何消息。",
"默认只读且不会发送或修改任何消息;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘,按既有安全下载约定无需交互确认。",
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)
},
}
+16 -16
View File
@@ -43,22 +43,22 @@ cli_version: ">=1.0.15"
| 服务 | shortcut 数 | multi skill | 发现命令 |
|---|---:|---|---|
| `aitable` | 29 | `dingtalk-aitable` | `dws shortcut list --service aitable --format json` |
| `attendance` | 19 | `dingtalk-misc` | `dws shortcut list --service attendance --format json` |
| `calendar` | 20 | `dingtalk-calendar` | `dws shortcut list --service calendar --format json` |
| `chat` | 42 | `dingtalk-chat` | `dws shortcut list --service chat --format json` |
| `contact` | 14 | `dingtalk-contact` | `dws shortcut list --service contact --format json` |
| `devapp` | 19 | `dingtalk-dev` | `dws shortcut list --service devapp --format json` |
| `ding` | 4 | `dingtalk-misc` | `dws shortcut list --service ding --format json` |
| `doc` | 17 | `dingtalk-doc` | `dws shortcut list --service doc --format json` |
| `drive` | 7 | `dingtalk-drive` | `dws shortcut list --service drive --format json` |
| `mail` | 10 | `dingtalk-mail` | `dws shortcut list --service mail --format json` |
| `minutes` | 6 | `dingtalk-minutes` | `dws shortcut list --service minutes --format json` |
| `oa` | 7 | `dingtalk-misc` | `dws shortcut list --service oa --format json` |
| `report` | 2 | `dingtalk-misc` | `dws shortcut list --service report --format json` |
| `sheet` | 2 | `dingtalk-misc` | `dws shortcut list --service sheet --format json` |
| `todo` | 11 | `dingtalk-todo` | `dws shortcut list --service todo --format json` |
| `wiki` | 1 | `dingtalk-wiki` | `dws shortcut list --service wiki --format json` |
| `aitable` | 29 | `dingtalk-workspace` | `dws shortcut list --service aitable --format json` |
| `attendance` | 19 | `dingtalk-workspace` | `dws shortcut list --service attendance --format json` |
| `calendar` | 20 | `dingtalk-workspace` | `dws shortcut list --service calendar --format json` |
| `chat` | 97 | `dingtalk-workspace` | `dws shortcut list --service chat --format json` |
| `contact` | 14 | `dingtalk-workspace` | `dws shortcut list --service contact --format json` |
| `devapp` | 19 | `dingtalk-workspace` | `dws shortcut list --service devapp --format json` |
| `ding` | 4 | `dingtalk-workspace` | `dws shortcut list --service ding --format json` |
| `doc` | 17 | `dingtalk-workspace` | `dws shortcut list --service doc --format json` |
| `drive` | 7 | `dingtalk-workspace` | `dws shortcut list --service drive --format json` |
| `mail` | 10 | `dingtalk-workspace` | `dws shortcut list --service mail --format json` |
| `minutes` | 6 | `dingtalk-workspace` | `dws shortcut list --service minutes --format json` |
| `oa` | 7 | `dingtalk-workspace` | `dws shortcut list --service oa --format json` |
| `report` | 2 | `dingtalk-workspace` | `dws shortcut list --service report --format json` |
| `sheet` | 2 | `dingtalk-workspace` | `dws shortcut list --service sheet --format json` |
| `todo` | 11 | `dingtalk-workspace` | `dws shortcut list --service todo --format json` |
| `wiki` | 1 | `dingtalk-workspace` | `dws shortcut list --service wiki --format json` |
<!-- VISIBLE_SHORTCUTS_OVERVIEW_END -->
## 多组织 / 多账号
+36 -21
View File
@@ -29,19 +29,21 @@
| "明早 9 点提醒我提交周报" | 创建个人待办后添加提醒 | `todo task create` + `todo task add-reminder` | `calendar` | `+remind --at` 只写截止时间;提醒可写入,但上游不支持规则读回 |
| "帮我建一个项目群" | 创建群聊 | `chat group create` | — | 群聊管理,不是日历日程 |
| "把张三拉进群" | 添加群成员 | `chat group members add` | — | 先查 userId,再添加 |
| "通知群里的人都来开会" | 个人身份群发 | `chat message send` | `chat message send-by-bot` | 以个人身份向群发消息 |
| "让机器人每天推送日报" | 机器人定时推送 | `chat message send-by-bot` | `chat message send` | 需要机器人身份定期发送 |
| "给张三发一条机器人单聊消息" | 机器人单聊 | `chat message send-by-bot --users` | — | 机器人批量单聊,先查 userId |
| "CPU 超过 90% 自动告警" | Webhook 告警 | `chat message send-by-webhook` | `chat message send-by-bot` | 系统告警场景,需自定义 Webhook |
| "通知群里的人都来开会" | 个人身份群发 | `chat +messages-send --as user` | `chat message send` | 统一入口校验身份、目标、内容和幂等键 |
| "让机器人每天推送日报" | 机器人定时推送 | `chat +messages-send --as bot` | `chat message send-by-bot` | 需要 robotCode;定时调度由外层工作流负责 |
| "给张三发一条机器人单聊消息" | 机器人单聊 | `chat +messages-send --as bot --users` | `chat message send-by-bot --users` | 机器人批量单聊,先查 userId |
| "CPU 超过 90% 自动告警" | Webhook 告警 | `chat +messages-send --as webhook` | `chat message send-by-webhook` | 系统告警场景,需自定义 Webhook token |
| "帮我看看收到的日报" | 收到的日志 | `report` | `doc` | 钉钉日志系统(日报/周报),不是文档 |
| "帮我创建一个待办提醒" | 个人待办 | `todo` | `report` | 个人任务提醒,不是日志汇报 |
| "拉取一下上周项目群的聊天记录" | 拉取会话消息 | `chat message list` | — | 拉取指定群聊的消息列表 |
| "拉取一下上周项目群的聊天记录" | 拉取会话消息 | `chat +chat-messages` | `chat message list` | Shortcut 投影发言人/文本/时间并可处理资源 |
| "看看张三发给我的消息" | 按发送者查询消息 | `chat message list-by-sender` | `chat message list --user` | 用户未明确说"单聊"时优先用 list-by-sender(跨单聊/群聊) |
| "拉取和张三的单聊记录" | 拉取单聊消息 | `chat message list --user` | `chat message list-by-sender` | 用户明确说"单聊"时用 list --user |
| "谁@了我/查看提及我的消息" | 查询@我的消息 | `chat message list-mentions` | `chat message list-all` | 都是跨会话时间范围查询,但 list-mentions 只返回@我的消息 |
| "谁@了我/查看提及我的消息" | 查询@我的消息 | `chat +at-me` | `chat message list-mentions` | Shortcut 自动时间窗、投影和资源处理 |
| "查看我今天的所有消息" | 全量会话消息 | `chat message list-all` | `chat message list` | 用户未指定具体会话时用 list-all(跨所有会话),指定了具体群或人时用 list |
| "搜一下消息里的changefree链接" | 消息搜索 | `chat message search-advanced`(首选) | `chat search` | 推荐首选 search-advanced,它是 search 的严格超集(keyword 可选、支持多群、可叠加发送者/at 维度) |
| "按发送者搜索/指定多个群搜索/多维度搜消息" | 多维度搜索消息 | `chat message search-advanced`(首选) | `chat message search` | 推荐首选,支持关键词、发送者、@我、多个会话等维度组合 |
| "搜一下消息里的changefree链接" | 消息搜索 | `chat +search-msg` | `chat message search-advanced` | Shortcut 支持关键词、发送者、会话、消息类型、时间窗、全量翻页和批量富化 |
| "按发送者搜索/指定多个群搜索/多维度搜消息" | 多维度搜索消息 | `chat +search-msg` | `chat message search-advanced` | 需要原子响应结构时才降级 |
| "查看这条话题的所有回复" | 话题回复 | `chat +thread-replies` | `chat message list-topic-replies` | 已知 threadId/topicId 时自动投影回复人、正文和时间 |
| "把这些消息里的附件下载下来" | 消息资源下载 | 查询 Shortcut 的 `--download-resources` | `resourceRefs.shortcut` 返回的下载命令 | 先用查询结果的 resourceRefs;单资源再执行返回的可运行参数 |
| "消息发没发成功/查询消息发送状态" | 查询消息发送状态 | `chat message query-send-status` | — | 需要 send 返回的 openTaskId |
| "撤回我发的消息/撤回消息" | 撤回个人消息 | `chat message recall` | `chat message recall-by-bot` | recall 撤回个人消息,recall-by-bot 撤回机器人消息 |
| "未读消息会话/未读会话列表/我的未读会话" | 未读会话列表 | `chat message list-unread-conversations` | `chat message read-status` | list-unread-conversations 查哪些会话有未读;read-status 查具体消息的已读状态 |
@@ -267,29 +269,37 @@ alidocs 链接表面长得一样(`https://alidocs.dingtalk.com/i/nodes/{id}`
### 5. chat 内部 — 消息发送与撤回
**用 `chat message send` 的场景**:
**优先用 `chat +messages-send` 的场景**:
- "帮我在群里发个消息提醒大家" — **个人身份**发群消息
- "发个单聊消息给某人" — 个人身份发单聊:
- 已有 userId 时直接使用 `--user`;已有 openDingTalkId 时使用 `--open-dingtalk-id`
- 纯文本/Markdown 单聊传 `--user` 时直接走 userId 发送能力,不需要先手动查询 openDingTalkId
- 富媒体消息(image/file)单聊优先使用 `--open-dingtalk-id`;传 `--user` 时 CLI 会尝试解析为 openDingTalkId 后发送
- `+messages-send --user` 对所有内容类型都会通过通讯录关键词搜索并按 userId 精确匹配 openDingTalkId;无需手动预查,`--dry-run` 也会执行这次只读解析
- 已持有 openDingTalkId 时优先使用显式 `--open-dingtalk-id`,避免额外解析
- "发本地图片/文件/语音/视频到群里" — `dws chat message send ... --msg-type file --file-path <本地路径>`,CLI 内部自动上传并发送;png/jpg/pdf/mp4/zip 等任意扩展名都走这条,接收方看到的是可下载的文件附件。图片不会内联渲染,也不会生成 mediaId
- "用已有 mediaId 发内联图片" — 仅当上游已经提供有效 mediaId 时,使用 `dws chat message send ... --msg-type image --media-id <mediaId>`;DWS CLI 不能把本地图片转换成 mediaId
- "发图片+文字说明" — 不要硬塞进一条命令;先发图片/文件消息再补一条 `--text "..."` 即可
```bash
dws chat message send --group <openConversationId> --msg-type file --file-path ./screenshot.png --format json
dws chat message send --open-dingtalk-id <openDingTalkId> --msg-type file --file-path ./report.pdf --format json
dws chat +messages-send --as user --chat-id <openConversationId> --msg-type file --file ./screenshot.png --idempotency-key <key> --format json
dws chat +messages-send --as user --open-dingtalk-id <openDingTalkId> --msg-type file --file ./report.pdf --idempotency-key <key> --format json
```
> ❌ 反模式:先把本地文件转换成 mediaId,或先经钉盘上传再拼装聊天消息。本地图片/文件一律用 `--msg-type file --file-path` 直发;`--msg-type image --media-id` 只接受上游已经提供的有效 mediaId。
> 富媒体消息单聊优先使用 `--open-dingtalk-id`;传 `--user` 时 CLI 会尝试解析为 openDingTalkId 后发送。
> ❌ 反模式:先把本地文件转换成 mediaId,或先经钉盘上传再拼装聊天消息。本地图片/文件一律用 `--msg-type file --file` 直发;`--msg-type image --media-id` 只接受上游已经提供的有效 mediaId。
> 单聊已持有 openDingTalkId 时优先使用 `--open-dingtalk-id`;传 `--user` 时 CLI 会通过通讯录关键词搜索做 userId 精确匹配后发送。
**用 `chat message send-by-bot` 的场景**:
`+messages-send` 的 @ 占位符按身份自动规范化并补齐:user 使用 `<@id>` / `<@all>`;bot/webhook 使用 `@id` / `@手机号` / `@all`。声明 `--at-*` / `--at-all` 即可,不要再手工拼 `@10`。
**用 `chat +messages-send-card` 的场景**:
- 群聊流式卡片使用 `--group <openConversationId>`。
- 单聊已有 userId 时使用 `--receiver <userId>`,CLI 始终通过通讯录关键词搜索并按 userId 精确匹配 openDingTalkId;即使 userId 以 D/d 开头也不会猜测类型,`--dry-run` 也会执行该解析。
- 单聊已有 openDingTalkId 时必须显式使用 `--receiver-open-dingtalk-id <openDingTalkId>`,避免与 userId 混淆。
- `--group`、`--receiver`、`--receiver-open-dingtalk-id` 严格三选一;传 `--content` 可在同一次调用中创建并结束卡片。
**用 `chat +messages-send --as bot` 的场景**:
- "让机器人在群里发一条通知" — **机器人身份**发消息
- "给张三发一条机器人单聊消息" — 机器人单聊
**用 `chat message send-by-webhook` 的场景**:
**用 `chat +messages-send --as webhook` 的场景**:
- "通过 Webhook 发告警到群里" — 自定义机器人 Webhook
- 用户有 Webhook Token
@@ -302,16 +312,21 @@ dws chat message send --open-dingtalk-id <openDingTalkId> --msg-type file --file
用 `chat message query-send-status` 的场景:
- "消息发没发成功/查询消息发送状态" — 查询个人发送消息的状态,需要 send 返回的 openTaskId
用 `chat message search-advanced` 的场景(推荐首选):
用 `chat +search-msg` 的场景(推荐首选):
- "按发送者搜索消息/指定多个群搜索/@我的消息多维度搜" — 支持关键词、发送者、@我、@指定人、多个会话等维度组合搜索
- 替代关系:完全替代 `chat message search`(严格超集:keyword 可选 vs 必填,支持多群 vs 单群);大部分替代 `chat message list-by-sender`(--user/--users 覆盖按 userId 搜索发送者,--sender-ids 覆盖按 openDingTalkId 搜索)和 `chat message list-mentions`(--at-me 覆盖核心功能)
- `--page-all` 连续拉取游标页,默认按消息 ID 批量富化;续页或富化失败保留已取得结果并返回逐项失败 ledger
- 替代关系:覆盖 `chat message search-advanced` 的常见组合过滤;需要原子接口返回结构或 Shortcut 尚未暴露的字段时再降级
- 不能替代:`chat message list-focused`(「特别关注人」是独立维度)
- 默认使用 search-advanced,仅在上述不适用场景才降级到具体命令
- 默认使用 `+search-msg`,仅在上述不适用场景才降级到具体命令
资源读取优先在 `+at-me`、`+chat-messages`、`+messages-mget`、`+search-msg`、`+thread-replies` 上加 `--download-resources`。引用、回复或合并转发里的资源使用结果 `resourceRefs` 自带的子消息 `messageId`;子消息没有会话 ID 时才继承父消息会话。单独下载一个资源时用 `+messages-resource-download`,输出必须是工作目录内的相对路径,默认拒绝覆盖。
上述五个查询 Shortcut 与 `+messages-resource-download` 都沿用安全本地下载的 `read/not_required` 契约,不应添加 `--yes` 或触发交互确认。输出只允许工作目录内相对路径、默认不覆盖并原子落盘;需要覆盖时必须由用户显式传 `--overwrite`。
**不支持的场景**:
- "撤回我刚发的消息"(但不知道消息 ID) — 需先通过消息拉取或搜索接口(如 `chat message list`、`chat message search-advanced` 等)获取 openMessageId,再调用 `chat message recall`
判断关键:个人发→ `send`;机器人发→ `send-by-bot`;有 Webhook Token→ `send-by-webhook`;个人撤回→ `recall`;机器人撤回→ `recall-by-bot`;查发送状态→ `query-send-status`;消息搜索类意图优先路由到 `search-advanced`(推荐首选),仅在不适用时降级到具体命令
判断关键:普通发送统一走 `+messages-send` 并显式选择 `--as`;个人撤回→ `recall`;机器人撤回→ `recall-by-bot`;查发送状态→ `query-send-status`;消息搜索类意图优先路由到 `+search-msg`,仅在不适用时降级到原子命令。
---
+21
View File
@@ -2,6 +2,27 @@
## 命令总览
## Shortcut 优先路由
常见 Agent 意图优先使用公开 `+` Shortcut;下面的原子命令章节保留给需要特定原始返回结构、兼容参数或 Shortcut 未覆盖字段的场景。执行前用 `dws schema --cli-path "chat +<shortcut>" --format json` 读取最终参数、约束和确认语义。
| 意图 | 首选 |
|---|---|
| 以 current-user / bot / webhook 身份发消息 | `dws chat +messages-send --as <identity> ...` |
| 拉取单个群聊或单聊的消息 | `dws chat +chat-messages ...` |
| 按关键词、发送者、@对象、会话、类型或时间组合搜索 | `dws chat +search-msg ...` |
| 查询 @我的消息 | `dws chat +at-me ...` |
| 根据消息 ID 批量取详情与 reaction | `dws chat +messages-mget ...` |
| 读取已知 thread/topic 的全部回复 | `dws chat +thread-replies ...` |
| 下载单个 mediaId/fileId | `dws chat +messages-resource-download ...` |
- `+messages-send` 只暴露下层真实支持的身份能力:user 支持文本/Markdown、已有 mediaId 图片、本地文件和幂等键;bot 支持群聊或批量单聊文本/Markdown;webhook 的目标由 token 所在群决定。
- `+messages-send` 会自动规范化并补齐 @ 占位符。user 使用 `<@id>` / `<@all>`;bot/webhook 使用 `@id` / `@手机号` / `@all`。声明 `--at-*` / `--at-all` 即可,不要为统一 Shortcut 手工拼 `@10`。
- `+search-msg --page-all` 连续翻页并默认按消息 ID 批量富化;任何续页或富化失败都会保留已取得结果并返回逐项失败 ledger。
- `+at-me`、`+chat-messages`、`+messages-mget`、`+search-msg`、`+thread-replies` 可用 `--download-resources` 下载资源。引用、回复、合并转发中的资源使用结果 `resourceRefs` 自带的子消息 `messageId`;仅当子消息缺会话 ID 时继承父消息 `openConversationId`。
- 上述五个查询 Shortcut 与 `+messages-resource-download` 都沿用安全本地下载的 `read/not_required` 契约,不应添加 `--yes` 或触发交互确认。下载只允许工作目录内相对路径、默认不覆盖并原子落盘;需要覆盖时必须由用户显式传 `--overwrite`。
- 下载器仅接受经审查的钉钉与公网 OSS HTTPS 地址并逐跳校验重定向;跨主机时不会转发下层提供的请求头。新官方域名被拒绝时记录错误中的 host 供审查,不要放宽为任意 HTTPS。
### group (群组管理)
#### 创建群 — 当前登录用户自动成为群主
+77 -9
View File
@@ -1,6 +1,6 @@
---
name: dingtalk-chat
description: 钉钉群聊与消息。Use when 用户提到 发消息/单聊/群聊/建群/拉人进群/改群名/搜索群/群成员管理/@消息/撤回消息/机器人群发/Webhook通知/发图片或文件到群/标记未读/清除红点/置顶消息/全部群列表。不做紧急 DING/短信/电话(走 dingtalk-misc)、邮件(走 dingtalk-mail)、班级群(走 dingtalk-misc)。命令前缀:dws chat。
description: 钉钉群聊与消息。Use when 用户提到 发消息/编辑或撤回消息/单聊/群聊/建群/普通群升级外部群/群昵称/会话分组/群成员管理/@消息/搜索聊天记录/话题回复/收藏消息/机器人群发/Webhook通知/发送或下载消息图片与文件。不做紧急 DING/短信/电话(走 dingtalk-misc)、邮件(走 dingtalk-mail)、班级群(走 dingtalk-misc)。命令前缀:dws chat。
metadata:
cli_version: ">=0.2.14"
category: product
@@ -28,58 +28,126 @@ metadata:
| `dws chat +bot-find` | read | 搜索全部可用机器人(含他人/官方,返回 openDingTalkId 可发单聊) |
| `dws chat +bot-search` | read | 搜索当前用户自己创建的机器人 |
| `dws chat +broadcast` | write | 按姓名逐一给多个人群发同一条单聊消息(自动解析 userId、逐个发送) |
| `dws chat +category-add-conversation` | write | 将会话移动到指定的自定义分组中 |
| `dws chat +category-create` | write | 创建用户自定义会话分组 |
| `dws chat +category-delete` | high-risk-write | 删除用户自定义会话分组 |
| `dws chat +category-list` | read | 获取用户自定义会话分组 |
| `dws chat +category-list-conversations` | read | 拉取指定自定义会话分组下的会话 |
| `dws chat +category-remove-conversation` | write | 将会话从指定的自定义分组中移出 |
| `dws chat +category-rename` | write | 更新用户自定义会话分组的名称 |
| `dws chat +chat-add-bot` | write | 将机器人添加到群中 |
| `dws chat +chat-audit-join` | write | 审批入群验证(通过/拒绝/删除/忽略/拉黑) |
| `dws chat +chat-bots` | read | 查看群内所有机器人 |
| `dws chat +chat-create` | write | 以当前用户身份创建钉钉群聊 |
| `dws chat +chat-dismiss` | high-risk-write | 解散群聊(不可逆,需群主权限) |
| `dws chat +chat-get-by-id` | read | 根据群号获取群聊信息 |
| `dws chat +chat-invite-url` | read | 获取群邀请链接 |
| `dws chat +chat-list-all` | read | 分页拉取我加入的所有群列表 |
| `dws chat +chat-list-join-requests` | read | 分页拉取入群验证记录 |
| `dws chat +chat-list-mine` | read | 拉取我创建/管理的群 |
| `dws chat +chat-members-get` | read | 根据成员 openDingTalkId 批量查询群成员详情 |
| `dws chat +chat-members-list` | read | 列出群成员并把用户与机器人分桶(支持群名语义解析) |
| `dws chat +chat-messages` | read | 拉取某个会话(群聊或单聊)的消息列表并投影出发言人/文本/时间 |
| `dws chat +chat-mute` | write | 全员禁言 / 取消全员禁言 |
| `dws chat +chat-mute-member` | write | 指定群成员禁言 / 取消禁言 |
| `dws chat +chat-quit` | write | 退出群聊 |
| `dws chat +chat-remove-bot` | high-risk-write | 从群内移除机器人 |
| `dws chat +chat-role-add` | write | 添加群身份 |
| `dws chat +chat-role-list` | read | 拉取会话的群身份列表 |
| `dws chat +chat-role-query-user` | read | 查询群成员的群身份 |
| `dws chat +chat-role-remove` | high-risk-write | 删除群身份 |
| `dws chat +chat-role-remove-user` | write | 移除用户的指定群身份 |
| `dws chat +chat-role-set-user` | write | 设置用户的群身份(覆盖该用户的全部群身份) |
| `dws chat +chat-role-update` | write | 更新群身份名称 |
| `dws chat +chat-search` | read | 按关键词搜索群聊 |
| `dws chat +chat-set-admin` | write | 设置 / 取消群管理员 |
| `dws chat +chat-set-history` | write | 设置新成员入群可查看历史消息范围 |
| `dws chat +chat-transfer-owner` | write | 转让群主 |
| `dws chat +chat-update` | write | 更新群名称(仅名称,不支持 description) |
| `dws chat +chat-update-alias` | write | 设置群备注(仅自己可见) |
| `dws chat +chat-update-icon` | write | 更新群头像 |
| `dws chat +chat-update-nick` | write | 设置当前用户在群内的群昵称 |
| `dws chat +chat-update-settings` | write | 更新群设置(settingKey + status) |
| `dws chat +conversation-clear-all-red-point` | write | 清除所有会话红点(全部已读) |
| `dws chat +conversation-clear-messages` | high-risk-write | 清空当前用户指定会话的聊天记录(仅本人视角,不可逆) |
| `dws chat +conversation-clear-red-point` | write | 清除会话红点 |
| `dws chat +conversation-hide` | write | 会话列表中隐藏会话(收到新消息会重新出现) |
| `dws chat +conversation-info` | read | 获取会话信息(群聊传 --group,单聊传 --open-dingtalk-id) |
| `dws chat +conversation-list` | read | 分页获取当前用户的全部会话列表(单聊+群聊) |
| `dws chat +conversation-list-top` | read | 拉取置顶会话列表 |
| `dws chat +conversation-list-top` | read | 拉取置顶会话列表,可只看群聊或单聊 |
| `dws chat +conversation-mark-read` | write | 标记消息已读(该消息及之前的消息都标记为已读) |
| `dws chat +conversation-mark-unread` | write | 标记会话为未读 |
| `dws chat +conversation-mute` | write | 会话消息免打扰(支持单聊/群聊) |
| `dws chat +conversation-set-top` | write | 批量会话置顶 / 取消置顶(最多 10 个) |
| `dws chat +dm` | write | 按姓名直接给某人发单聊消息(自动解析 userId) |
| `dws chat +feed-group-query-item` | read | 在会话分组结果中按会话 ID 精确查询多项 |
| `dws chat +flag-cancel` | write | 取消收藏一条或多条消息(最多 10 条) |
| `dws chat +flag-create` | write | 收藏一条或多条消息(最多 10 条) |
| `dws chat +flag-list` | read | 分页查询当前用户收藏的消息 |
| `dws chat +group-members` | read | 按群名列出群成员(自动搜群解析 openConversationId) |
| `dws chat +messages-add-emoji` | write | 对消息添加 emoji 表情回应 |
| `dws chat +messages-add-text-emotion` | write | 对消息添加文字表情回应 |
| `dws chat +messages-batch-recall-by-bot` | write | 机器人撤回单聊消息 |
| `dws chat +messages-batch-send-by-bot` | write | 机器人批量向用户发送单聊 Markdown 消息 |
| `dws chat +messages-combine-forward` | write | 合并转发多条消息 |
| `dws chat +messages-create-text-emotion` | write | 创建文字表情(获取 emotionId) |
| `dws chat +messages-forward` | write | 转发单条消息 |
| `dws chat +messages-forward-topic` | write | 转发话题消息到目标会话 |
| `dws chat +messages-list` | read | 拉取群聊会话消息 |
| `dws chat +messages-list-direct` | read | 拉取单聊会话消息 |
| `dws chat +messages-list-pin` | read | 拉取会话中钉住的消息列表 |
| `dws chat +messages-list-unread-conversations` | read | 获取有未读消息的会话列表 |
| `dws chat +messages-mget` | read | 根据消息 ID 批量查询消息(最多 50 条) |
| `dws chat +messages-query-send-status` | read | 查询消息发送状态 |
| `dws chat +messages-read-status` | read | 查询消息的已读/未读状态 |
| `dws chat +messages-recall` | write | 撤回当前用户发送的消息 |
| `dws chat +messages-recall-by-bot` | write | 机器人撤回群消息 |
| `dws chat +messages-remove-emoji` | write | 移除消息的 emoji 表情回应 |
| `dws chat +messages-remove-text-emotion` | write | 移除消息的文字表情回应 |
| `dws chat +messages-reply` | write | 以当前用户身份引用回复消息(自动补全原发送者) |
| `dws chat +messages-resource-download` | read | 安全下载消息资源(图片/视频/语音/文件)到工作目录内,无需交互确认 |
| `dws chat +messages-resource-url` | read | 获取消息资源(图片/视频/语音)下载链接 |
| `dws chat +messages-send` | write | 统一发送文本、Markdown、当前用户文件或已有 mediaId 图片 |
| `dws chat +messages-send-by-bot` | write | 机器人向群聊发送 Markdown 消息 |
| `dws chat +messages-send-by-webhook` | write | 自定义机器人 Webhook 发送群消息 |
| `dws chat +messages-send-card` | write | 创建流式卡片,可在同一次调用中写入内容并结束 |
| `dws chat +messages-set-pin` | write | 钉住消息(Pin) |
| `dws chat +messages-set-top` | write | 置顶消息 |
| `dws chat +messages-unset-pin` | write | 取消钉住消息(Unpin) |
| `dws chat +messages-unset-top` | write | 取消置顶消息 |
| `dws chat +messages-update-card` | write | 流式更新卡片内容(最后一次 --flow-status 应为 3) |
| `dws chat +my-groups` | read | 列出我加入的群,可按类型过滤并投影关键字段 |
| `dws chat +search-msg` | read | 多维搜索消息,可全量翻页并批量富化详情 |
| `dws chat +send-to-group` | write | 按群名直接给群发消息(自动搜群解析 openConversationId) |
| `dws chat +thread-replies` | read | 拉取某条话题消息的全部回复并投影出发言人/文本/时间 |
| `dws chat +unread-chats` | read | 列出我有未读消息的会话(投影会话名/未读数/会话ID) |
<!-- VISIBLE_SHORTCUTS_END -->
## IM Shortcut 优先路由
- 统一发送优先用 `chat +messages-send`:通过 `--as user|bot|webhook` 选择身份,并只传该身份真实支持的目标、内容和凭据。仅在需要原子命令的特定返回结构或兼容参数时,才降级到 `chat message send` / `send-by-bot` / `send-by-webhook`。
- 消息查询按意图选择:指定会话用 `+chat-messages`,跨维度过滤/全量翻页用 `+search-msg`,@我用 `+at-me`,已知消息 ID 批量富化用 `+messages-mget`,已知 thread/topic ID 读取回复用 `+thread-replies`。
- 查询结果中的 `resourceRefs` 是可继续执行的资源上下文。引用、回复或合并转发中的子消息必须使用该子消息返回的 `messageId`;子消息缺会话 ID 时才继承父消息的 `openConversationId`。
- 上述五个查询 Shortcut 与 `+messages-resource-download` 都沿用安全本地下载的 `read/not_required` 契约,不应添加 `--yes` 或触发交互确认。下载只允许工作目录内相对路径、默认不覆盖并原子落盘;需要覆盖时必须由用户显式传 `--overwrite`。
- `+messages-send` 会按身份规范化 @ 占位符:user 使用 `<@id>` / `<@all>`,bot/webhook 使用 `@id` / `@手机号` / `@all`;只需声明 `--at-*` / `--at-all`,缺失占位符会自动补齐。
- `+messages-send --as user --user <userId>` 会通过通讯录关键词搜索并按 userId 精确匹配 openDingTalkId,所有内容类型与 `--dry-run` 都使用同一只读解析链路;已有 openDingTalkId 时直接传 `--open-dingtalk-id`。
- 流式卡片优先用 `+messages-send-card`:群聊传 `--group`,单聊 userId 传 `--receiver` 并由 CLI 通过通讯录关键词搜索做 userId 精确匹配,已有 openDingTalkId 则必须显式传 `--receiver-open-dingtalk-id`;三者严格三选一,禁止根据首字母猜身份类型。
## 意图表
| 用户说 | 命令 |
|--------|------|
| "发消息给张三" | `dws chat message send --open-dingtalk-id <id> --text "<内容>"` |
| "发到XX群" | `dws chat search --query "<群名>"` → `dws chat message send --group <openConversationId> --text "<内容>"` |
| "发消息给张三" | `dws chat +messages-send --as user --open-dingtalk-id <id> --text "<内容>"` |
| "发到XX群" | `dws chat +chat-search --query "<群名>"` → `dws chat +messages-send --as user --chat-id <openConversationId> --text "<内容>"` |
| "建群" / "拉人进群" | `dws chat group create` / `dws chat group members add` |
| "改群名" / "踢人" | `dws chat group rename` / `dws chat group members remove` |
| "@我消息" | `dws chat message list-mentions` |
| "查群聊记录" | `dws chat search --query "<群名>"` → `dws chat message list --group <openConversationId> --time "<yyyy-MM-dd HH:mm:ss>" --direction older` |
| "用机器人发消息" | `dws chat message send-by-bot --robot-code <code> --group <id>` |
| "Webhook 推一条" | `dws chat message send-by-webhook --token <token>` |
| "改群名" / "踢人" | `dws chat group rename` / `dws chat group members remove --yes`(踢人不可逆,先确认目标) |
| "@我消息" | `dws chat +at-me` |
| "查群聊记录" | `dws chat +chat-messages --group <openConversationId>` |
| "收藏/取消收藏这条消息" | `dws chat +flag-create` / `dws chat +flag-cancel` |
| "查看我收藏的消息" | `dws chat +flag-list` |
| "用机器人发消息" | `dws chat +messages-send --as bot --robot-code <code> --chat-id <id> --text "<内容>"` |
| "Webhook 推一条" | `dws chat +messages-send --as webhook --webhook-token <token> --text "<内容>"` |
| "发送流式卡片" | `dws chat +messages-send-card --group <openConversationId> --content "<内容>"`;单聊 userId 改用 `--receiver`,openDingTalkId 改用 `--receiver-open-dingtalk-id` |
| "撤回消息" | `dws chat message recall --conversation-id <openConversationId> --msg-id <openMessageId>` |
| "标记未读 / 清除红点 / 全部已读" | `dws chat mark-unread` / `dws chat clear-red-point` / `dws chat clear-all-red-point` |
| "置顶某条消息 / 取消消息置顶" | `dws chat message set-top-msg` / `dws chat message unset-top-msg` |
@@ -4,6 +4,26 @@
> 命令别名:`dws im` 等价于 `dws chat`。
## Shortcut 优先路由
常见 Agent 意图优先使用公开 `+` Shortcut;原子命令保留给需要特定原始返回结构、兼容参数或 Shortcut 未覆盖字段的场景。执行前用 `dws schema --cli-path "chat +<shortcut>" --format json` 读取最终参数、约束和默认确认语义。
| 意图 | 首选 |
|---|---|
| 以 current-user / bot / webhook 身份发消息 | `dws chat +messages-send --as <identity> ...` |
| 拉取单个群聊或单聊的消息 | `dws chat +chat-messages ...` |
| 按关键词、发送者、@对象、会话、类型或时间组合搜索 | `dws chat +search-msg ...` |
| 查询 @我的消息 | `dws chat +at-me ...` |
| 根据消息 ID 批量取详情与 reaction | `dws chat +messages-mget ...` |
| 读取已知 thread/topic 的全部回复 | `dws chat +thread-replies ...` |
| 下载单个 mediaId/fileId | `dws chat +messages-resource-download ...` |
- `+messages-send` 只暴露下层真实支持的身份能力,并自动规范化、补齐对应身份的 @ 占位符。
- `+search-msg --page-all` 连续翻页并默认按消息 ID 批量富化;续页或富化失败会保留已取得结果并返回逐项失败 ledger。
- 五个查询 Shortcut 的 `--download-resources` 沿用安全本地下载的 `read/not_required` 契约,不应添加 `--yes` 或触发交互确认。引用、回复、合并转发中的资源使用 `resourceRefs` 自带的子消息 `messageId`;仅当子消息缺会话 ID 时继承父消息 `openConversationId`。
- `+messages-resource-download` 同样无需交互确认,但只允许工作目录内相对路径、默认拒绝覆盖并原子落盘;需要覆盖时必须由用户显式传 `--overwrite`。
- 下载器只接受经审查的钉钉与公网 OSS HTTPS 地址并逐跳校验重定向;跨主机时不会转发下层提供的请求头。
## 适用范围与安全硬约束
`chat` 覆盖钉钉会话、群聊、群成员、会话消息、机器人消息、Webhook、会话状态和群身份管理。
@@ -288,8 +288,8 @@ alidocs 链接表面长得一样(`https://alidocs.dingtalk.com/i/nodes/{id}`
- "帮我在群里发个消息提醒大家" — **个人身份**发群消息
- "发个单聊消息给某人" — 个人身份发单聊:
- 已有 userId 时直接使用 `--user`;已有 openDingTalkId 时使用 `--open-dingtalk-id`
- 纯文本/Markdown 单聊传 `--user` 时直接走 userId 发送能力,不需要先手动查询 openDingTalkId
- 富媒体消息(image/file)单聊优先使用 `--open-dingtalk-id`;传 `--user` 时 CLI 会尝试解析为 openDingTalkId 后发送
- `+messages-send --user` 对所有内容类型都会通过通讯录关键词搜索并按 userId 精确匹配 openDingTalkId;无需手动预查,`--dry-run` 也会执行这次只读解析
- 已持有 openDingTalkId 时优先使用显式 `--open-dingtalk-id`,避免额外解析
- "发张图片/截图/语音/视频/文件到群里" / "发张图给某某" — **统一一条命令**:`dws chat message send ... --msg-type file --file-path <本地路径>`,CLI 内部自动上传并发送,**任意扩展名(png/jpg/pdf/mp4/zip…)都走这条**
- "发图片+文字说明" — 不要硬塞进一条命令;先发文件消息再补一条 `--text "..."` 即可
@@ -299,7 +299,13 @@ dws chat message send --open-dingtalk-id <openDingTalkId> --msg-type file --file
```
> ❌ 反模式:调 `dt_media_upload` / `extract_media_id.py` / `drive upload` / `drive download` 等前置工具再 `--msg-type image --media-id`。这是**旧链路**,仅当上游已持有 mediaId 才用;新场景一律 `--file-path` 直发,避免长链路与“空白图”现象。
> 富媒体消息单聊优先使用 `--open-dingtalk-id`;传 `--user` 时 CLI 会尝试解析为 openDingTalkId 后发送。
> 单聊已持有 openDingTalkId 时优先使用 `--open-dingtalk-id`;传 `--user` 时 CLI 会通过通讯录关键词搜索做 userId 精确匹配后发送。
**用 `chat +messages-send-card` 的场景**:
- 群聊流式卡片使用 `--group <openConversationId>`。
- 单聊已有 userId 时使用 `--receiver <userId>`,CLI 始终通过通讯录关键词搜索并按 userId 精确匹配 openDingTalkId;即使 userId 以 D/d 开头也不会猜测类型,`--dry-run` 也会执行该解析。
- 单聊已有 openDingTalkId 时必须显式使用 `--receiver-open-dingtalk-id <openDingTalkId>`,避免与 userId 混淆。
- `--group`、`--receiver`、`--receiver-open-dingtalk-id` 严格三选一;传 `--content` 可在同一次调用中创建并结束卡片。
- "发送位置/坐标/地址到群里" / "发个位置给某某" — `dws chat message send ... --msg-type location --latitude <纬度> --longitude <经度> --location-name <地址名称> --map-thumbnail-url @mediaId`;地图缩略图需先通过 `dt_media_upload` 上传获取 mediaId