diff --git a/internal/app/schema_shortcut_contract_test.go b/internal/app/schema_shortcut_contract_test.go index 4e09f8fb..007fc545 100644 --- a/internal/app/schema_shortcut_contract_test.go +++ b/internal/app/schema_shortcut_contract_test.go @@ -388,6 +388,7 @@ func shortcutCustomConstraintEvidence(description string) []string { "绝对路径", "..", "最多 15 个字符", + "能力矩阵", } evidence := make([]string, 0, len(probes)) for _, probe := range probes { diff --git a/internal/cli/schema_catalog/catalog.json b/internal/cli/schema_catalog/catalog.json index 5bf87af4..511f5bfc 100644 --- a/internal/cli/schema_catalog/catalog.json +++ b/internal/cli/schema_catalog/catalog.json @@ -1,7 +1,7 @@ { "version": 1, "surface_hash": "sha256:60eee8e2f37d6d9d60689efce85082798eb9ad38b7ba7c0b471c3de676a85a16", - "source_hash": "sha256:0aec358a00376b48a2bf263dd413a439be2d19f5fc2dbc5f37ade862727f2b3c", + "source_hash": "sha256:47e91a68c4dc191042873c29a3738e3de45633421dd75c9c7671f4d2d7f18e37", "catalog": { "agent_metadata": { "products_with_metadata": 26, @@ -10788,7 +10788,7 @@ "cli_name": "+messages-send", "cli_path": "chat +messages-send", "confirmation": "user_required", - "description": "当你希望用同一个入口选择 current-user、bot 或 webhook 身份发送消息时使用;命令会按身份校验目标、内容和凭据并路由到真实下层。current-user 支持文本/Markdown、已有 mediaId 图片、安全相对路径文件上传和幂等键;bot 支持群聊或批量单聊文本/Markdown;webhook 的目标由 token 所在群决定。不会把 user 文件能力伪装成 bot/webhook 等价能力。\n\n参数约束:\n - --text、--markdown、--media-id、--file、--file-path 至少指定一个\n - --text、--markdown、--media-id、--file、--file-path 互斥,最多指定一个\n - --identity、--as 互斥,最多指定一个\n - --group、--chat-id 互斥,最多指定一个\n - --user、--open-dingtalk-id 互斥,最多指定一个\n - --uuid、--idempotency-key 互斥,最多指定一个\n - 身份相关目标、内容和凭据不能为空,完整能力矩阵见命令说明", + "description": "当你希望用同一个入口选择 current-user、bot 或 webhook 身份发送消息时使用;命令会按身份校验目标、内容和凭据并路由到真实下层。current-user 支持文本/Markdown、已有 mediaId 图片、安全相对路径文件上传和幂等键;bot 支持群聊或批量单聊文本/Markdown;webhook 的目标由 token 所在群决定。不会把 user 文件能力伪装成 bot/webhook 等价能力。\n\n参数约束:\n - --text、--markdown、--media-id、--file、--file-path 至少指定一个\n - --text、--markdown、--media-id、--file、--file-path 互斥,最多指定一个\n - --identity、--as 互斥,最多指定一个\n - --group、--chat-id 互斥,最多指定一个\n - --user、--open-dingtalk-id 互斥,最多指定一个\n - --uuid、--idempotency-key 互斥,最多指定一个\n - 目标、凭据和幂等参数受发送身份能力矩阵约束:user 必须指定一个群聊或单聊目标;bot 必须指定 robot-code 和一类目标;webhook 必须指定 webhook-token;幂等键仅 user 支持", "effect": "write", "idempotency": "unknown", "interface_mode": "composite", diff --git a/internal/cli/schema_catalog/tools/chat.json b/internal/cli/schema_catalog/tools/chat.json index bb736b71..caaa9559 100644 --- a/internal/cli/schema_catalog/tools/chat.json +++ b/internal/cli/schema_catalog/tools/chat.json @@ -73972,7 +73972,7 @@ ] ] }, - "description": "当你希望用同一个入口选择 current-user、bot 或 webhook 身份发送消息时使用;命令会按身份校验目标、内容和凭据并路由到真实下层。current-user 支持文本/Markdown、已有 mediaId 图片、安全相对路径文件上传和幂等键;bot 支持群聊或批量单聊文本/Markdown;webhook 的目标由 token 所在群决定。不会把 user 文件能力伪装成 bot/webhook 等价能力。\n\n参数约束:\n - --text、--markdown、--media-id、--file、--file-path 至少指定一个\n - --text、--markdown、--media-id、--file、--file-path 互斥,最多指定一个\n - --identity、--as 互斥,最多指定一个\n - --group、--chat-id 互斥,最多指定一个\n - --user、--open-dingtalk-id 互斥,最多指定一个\n - --uuid、--idempotency-key 互斥,最多指定一个\n - 身份相关目标、内容和凭据不能为空,完整能力矩阵见命令说明", + "description": "当你希望用同一个入口选择 current-user、bot 或 webhook 身份发送消息时使用;命令会按身份校验目标、内容和凭据并路由到真实下层。current-user 支持文本/Markdown、已有 mediaId 图片、安全相对路径文件上传和幂等键;bot 支持群聊或批量单聊文本/Markdown;webhook 的目标由 token 所在群决定。不会把 user 文件能力伪装成 bot/webhook 等价能力。\n\n参数约束:\n - --text、--markdown、--media-id、--file、--file-path 至少指定一个\n - --text、--markdown、--media-id、--file、--file-path 互斥,最多指定一个\n - --identity、--as 互斥,最多指定一个\n - --group、--chat-id 互斥,最多指定一个\n - --user、--open-dingtalk-id 互斥,最多指定一个\n - --uuid、--idempotency-key 互斥,最多指定一个\n - 目标、凭据和幂等参数受发送身份能力矩阵约束:user 必须指定一个群聊或单聊目标;bot 必须指定 robot-code 和一类目标;webhook 必须指定 webhook-token;幂等键仅 user 支持", "display": "群聊 / 消息 / 机器人", "effect": "write", "effect_source": "agent-hint", @@ -74077,13 +74077,13 @@ "precedence": "cobra_help", "selected": true, "source": "cobra_help", - "value": "当你希望用同一个入口选择 current-user、bot 或 webhook 身份发送消息时使用;命令会按身份校验目标、内容和凭据并路由到真实下层。current-user 支持文本/Markdown、已有 mediaId 图片、安全相对路径文件上传和幂等键;bot 支持群聊或批量单聊文本/Markdown;webhook 的目标由 token 所在群决定。不会把 user 文件能力伪装成 bot/webhook 等价能力。\n\n参数约束:\n - --text、--markdown、--media-id、--file、--file-path 至少指定一个\n - --text、--markdown、--media-id、--file、--file-path 互斥,最多指定一个\n - --identity、--as 互斥,最多指定一个\n - --group、--chat-id 互斥,最多指定一个\n - --user、--open-dingtalk-id 互斥,最多指定一个\n - --uuid、--idempotency-key 互斥,最多指定一个\n - 身份相关目标、内容和凭据不能为空,完整能力矩阵见命令说明" + "value": "当你希望用同一个入口选择 current-user、bot 或 webhook 身份发送消息时使用;命令会按身份校验目标、内容和凭据并路由到真实下层。current-user 支持文本/Markdown、已有 mediaId 图片、安全相对路径文件上传和幂等键;bot 支持群聊或批量单聊文本/Markdown;webhook 的目标由 token 所在群决定。不会把 user 文件能力伪装成 bot/webhook 等价能力。\n\n参数约束:\n - --text、--markdown、--media-id、--file、--file-path 至少指定一个\n - --text、--markdown、--media-id、--file、--file-path 互斥,最多指定一个\n - --identity、--as 互斥,最多指定一个\n - --group、--chat-id 互斥,最多指定一个\n - --user、--open-dingtalk-id 互斥,最多指定一个\n - --uuid、--idempotency-key 互斥,最多指定一个\n - 目标、凭据和幂等参数受发送身份能力矩阵约束:user 必须指定一个群聊或单聊目标;bot 必须指定 robot-code 和一类目标;webhook 必须指定 webhook-token;幂等键仅 user 支持" } ], "precedence": "cobra_help", "resolution": "highest_precedence", "source": "cobra_help", - "value": "当你希望用同一个入口选择 current-user、bot 或 webhook 身份发送消息时使用;命令会按身份校验目标、内容和凭据并路由到真实下层。current-user 支持文本/Markdown、已有 mediaId 图片、安全相对路径文件上传和幂等键;bot 支持群聊或批量单聊文本/Markdown;webhook 的目标由 token 所在群决定。不会把 user 文件能力伪装成 bot/webhook 等价能力。\n\n参数约束:\n - --text、--markdown、--media-id、--file、--file-path 至少指定一个\n - --text、--markdown、--media-id、--file、--file-path 互斥,最多指定一个\n - --identity、--as 互斥,最多指定一个\n - --group、--chat-id 互斥,最多指定一个\n - --user、--open-dingtalk-id 互斥,最多指定一个\n - --uuid、--idempotency-key 互斥,最多指定一个\n - 身份相关目标、内容和凭据不能为空,完整能力矩阵见命令说明" + "value": "当你希望用同一个入口选择 current-user、bot 或 webhook 身份发送消息时使用;命令会按身份校验目标、内容和凭据并路由到真实下层。current-user 支持文本/Markdown、已有 mediaId 图片、安全相对路径文件上传和幂等键;bot 支持群聊或批量单聊文本/Markdown;webhook 的目标由 token 所在群决定。不会把 user 文件能力伪装成 bot/webhook 等价能力。\n\n参数约束:\n - --text、--markdown、--media-id、--file、--file-path 至少指定一个\n - --text、--markdown、--media-id、--file、--file-path 互斥,最多指定一个\n - --identity、--as 互斥,最多指定一个\n - --group、--chat-id 互斥,最多指定一个\n - --user、--open-dingtalk-id 互斥,最多指定一个\n - --uuid、--idempotency-key 互斥,最多指定一个\n - 目标、凭据和幂等参数受发送身份能力矩阵约束:user 必须指定一个群聊或单聊目标;bot 必须指定 robot-code 和一类目标;webhook 必须指定 webhook-token;幂等键仅 user 支持" }, "effect": { "candidates": [ @@ -74379,7 +74379,7 @@ "type": "boolean" }, "as": { - "description": "--identity 的 lark-cli 对齐别名(可选值: user, bot, webhook)", + "description": "--identity 的 lark-cli 对齐别名;受发送身份能力矩阵约束(可选值: user, bot, webhook)", "enum": [ "user", "bot", @@ -74392,7 +74392,7 @@ "precedence": "cobra_contract", "selected": true, "source": "cobra_usage", - "value": "--identity 的 lark-cli 对齐别名(可选值: user, bot, webhook)" + "value": "--identity 的 lark-cli 对齐别名;受发送身份能力矩阵约束(可选值: user, bot, webhook)" }, { "precedence": "default", @@ -74404,7 +74404,7 @@ "precedence": "cobra_contract", "resolution": "highest_precedence", "source": "cobra_usage", - "value": "--identity 的 lark-cli 对齐别名(可选值: user, bot, webhook)" + "value": "--identity 的 lark-cli 对齐别名;受发送身份能力矩阵约束(可选值: user, bot, webhook)" }, "enum": { "candidates": [ @@ -74832,7 +74832,7 @@ "type": "array" }, "chat-id": { - "description": "--group 的 lark-cli 对齐别名", + "description": "--group 的 lark-cli 对齐别名;受发送身份能力矩阵约束", "field_provenance": { "description": { "candidates": [ @@ -74840,7 +74840,7 @@ "precedence": "cobra_contract", "selected": true, "source": "cobra_usage", - "value": "--group 的 lark-cli 对齐别名" + "value": "--group 的 lark-cli 对齐别名;受发送身份能力矩阵约束" }, { "precedence": "default", @@ -74852,7 +74852,7 @@ "precedence": "cobra_contract", "resolution": "highest_precedence", "source": "cobra_usage", - "value": "--group 的 lark-cli 对齐别名" + "value": "--group 的 lark-cli 对齐别名;受发送身份能力矩阵约束" }, "property": { "candidates": [ @@ -75096,7 +75096,7 @@ "type": "string" }, "group": { - "description": "群 openConversationId(user/bot 群聊)", + "description": "群 openConversationId(user/bot 群聊);受发送身份能力矩阵约束", "field_provenance": { "description": { "candidates": [ @@ -75104,7 +75104,7 @@ "precedence": "cobra_contract", "selected": true, "source": "cobra_usage", - "value": "群 openConversationId(user/bot 群聊)" + "value": "群 openConversationId(user/bot 群聊);受发送身份能力矩阵约束" }, { "precedence": "default", @@ -75116,7 +75116,7 @@ "precedence": "cobra_contract", "resolution": "highest_precedence", "source": "cobra_usage", - "value": "群 openConversationId(user/bot 群聊)" + "value": "群 openConversationId(user/bot 群聊);受发送身份能力矩阵约束" }, "property": { "candidates": [ @@ -75180,7 +75180,7 @@ "type": "string" }, "idempotency-key": { - "description": "--uuid 的 lark-cli 对齐别名(仅 user)", + "description": "--uuid 的 lark-cli 对齐别名(仅 user);受发送身份能力矩阵约束", "field_provenance": { "description": { "candidates": [ @@ -75188,7 +75188,7 @@ "precedence": "cobra_contract", "selected": true, "source": "cobra_usage", - "value": "--uuid 的 lark-cli 对齐别名(仅 user)" + "value": "--uuid 的 lark-cli 对齐别名(仅 user);受发送身份能力矩阵约束" }, { "precedence": "default", @@ -75200,7 +75200,7 @@ "precedence": "cobra_contract", "resolution": "highest_precedence", "source": "cobra_usage", - "value": "--uuid 的 lark-cli 对齐别名(仅 user)" + "value": "--uuid 的 lark-cli 对齐别名(仅 user);受发送身份能力矩阵约束" }, "property": { "candidates": [ @@ -75265,7 +75265,7 @@ }, "identity": { "default": "user", - "description": "发送身份;身份相关目标、内容和凭据不能为空,完整能力矩阵见命令说明(可选值: user, bot, webhook)", + "description": "发送身份;目标、凭据和幂等参数受发送身份能力矩阵约束(可选值: user, bot, webhook)", "enum": [ "user", "bot", @@ -75278,7 +75278,7 @@ "precedence": "cobra_contract", "selected": true, "source": "cobra_usage", - "value": "发送身份;身份相关目标、内容和凭据不能为空,完整能力矩阵见命令说明(可选值: user, bot, webhook)" + "value": "发送身份;目标、凭据和幂等参数受发送身份能力矩阵约束(可选值: user, bot, webhook)" }, { "precedence": "default", @@ -75290,7 +75290,7 @@ "precedence": "cobra_contract", "resolution": "highest_precedence", "source": "cobra_usage", - "value": "发送身份;身份相关目标、内容和凭据不能为空,完整能力矩阵见命令说明(可选值: user, bot, webhook)" + "value": "发送身份;目标、凭据和幂等参数受发送身份能力矩阵约束(可选值: user, bot, webhook)" }, "enum": { "candidates": [ @@ -75694,7 +75694,7 @@ "type": "string" }, "open-dingtalk-id": { - "description": "单聊接收者 openDingTalkId(user)", + "description": "单聊接收者 openDingTalkId(user);受发送身份能力矩阵约束", "field_provenance": { "description": { "candidates": [ @@ -75702,7 +75702,7 @@ "precedence": "cobra_contract", "selected": true, "source": "cobra_usage", - "value": "单聊接收者 openDingTalkId(user)" + "value": "单聊接收者 openDingTalkId(user);受发送身份能力矩阵约束" }, { "precedence": "default", @@ -75714,7 +75714,7 @@ "precedence": "cobra_contract", "resolution": "highest_precedence", "source": "cobra_usage", - "value": "单聊接收者 openDingTalkId(user)" + "value": "单聊接收者 openDingTalkId(user);受发送身份能力矩阵约束" }, "property": { "candidates": [ @@ -75778,7 +75778,7 @@ "type": "string" }, "open-dingtalk-ids": { - "description": "批量单聊接收者 openDingTalkId(bot)", + "description": "批量单聊接收者 openDingTalkId(bot);受发送身份能力矩阵约束", "field_provenance": { "description": { "candidates": [ @@ -75786,7 +75786,7 @@ "precedence": "cobra_contract", "selected": true, "source": "cobra_usage", - "value": "批量单聊接收者 openDingTalkId(bot)" + "value": "批量单聊接收者 openDingTalkId(bot);受发送身份能力矩阵约束" }, { "precedence": "default", @@ -75798,7 +75798,7 @@ "precedence": "cobra_contract", "resolution": "highest_precedence", "source": "cobra_usage", - "value": "批量单聊接收者 openDingTalkId(bot)" + "value": "批量单聊接收者 openDingTalkId(bot);受发送身份能力矩阵约束" }, "property": { "candidates": [ @@ -75862,7 +75862,7 @@ "type": "array" }, "robot-code": { - "description": "机器人 Code(identity=bot 时使用)", + "description": "机器人 Code(identity=bot 时使用);受发送身份能力矩阵约束", "field_provenance": { "description": { "candidates": [ @@ -75870,7 +75870,7 @@ "precedence": "cobra_contract", "selected": true, "source": "cobra_usage", - "value": "机器人 Code(identity=bot 时使用)" + "value": "机器人 Code(identity=bot 时使用);受发送身份能力矩阵约束" }, { "precedence": "default", @@ -75882,7 +75882,7 @@ "precedence": "cobra_contract", "resolution": "highest_precedence", "source": "cobra_usage", - "value": "机器人 Code(identity=bot 时使用)" + "value": "机器人 Code(identity=bot 时使用);受发送身份能力矩阵约束" }, "property": { "candidates": [ @@ -76120,7 +76120,7 @@ "type": "string" }, "user": { - "description": "单聊接收者 userId(user;自动解析 openDingTalkId)", + "description": "单聊接收者 userId(user;自动解析 openDingTalkId);受发送身份能力矩阵约束", "field_provenance": { "description": { "candidates": [ @@ -76128,7 +76128,7 @@ "precedence": "cobra_contract", "selected": true, "source": "cobra_usage", - "value": "单聊接收者 userId(user;自动解析 openDingTalkId)" + "value": "单聊接收者 userId(user;自动解析 openDingTalkId);受发送身份能力矩阵约束" }, { "precedence": "default", @@ -76140,7 +76140,7 @@ "precedence": "cobra_contract", "resolution": "highest_precedence", "source": "cobra_usage", - "value": "单聊接收者 userId(user;自动解析 openDingTalkId)" + "value": "单聊接收者 userId(user;自动解析 openDingTalkId);受发送身份能力矩阵约束" }, "property": { "candidates": [ @@ -76204,7 +76204,7 @@ "type": "string" }, "users": { - "description": "批量单聊接收者 userId(bot)", + "description": "批量单聊接收者 userId(bot);受发送身份能力矩阵约束", "field_provenance": { "description": { "candidates": [ @@ -76212,7 +76212,7 @@ "precedence": "cobra_contract", "selected": true, "source": "cobra_usage", - "value": "批量单聊接收者 userId(bot)" + "value": "批量单聊接收者 userId(bot);受发送身份能力矩阵约束" }, { "precedence": "default", @@ -76224,7 +76224,7 @@ "precedence": "cobra_contract", "resolution": "highest_precedence", "source": "cobra_usage", - "value": "批量单聊接收者 userId(bot)" + "value": "批量单聊接收者 userId(bot);受发送身份能力矩阵约束" }, "property": { "candidates": [ @@ -76288,7 +76288,7 @@ "type": "array" }, "uuid": { - "description": "幂等键(仅 user)", + "description": "幂等键(仅 user);受发送身份能力矩阵约束", "field_provenance": { "description": { "candidates": [ @@ -76296,7 +76296,7 @@ "precedence": "cobra_contract", "selected": true, "source": "cobra_usage", - "value": "幂等键(仅 user)" + "value": "幂等键(仅 user);受发送身份能力矩阵约束" }, { "precedence": "default", @@ -76308,7 +76308,7 @@ "precedence": "cobra_contract", "resolution": "highest_precedence", "source": "cobra_usage", - "value": "幂等键(仅 user)" + "value": "幂等键(仅 user);受发送身份能力矩阵约束" }, "property": { "candidates": [ @@ -76372,7 +76372,7 @@ "type": "string" }, "webhook-token": { - "description": "自定义机器人 Webhook token(identity=webhook 时使用)", + "description": "自定义机器人 Webhook token(identity=webhook 时使用);受发送身份能力矩阵约束", "field_provenance": { "description": { "candidates": [ @@ -76380,7 +76380,7 @@ "precedence": "cobra_contract", "selected": true, "source": "cobra_usage", - "value": "自定义机器人 Webhook token(identity=webhook 时使用)" + "value": "自定义机器人 Webhook token(identity=webhook 时使用);受发送身份能力矩阵约束" }, { "precedence": "default", @@ -76392,7 +76392,7 @@ "precedence": "cobra_contract", "resolution": "highest_precedence", "source": "cobra_usage", - "value": "自定义机器人 Webhook token(identity=webhook 时使用)" + "value": "自定义机器人 Webhook token(identity=webhook 时使用);受发送身份能力矩阵约束" }, "property": { "candidates": [ diff --git a/internal/shortcut/chat/chat_message.go b/internal/shortcut/chat/chat_message.go index 55d2276a..10f63fe7 100644 --- a/internal/shortcut/chat/chat_message.go +++ b/internal/shortcut/chat/chat_message.go @@ -320,7 +320,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 { @@ -638,6 +638,9 @@ func DownloadMessageResources( 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) @@ -692,6 +695,9 @@ func DownloadMessageResources( plannedNames := map[string]int{} 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) diff --git a/internal/shortcut/chat/chat_message_coverage_test.go b/internal/shortcut/chat/chat_message_coverage_test.go index 255c359d..ababe470 100644 --- a/internal/shortcut/chat/chat_message_coverage_test.go +++ b/internal/shortcut/chat/chat_message_coverage_test.go @@ -24,7 +24,7 @@ func TestCrossPlatformCoverageListMessageRichProjection(t *testing.T) { "content": `{"mediaId":"@image"}`, "quotedMessage": map[string]any{ "openMessageId": "quoted", - "content": "quoted", + "content": `{"mediaId":"@quoted-image"}`, }, }, }}}) @@ -36,6 +36,16 @@ 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) { diff --git a/internal/shortcut/chat/gap_fill_test.go b/internal/shortcut/chat/gap_fill_test.go index 8d50f4a6..26576ba0 100644 --- a/internal/shortcut/chat/gap_fill_test.go +++ b/internal/shortcut/chat/gap_fill_test.go @@ -20,6 +20,36 @@ import ( "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) + } + } +} + func TestCrossPlatformCoverageMessagesSendCurrentUserImageAndUserResolution(t *testing.T) { fake := &larkAlignmentCaller{} helpers.InitDeps(fake) diff --git a/internal/shortcut/chat/resource_download.go b/internal/shortcut/chat/resource_download.go index a23da3c8..787da56d 100644 --- a/internal/shortcut/chat/resource_download.go +++ b/internal/shortcut/chat/resource_download.go @@ -89,7 +89,8 @@ var MessagesResourceDownload = shortcut.Shortcut{ if err := validateResourceDownloadOutput(rt.Str("output")); err != nil { return err } - if rt.Str("type") == "mediaId" && + resourceType, _ := canonicalMessageResourceType(rt.Str("type")) + if resourceType == "mediaId" && (strings.TrimSpace(rt.Str("message-id")) == "" || strings.TrimSpace(rt.Str("open-conversation-id")) == "") { return apperrors.NewValidation( @@ -98,8 +99,9 @@ var MessagesResourceDownload = shortcut.Shortcut{ 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"), @@ -119,7 +121,7 @@ var MessagesResourceDownload = shortcut.Shortcut{ data, err := resolveMessageResourceDownloadData( rt, - rt.Str("type"), + resourceType, rt.Str("resource-id"), rt.Str("message-id"), rt.Str("open-conversation-id"), @@ -153,7 +155,7 @@ 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, }) @@ -164,6 +166,11 @@ 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, @@ -177,6 +184,17 @@ func resolveMessageResourceDownloadData( }) } +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") } @@ -486,6 +504,8 @@ func downloadResourceAtomically( 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 _, redirectErr := validateResourceDownloadURL(req.URL.String()); redirectErr != nil { return apperrors.NewValidation(fmt.Sprintf( @@ -494,8 +514,14 @@ func downloadResourceAtomically( if len(via) >= 10 { return apperrors.NewAPI("资源下载重定向次数过多") } - if len(via) > 0 && - !strings.EqualFold(req.URL.Hostname(), via[len(via)-1].URL.Hostname()) { + 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) } diff --git a/internal/shortcut/chat/resource_download_coverage_test.go b/internal/shortcut/chat/resource_download_coverage_test.go index a8ead3bf..6849f6c6 100644 --- a/internal/shortcut/chat/resource_download_coverage_test.go +++ b/internal/shortcut/chat/resource_download_coverage_test.go @@ -469,18 +469,29 @@ func TestCrossPlatformCoverageDownloadResourceRedirectGuards(t *testing.T) { redirectCount := 0 crossHost := &http.Client{Transport: resourceRoundTripper(func(request *http.Request) (*http.Response, error) { redirectCount++ - if redirectCount == 1 { + 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 - } - if request.Header.Get("X-Resource-Token") != "" { - t.Fatal("server-supplied header leaked across download domains") + 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, diff --git a/internal/shortcut/chat/resource_download_test.go b/internal/shortcut/chat/resource_download_test.go index e45b74a6..813fae48 100644 --- a/internal/shortcut/chat/resource_download_test.go +++ b/internal/shortcut/chat/resource_download_test.go @@ -23,6 +23,29 @@ import ( "testing" ) +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{ diff --git a/internal/shortcut/chat/unified_send.go b/internal/shortcut/chat/unified_send.go index b4632001..9d66295d 100644 --- a/internal/shortcut/chat/unified_send.go +++ b/internal/shortcut/chat/unified_send.go @@ -31,16 +31,16 @@ var MessagesSend = shortcut.Shortcut{ Intent: "当你希望用同一个入口选择 current-user、bot 或 webhook 身份发送消息时使用;命令会按身份校验目标、内容和凭据并路由到真实下层。current-user 支持文本/Markdown、已有 mediaId 图片、安全相对路径文件上传和幂等键;bot 支持群聊或批量单聊文本/Markdown;webhook 的目标由 token 所在群决定。不会把 user 文件能力伪装成 bot/webhook 等价能力。", Risk: shortcut.RiskWrite, Flags: []shortcut.Flag{ - {Name: "identity", Type: shortcut.FlagString, Default: "user", Enum: []string{"user", "bot", "webhook"}, Desc: "发送身份;身份相关目标、内容和凭据不能为空,完整能力矩阵见命令说明"}, - {Name: "as", Type: shortcut.FlagString, Enum: []string{"user", "bot", "webhook"}, Desc: "--identity 的 lark-cli 对齐别名"}, - {Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId(user/bot 群聊)"}, - {Name: "chat-id", Type: shortcut.FlagString, Desc: "--group 的 lark-cli 对齐别名"}, - {Name: "user", Type: shortcut.FlagString, Desc: "单聊接收者 userId(user;自动解析 openDingTalkId)"}, - {Name: "open-dingtalk-id", Type: shortcut.FlagString, Desc: "单聊接收者 openDingTalkId(user)"}, - {Name: "users", Type: shortcut.FlagStringSlice, Desc: "批量单聊接收者 userId(bot)"}, - {Name: "open-dingtalk-ids", Type: shortcut.FlagStringSlice, Desc: "批量单聊接收者 openDingTalkId(bot)"}, - {Name: "robot-code", Type: shortcut.FlagString, Desc: "机器人 Code(identity=bot 时使用)"}, - {Name: "webhook-token", Type: shortcut.FlagString, Desc: "自定义机器人 Webhook token(identity=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;自动解析 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 正文"}, @@ -48,8 +48,8 @@ var MessagesSend = shortcut.Shortcut{ {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: "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)"}, @@ -65,8 +65,8 @@ var MessagesSend = shortcut.Shortcut{ {Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"uuid", "idempotency-key"}}, { Kind: shortcut.ConstraintCustom, - Flags: []string{"identity"}, - Description: "身份相关目标、内容和凭据不能为空,完整能力矩阵见命令说明", + 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{ diff --git a/internal/shortcut/shortcut_test.go b/internal/shortcut/shortcut_test.go index ac191b09..da94101b 100644 --- a/internal/shortcut/shortcut_test.go +++ b/internal/shortcut/shortcut_test.go @@ -135,6 +135,46 @@ func TestCrossPlatformCoverageConfirmRiskUsesEffectiveRisk(t *testing.T) { } } +func TestConfirmRiskDoesNotReadStdinForDynamicReadInvocation(t *testing.T) { + cmd := &cobra.Command{} + cmd.Flags().Bool("download-resources", false, "") + var stderr bytes.Buffer + cmd.SetErr(&stderr) + rt := &RuntimeContext{cmd: cmd} + s := Shortcut{ + Service: "chat", + Command: "+at-me", + Risk: RiskRead, + RiskWhen: func(rt *RuntimeContext) Risk { + if rt.Bool("download-resources") { + return RiskWrite + } + return RiskRead + }, + } + + reader, writer, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + previousStdin := os.Stdin + os.Stdin = reader + t.Cleanup(func() { + os.Stdin = previousStdin + _ = reader.Close() + }) + + if !confirmRisk(rt, s) { + t.Fatal("dynamic read invocation was treated as a declined write") + } + if stderr.Len() != 0 { + t.Fatalf("dynamic read invocation prompted: %q", stderr.String()) + } +} + func TestCrossPlatformCoverageMountRegistersFlagsAndUse(t *testing.T) { s := Shortcut{ Service: "contact", diff --git a/internal/shortcut/smart/at_me.go b/internal/shortcut/smart/at_me.go index 7b10f682..97f230cc 100644 --- a/internal/shortcut/smart/at_me.go +++ b/internal/shortcut/smart/at_me.go @@ -240,7 +240,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 { diff --git a/internal/shortcut/smart/chat_messages.go b/internal/shortcut/smart/chat_messages.go index a3f15762..9425c49e 100644 --- a/internal/shortcut/smart/chat_messages.go +++ b/internal/shortcut/smart/chat_messages.go @@ -22,6 +22,12 @@ import ( "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. @@ -86,7 +92,7 @@ var ChatMessages = shortcut.Shortcut{ if rt.Changed("time") && rt.Str("time") != "" { params["time"] = rt.Str("time") } else { - params["time"] = time.Now().Format("2006-01-02 15:04:05") + params["time"] = formatDingTalkMessageBoundary(time.Now()) } if limit := rt.IntFirst("limit", "size"); limit > 0 { params["limit"] = limit @@ -211,7 +217,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 { diff --git a/internal/shortcut/smart/coverage_completion_test.go b/internal/shortcut/smart/coverage_completion_test.go index 0208f294..155b9b3d 100644 --- a/internal/shortcut/smart/coverage_completion_test.go +++ b/internal/shortcut/smart/coverage_completion_test.go @@ -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]) + } } } diff --git a/internal/shortcut/smart/gap_fill_test.go b/internal/shortcut/smart/gap_fill_test.go index 48bcd045..0ba3d747 100644 --- a/internal/shortcut/smart/gap_fill_test.go +++ b/internal/shortcut/smart/gap_fill_test.go @@ -13,7 +13,7 @@ import ( ) func TestCrossPlatformCoverageMessageReadShortcutsPublishResourceDownloadPlans(t *testing.T) { - message := `{"openMessageId":"msg","openConversationId":"cid","content":"{\"mediaId\":\"@image\"}"}` + message := `{"openMessageId":"msg","openConversationId":"cid","content":"{\"mediaId\":\"@image\"}","quotedMessage":{"openMessageId":"quoted","content":"{\"fileId\":\"@quoted-file\"}"}}` tests := []struct { name string tool string @@ -72,11 +72,19 @@ func TestCrossPlatformCoverageMessageReadShortcutsPublishResourceDownloadPlans(t if err := json.Unmarshal(output.Bytes(), &payload); err != nil { t.Fatalf("decode output: %v\n%s", err, output.String()) } - if _, ok := payload[tc.resultKey]; !ok { + 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["requestedCount"] != float64(1) { + if ledger["dryRun"] != true || + ledger["discoveredCount"] != float64(2) || + ledger["requestedCount"] != float64(2) { t.Fatalf("resource plan = %#v", ledger) } }) @@ -104,7 +112,7 @@ func TestCrossPlatformCoverageChatMessagesDefaultsToRecentHistory(t *testing.T) helpers.InitDeps(caller) root := newPlatformCoverageRoot() before := time.Now().Add(-2 * time.Second) - root.SetArgs([]string{"chat", "+chat-messages", "--group", "cid", "--limit", "5", "--yes"}) + root.SetArgs([]string{"chat", "+chat-messages", "--group", "cid", "--limit", "5"}) if err := root.Execute(); err != nil { t.Fatal(err) } @@ -116,7 +124,11 @@ func TestCrossPlatformCoverageChatMessagesDefaultsToRecentHistory(t *testing.T) if call.args["forward"] != false { t.Fatalf("default forward = %#v, want false", call.args["forward"]) } - boundary, err := time.ParseInLocation("2006-01-02 15:04:05", call.args["time"].(string), time.Local) + boundary, err := time.ParseInLocation( + "2006-01-02 15:04:05", + call.args["time"].(string), + dingTalkMessageLocation, + ) if err != nil { t.Fatal(err) } @@ -125,6 +137,13 @@ func TestCrossPlatformCoverageChatMessagesDefaultsToRecentHistory(t *testing.T) } } +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) diff --git a/internal/shortcut/smart/search_msg.go b/internal/shortcut/smart/search_msg.go index 1f1a5b0c..1b7d4a41 100644 --- a/internal/shortcut/smart/search_msg.go +++ b/internal/shortcut/smart/search_msg.go @@ -526,7 +526,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 {