Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9dbbd64f3c | ||
|
|
7ba12a8e4c | ||
|
|
41372b0597 | ||
|
|
cffc48406c | ||
|
|
f9e3476d42 | ||
|
|
2b48f27a4b | ||
|
|
bf79a67efe | ||
|
|
95d262bbb2 | ||
|
|
d5c260c7c0 | ||
|
|
9c297d0520 | ||
|
|
37230d2d4d | ||
|
|
fde6b59074 | ||
|
|
4567dd1cd6 | ||
|
|
75bb01bb64 | ||
|
|
11a7ab8b7c | ||
|
|
41e0fb381a |
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import "context"
|
||||
|
||||
// ConversationLocalFileMeta exposes the already-reviewed native chat upload
|
||||
// metadata to built-in semantic Shortcuts. It remains an alias so the native
|
||||
// Cobra leaf and Shortcut use exactly the same upload implementation.
|
||||
type ConversationLocalFileMeta = conversationLocalFileMeta
|
||||
|
||||
// BuildConversationLocalFileMeta validates a local file and computes the
|
||||
// metadata required by DingTalk's conversation-file upload flow.
|
||||
func BuildConversationLocalFileMeta(filePath, fileName, md5Value string) (ConversationLocalFileMeta, error) {
|
||||
return buildConversationLocalFileMeta(filePath, fileName, md5Value)
|
||||
}
|
||||
|
||||
// UploadConversationLocalFile executes the existing init -> HTTP upload ->
|
||||
// commit flow and returns the commit response for message-content assembly.
|
||||
func UploadConversationLocalFile(
|
||||
ctx context.Context,
|
||||
targetArgs map[string]any,
|
||||
meta ConversationLocalFileMeta,
|
||||
uuid string,
|
||||
) (string, error) {
|
||||
return uploadConversationLocalFile(ctx, targetArgs, meta, uuid)
|
||||
}
|
||||
|
||||
// ParseConversationFileSendIDs extracts the committed dentry and space IDs.
|
||||
func ParseConversationFileSendIDs(text string) (int64, int64, error) {
|
||||
return parseConversationFileSendIDs(text)
|
||||
}
|
||||
|
||||
// BuildConversationFileContent renders the exact file-message content accepted
|
||||
// by send_personal_message.
|
||||
func BuildConversationFileContent(
|
||||
dentryID, spaceID int64,
|
||||
meta ConversationLocalFileMeta,
|
||||
) (string, error) {
|
||||
return buildConversationFileContent(dentryID, spaceID, meta)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageConversationFileShortcutWrappersReuseNativeFlow(t *testing.T) {
|
||||
file := filepath.Join(t.TempDir(), "report.txt")
|
||||
if err := os.WriteFile(file, []byte("content"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
meta, err := BuildConversationLocalFileMeta(file, "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if meta.FileName != "report.txt" || meta.FileSize != 7 {
|
||||
t.Fatalf("meta = %#v", meta)
|
||||
}
|
||||
content, err := BuildConversationFileContent(11, 22, meta)
|
||||
if err != nil || !strings.Contains(content, `"dentryId":11`) {
|
||||
t.Fatalf("content = %q, %v", content, err)
|
||||
}
|
||||
dentryID, spaceID, err := ParseConversationFileSendIDs(`{"result":{"dentryId":11,"spaceId":22}}`)
|
||||
if err != nil || dentryID != 11 || spaceID != 22 {
|
||||
t.Fatalf("ids = %d/%d, %v", dentryID, spaceID, err)
|
||||
}
|
||||
|
||||
oldPut := httpPutFile
|
||||
t.Cleanup(func() { httpPutFile = oldPut })
|
||||
var putPath string
|
||||
httpPutFile = func(_ context.Context, _ string, _ map[string]string, localPath string, size int64) error {
|
||||
putPath = localPath
|
||||
if size != 7 {
|
||||
t.Errorf("upload size = %d", size)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"resourceUrl":"https://upload.example.test/file","uploadKey":"key"}`},
|
||||
{text: `{"result":{"dentryId":11,"spaceId":22}}`},
|
||||
}}
|
||||
installScriptedCaller(t, caller)
|
||||
commit, err := UploadConversationLocalFile(
|
||||
context.Background(),
|
||||
map[string]any{"openConversationId": "cid"},
|
||||
meta,
|
||||
"uuid",
|
||||
)
|
||||
if err != nil || !strings.Contains(commit, `"dentryId":11`) || putPath != file || caller.calls != 2 {
|
||||
t.Fatalf("upload = %q, path=%q calls=%d err=%v", commit, putPath, caller.calls, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageReadToolNameContractAndHelperBoundary(t *testing.T) {
|
||||
for tool, want := range map[string]bool{
|
||||
" get_conversation ": true,
|
||||
"LIST_MESSAGES": true,
|
||||
"query_send_status": true,
|
||||
"search_messages": true,
|
||||
"unread_message_conversation_list": true,
|
||||
"send_personal_message": false,
|
||||
"": false,
|
||||
} {
|
||||
if got := IsReadToolName(tool); got != want {
|
||||
t.Errorf("IsReadToolName(%q) = %v, want %v", tool, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
caller := &helpersReadCaller{
|
||||
helpersCoreCaller: &helpersCoreCaller{format: "json", dry: true},
|
||||
readResult: textToolResult(`{"success":true}`),
|
||||
}
|
||||
installHelpersCoreDeps(t, caller)
|
||||
if _, err := CallMCPReadToolTextOnServer("chat", "send_personal_message", nil); err == nil {
|
||||
t.Fatal("write tool was accepted by the read helper boundary")
|
||||
}
|
||||
if caller.readCalls != 0 || caller.calls != 0 {
|
||||
t.Fatalf("rejected write reached caller: read=%d regular=%d", caller.readCalls, caller.calls)
|
||||
}
|
||||
}
|
||||
@@ -156,6 +156,12 @@ func callMCPToolReturnTextOnServer(ctx context.Context, serverID, toolName strin
|
||||
// returning a synthetic dry-run envelope that looks like business data.
|
||||
func CallMCPReadToolTextOnServer(serverID, toolName string, args map[string]any) (string, error) {
|
||||
ctx := context.Background()
|
||||
if !IsReadToolName(toolName) {
|
||||
return "", &CLIError{
|
||||
Code: CodeMCPToolError,
|
||||
Message: fmt.Sprintf("tool %q is not allowed on the dry-run read channel", toolName),
|
||||
}
|
||||
}
|
||||
if deps == nil || deps.Caller == nil {
|
||||
return "", &CLIError{
|
||||
Code: CodeMCPToolError,
|
||||
@@ -176,6 +182,22 @@ func CallMCPReadToolTextOnServer(serverID, toolName string, args map[string]any)
|
||||
return parseMCPToolTextResult(serverID, toolName, result, err)
|
||||
}
|
||||
|
||||
// IsReadToolName is the fail-closed naming contract for the dry-run read
|
||||
// channel. Both the Shortcut runtime and the helper boundary enforce it so a
|
||||
// future direct helper caller cannot accidentally route a write tool through
|
||||
// ReadToolCaller.
|
||||
func IsReadToolName(toolName string) bool {
|
||||
toolName = strings.TrimSpace(strings.ToLower(toolName))
|
||||
for _, prefix := range []string{
|
||||
"get_", "list_", "query_", "search_", "unread_",
|
||||
} {
|
||||
if strings.HasPrefix(toolName, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func parseMCPToolTextResult(serverID, toolName string, result *edition.ToolResult, err error) (string, error) {
|
||||
if err != nil {
|
||||
if patErr := reclassifyPATFromError(err); patErr != nil {
|
||||
|
||||
@@ -14,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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 -->
|
||||
|
||||
## 多组织 / 多账号
|
||||
|
||||
@@ -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`,仅在不适用时降级到原子命令。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 (群组管理)
|
||||
|
||||
#### 创建群 — 当前登录用户自动成为群主
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user