fix(im): address shortcut review regressions

This commit is contained in:
Dennis committed 2026-07-29 19:31:48 +08:00
1 parent 9c297d0520
commit d5c260c7c0
16 files changed
+259 -77

No files matched your search

@@ -388,6 +388,7 @@ func shortcutCustomConstraintEvidence(description string) []string {
"绝对路径", "绝对路径",
"..", "..",
"最多 15 个字符", "最多 15 个字符",
"能力矩阵",
} }
evidence := make([]string, 0, len(probes)) evidence := make([]string, 0, len(probes))
for _, probe := range probes { for _, probe := range probes {
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"version": 1, "version": 1,
"surface_hash": "sha256:60eee8e2f37d6d9d60689efce85082798eb9ad38b7ba7c0b471c3de676a85a16", "surface_hash": "sha256:60eee8e2f37d6d9d60689efce85082798eb9ad38b7ba7c0b471c3de676a85a16",
"source_hash": "sha256:0aec358a00376b48a2bf263dd413a439be2d19f5fc2dbc5f37ade862727f2b3c", "source_hash": "sha256:47e91a68c4dc191042873c29a3738e3de45633421dd75c9c7671f4d2d7f18e37",
"catalog": { "catalog": {
"agent_metadata": { "agent_metadata": {
"products_with_metadata": 26, "products_with_metadata": 26,
@@ -10788,7 +10788,7 @@
"cli_name": "+messages-send", "cli_name": "+messages-send",
"cli_path": "chat +messages-send", "cli_path": "chat +messages-send",
"confirmation": "user_required", "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", "effect": "write",
"idempotency": "unknown", "idempotency": "unknown",
"interface_mode": "composite", "interface_mode": "composite",
+39 -39
View File
@@ -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": "群聊 / 消息 / 机器人", "display": "群聊 / 消息 / 机器人",
"effect": "write", "effect": "write",
"effect_source": "agent-hint", "effect_source": "agent-hint",
@@ -74077,13 +74077,13 @@
"precedence": "cobra_help", "precedence": "cobra_help",
"selected": true, "selected": true,
"source": "cobra_help", "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", "precedence": "cobra_help",
"resolution": "highest_precedence", "resolution": "highest_precedence",
"source": "cobra_help", "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": { "effect": {
"candidates": [ "candidates": [
@@ -74379,7 +74379,7 @@
"type": "boolean" "type": "boolean"
}, },
"as": { "as": {
"description": "--identity 的 lark-cli 对齐别名(可选值: user, bot, webhook)", "description": "--identity 的 lark-cli 对齐别名;受发送身份能力矩阵约束(可选值: user, bot, webhook)",
"enum": [ "enum": [
"user", "user",
"bot", "bot",
@@ -74392,7 +74392,7 @@
"precedence": "cobra_contract", "precedence": "cobra_contract",
"selected": true, "selected": true,
"source": "cobra_usage", "source": "cobra_usage",
"value": "--identity 的 lark-cli 对齐别名(可选值: user, bot, webhook)" "value": "--identity 的 lark-cli 对齐别名;受发送身份能力矩阵约束(可选值: user, bot, webhook)"
}, },
{ {
"precedence": "default", "precedence": "default",
@@ -74404,7 +74404,7 @@
"precedence": "cobra_contract", "precedence": "cobra_contract",
"resolution": "highest_precedence", "resolution": "highest_precedence",
"source": "cobra_usage", "source": "cobra_usage",
"value": "--identity 的 lark-cli 对齐别名(可选值: user, bot, webhook)" "value": "--identity 的 lark-cli 对齐别名;受发送身份能力矩阵约束(可选值: user, bot, webhook)"
}, },
"enum": { "enum": {
"candidates": [ "candidates": [
@@ -74832,7 +74832,7 @@
"type": "array" "type": "array"
}, },
"chat-id": { "chat-id": {
"description": "--group 的 lark-cli 对齐别名", "description": "--group 的 lark-cli 对齐别名;受发送身份能力矩阵约束",
"field_provenance": { "field_provenance": {
"description": { "description": {
"candidates": [ "candidates": [
@@ -74840,7 +74840,7 @@
"precedence": "cobra_contract", "precedence": "cobra_contract",
"selected": true, "selected": true,
"source": "cobra_usage", "source": "cobra_usage",
"value": "--group 的 lark-cli 对齐别名" "value": "--group 的 lark-cli 对齐别名;受发送身份能力矩阵约束"
}, },
{ {
"precedence": "default", "precedence": "default",
@@ -74852,7 +74852,7 @@
"precedence": "cobra_contract", "precedence": "cobra_contract",
"resolution": "highest_precedence", "resolution": "highest_precedence",
"source": "cobra_usage", "source": "cobra_usage",
"value": "--group 的 lark-cli 对齐别名" "value": "--group 的 lark-cli 对齐别名;受发送身份能力矩阵约束"
}, },
"property": { "property": {
"candidates": [ "candidates": [
@@ -75096,7 +75096,7 @@
"type": "string" "type": "string"
}, },
"group": { "group": {
"description": "群 openConversationId(user/bot 群聊)", "description": "群 openConversationId(user/bot 群聊);受发送身份能力矩阵约束",
"field_provenance": { "field_provenance": {
"description": { "description": {
"candidates": [ "candidates": [
@@ -75104,7 +75104,7 @@
"precedence": "cobra_contract", "precedence": "cobra_contract",
"selected": true, "selected": true,
"source": "cobra_usage", "source": "cobra_usage",
"value": "群 openConversationId(user/bot 群聊)" "value": "群 openConversationId(user/bot 群聊);受发送身份能力矩阵约束"
}, },
{ {
"precedence": "default", "precedence": "default",
@@ -75116,7 +75116,7 @@
"precedence": "cobra_contract", "precedence": "cobra_contract",
"resolution": "highest_precedence", "resolution": "highest_precedence",
"source": "cobra_usage", "source": "cobra_usage",
"value": "群 openConversationId(user/bot 群聊)" "value": "群 openConversationId(user/bot 群聊);受发送身份能力矩阵约束"
}, },
"property": { "property": {
"candidates": [ "candidates": [
@@ -75180,7 +75180,7 @@
"type": "string" "type": "string"
}, },
"idempotency-key": { "idempotency-key": {
"description": "--uuid 的 lark-cli 对齐别名(仅 user)", "description": "--uuid 的 lark-cli 对齐别名(仅 user);受发送身份能力矩阵约束",
"field_provenance": { "field_provenance": {
"description": { "description": {
"candidates": [ "candidates": [
@@ -75188,7 +75188,7 @@
"precedence": "cobra_contract", "precedence": "cobra_contract",
"selected": true, "selected": true,
"source": "cobra_usage", "source": "cobra_usage",
"value": "--uuid 的 lark-cli 对齐别名(仅 user)" "value": "--uuid 的 lark-cli 对齐别名(仅 user);受发送身份能力矩阵约束"
}, },
{ {
"precedence": "default", "precedence": "default",
@@ -75200,7 +75200,7 @@
"precedence": "cobra_contract", "precedence": "cobra_contract",
"resolution": "highest_precedence", "resolution": "highest_precedence",
"source": "cobra_usage", "source": "cobra_usage",
"value": "--uuid 的 lark-cli 对齐别名(仅 user)" "value": "--uuid 的 lark-cli 对齐别名(仅 user);受发送身份能力矩阵约束"
}, },
"property": { "property": {
"candidates": [ "candidates": [
@@ -75265,7 +75265,7 @@
}, },
"identity": { "identity": {
"default": "user", "default": "user",
"description": "发送身份;身份相关目标、内容和凭据不能为空,完整能力矩阵见命令说明(可选值: user, bot, webhook)", "description": "发送身份;目标、凭据和幂等参数受发送身份能力矩阵约束(可选值: user, bot, webhook)",
"enum": [ "enum": [
"user", "user",
"bot", "bot",
@@ -75278,7 +75278,7 @@
"precedence": "cobra_contract", "precedence": "cobra_contract",
"selected": true, "selected": true,
"source": "cobra_usage", "source": "cobra_usage",
"value": "发送身份;身份相关目标、内容和凭据不能为空,完整能力矩阵见命令说明(可选值: user, bot, webhook)" "value": "发送身份;目标、凭据和幂等参数受发送身份能力矩阵约束(可选值: user, bot, webhook)"
}, },
{ {
"precedence": "default", "precedence": "default",
@@ -75290,7 +75290,7 @@
"precedence": "cobra_contract", "precedence": "cobra_contract",
"resolution": "highest_precedence", "resolution": "highest_precedence",
"source": "cobra_usage", "source": "cobra_usage",
"value": "发送身份;身份相关目标、内容和凭据不能为空,完整能力矩阵见命令说明(可选值: user, bot, webhook)" "value": "发送身份;目标、凭据和幂等参数受发送身份能力矩阵约束(可选值: user, bot, webhook)"
}, },
"enum": { "enum": {
"candidates": [ "candidates": [
@@ -75694,7 +75694,7 @@
"type": "string" "type": "string"
}, },
"open-dingtalk-id": { "open-dingtalk-id": {
"description": "单聊接收者 openDingTalkId(user)", "description": "单聊接收者 openDingTalkId(user);受发送身份能力矩阵约束",
"field_provenance": { "field_provenance": {
"description": { "description": {
"candidates": [ "candidates": [
@@ -75702,7 +75702,7 @@
"precedence": "cobra_contract", "precedence": "cobra_contract",
"selected": true, "selected": true,
"source": "cobra_usage", "source": "cobra_usage",
"value": "单聊接收者 openDingTalkId(user)" "value": "单聊接收者 openDingTalkId(user);受发送身份能力矩阵约束"
}, },
{ {
"precedence": "default", "precedence": "default",
@@ -75714,7 +75714,7 @@
"precedence": "cobra_contract", "precedence": "cobra_contract",
"resolution": "highest_precedence", "resolution": "highest_precedence",
"source": "cobra_usage", "source": "cobra_usage",
"value": "单聊接收者 openDingTalkId(user)" "value": "单聊接收者 openDingTalkId(user);受发送身份能力矩阵约束"
}, },
"property": { "property": {
"candidates": [ "candidates": [
@@ -75778,7 +75778,7 @@
"type": "string" "type": "string"
}, },
"open-dingtalk-ids": { "open-dingtalk-ids": {
"description": "批量单聊接收者 openDingTalkId(bot)", "description": "批量单聊接收者 openDingTalkId(bot);受发送身份能力矩阵约束",
"field_provenance": { "field_provenance": {
"description": { "description": {
"candidates": [ "candidates": [
@@ -75786,7 +75786,7 @@
"precedence": "cobra_contract", "precedence": "cobra_contract",
"selected": true, "selected": true,
"source": "cobra_usage", "source": "cobra_usage",
"value": "批量单聊接收者 openDingTalkId(bot)" "value": "批量单聊接收者 openDingTalkId(bot);受发送身份能力矩阵约束"
}, },
{ {
"precedence": "default", "precedence": "default",
@@ -75798,7 +75798,7 @@
"precedence": "cobra_contract", "precedence": "cobra_contract",
"resolution": "highest_precedence", "resolution": "highest_precedence",
"source": "cobra_usage", "source": "cobra_usage",
"value": "批量单聊接收者 openDingTalkId(bot)" "value": "批量单聊接收者 openDingTalkId(bot);受发送身份能力矩阵约束"
}, },
"property": { "property": {
"candidates": [ "candidates": [
@@ -75862,7 +75862,7 @@
"type": "array" "type": "array"
}, },
"robot-code": { "robot-code": {
"description": "机器人 Code(identity=bot 时使用)", "description": "机器人 Code(identity=bot 时使用);受发送身份能力矩阵约束",
"field_provenance": { "field_provenance": {
"description": { "description": {
"candidates": [ "candidates": [
@@ -75870,7 +75870,7 @@
"precedence": "cobra_contract", "precedence": "cobra_contract",
"selected": true, "selected": true,
"source": "cobra_usage", "source": "cobra_usage",
"value": "机器人 Code(identity=bot 时使用)" "value": "机器人 Code(identity=bot 时使用);受发送身份能力矩阵约束"
}, },
{ {
"precedence": "default", "precedence": "default",
@@ -75882,7 +75882,7 @@
"precedence": "cobra_contract", "precedence": "cobra_contract",
"resolution": "highest_precedence", "resolution": "highest_precedence",
"source": "cobra_usage", "source": "cobra_usage",
"value": "机器人 Code(identity=bot 时使用)" "value": "机器人 Code(identity=bot 时使用);受发送身份能力矩阵约束"
}, },
"property": { "property": {
"candidates": [ "candidates": [
@@ -76120,7 +76120,7 @@
"type": "string" "type": "string"
}, },
"user": { "user": {
"description": "单聊接收者 userId(user;自动解析 openDingTalkId)", "description": "单聊接收者 userId(user;自动解析 openDingTalkId);受发送身份能力矩阵约束",
"field_provenance": { "field_provenance": {
"description": { "description": {
"candidates": [ "candidates": [
@@ -76128,7 +76128,7 @@
"precedence": "cobra_contract", "precedence": "cobra_contract",
"selected": true, "selected": true,
"source": "cobra_usage", "source": "cobra_usage",
"value": "单聊接收者 userId(user;自动解析 openDingTalkId)" "value": "单聊接收者 userId(user;自动解析 openDingTalkId);受发送身份能力矩阵约束"
}, },
{ {
"precedence": "default", "precedence": "default",
@@ -76140,7 +76140,7 @@
"precedence": "cobra_contract", "precedence": "cobra_contract",
"resolution": "highest_precedence", "resolution": "highest_precedence",
"source": "cobra_usage", "source": "cobra_usage",
"value": "单聊接收者 userId(user;自动解析 openDingTalkId)" "value": "单聊接收者 userId(user;自动解析 openDingTalkId);受发送身份能力矩阵约束"
}, },
"property": { "property": {
"candidates": [ "candidates": [
@@ -76204,7 +76204,7 @@
"type": "string" "type": "string"
}, },
"users": { "users": {
"description": "批量单聊接收者 userId(bot)", "description": "批量单聊接收者 userId(bot);受发送身份能力矩阵约束",
"field_provenance": { "field_provenance": {
"description": { "description": {
"candidates": [ "candidates": [
@@ -76212,7 +76212,7 @@
"precedence": "cobra_contract", "precedence": "cobra_contract",
"selected": true, "selected": true,
"source": "cobra_usage", "source": "cobra_usage",
"value": "批量单聊接收者 userId(bot)" "value": "批量单聊接收者 userId(bot);受发送身份能力矩阵约束"
}, },
{ {
"precedence": "default", "precedence": "default",
@@ -76224,7 +76224,7 @@
"precedence": "cobra_contract", "precedence": "cobra_contract",
"resolution": "highest_precedence", "resolution": "highest_precedence",
"source": "cobra_usage", "source": "cobra_usage",
"value": "批量单聊接收者 userId(bot)" "value": "批量单聊接收者 userId(bot);受发送身份能力矩阵约束"
}, },
"property": { "property": {
"candidates": [ "candidates": [
@@ -76288,7 +76288,7 @@
"type": "array" "type": "array"
}, },
"uuid": { "uuid": {
"description": "幂等键(仅 user)", "description": "幂等键(仅 user);受发送身份能力矩阵约束",
"field_provenance": { "field_provenance": {
"description": { "description": {
"candidates": [ "candidates": [
@@ -76296,7 +76296,7 @@
"precedence": "cobra_contract", "precedence": "cobra_contract",
"selected": true, "selected": true,
"source": "cobra_usage", "source": "cobra_usage",
"value": "幂等键(仅 user)" "value": "幂等键(仅 user);受发送身份能力矩阵约束"
}, },
{ {
"precedence": "default", "precedence": "default",
@@ -76308,7 +76308,7 @@
"precedence": "cobra_contract", "precedence": "cobra_contract",
"resolution": "highest_precedence", "resolution": "highest_precedence",
"source": "cobra_usage", "source": "cobra_usage",
"value": "幂等键(仅 user)" "value": "幂等键(仅 user);受发送身份能力矩阵约束"
}, },
"property": { "property": {
"candidates": [ "candidates": [
@@ -76372,7 +76372,7 @@
"type": "string" "type": "string"
}, },
"webhook-token": { "webhook-token": {
"description": "自定义机器人 Webhook token(identity=webhook 时使用)", "description": "自定义机器人 Webhook token(identity=webhook 时使用);受发送身份能力矩阵约束",
"field_provenance": { "field_provenance": {
"description": { "description": {
"candidates": [ "candidates": [
@@ -76380,7 +76380,7 @@
"precedence": "cobra_contract", "precedence": "cobra_contract",
"selected": true, "selected": true,
"source": "cobra_usage", "source": "cobra_usage",
"value": "自定义机器人 Webhook token(identity=webhook 时使用)" "value": "自定义机器人 Webhook token(identity=webhook 时使用);受发送身份能力矩阵约束"
}, },
{ {
"precedence": "default", "precedence": "default",
@@ -76392,7 +76392,7 @@
"precedence": "cobra_contract", "precedence": "cobra_contract",
"resolution": "highest_precedence", "resolution": "highest_precedence",
"source": "cobra_usage", "source": "cobra_usage",
"value": "自定义机器人 Webhook token(identity=webhook 时使用)" "value": "自定义机器人 Webhook token(identity=webhook 时使用);受发送身份能力矩阵约束"
}, },
"property": { "property": {
"candidates": [ "candidates": [
+7 -1
View File
@@ -320,7 +320,7 @@ func listMessageProjectOneWithReactions(m map[string]any, includeReactions bool)
if quoted := chatmsg.QuotedMessage(m); len(quoted) > 0 { if quoted := chatmsg.QuotedMessage(m); len(quoted) > 0 {
row["quotedMessage"] = quoted row["quotedMessage"] = quoted
} }
if resources := chatmsg.Resources(m); len(resources) > 0 { if resources := chatmsg.ResourcesDeep(m); len(resources) > 0 {
row["resourceRefs"] = resources row["resourceRefs"] = resources
} }
projectForwarded := func(item map[string]any) map[string]any { projectForwarded := func(item map[string]any) map[string]any {
@@ -638,6 +638,9 @@ func DownloadMessageResources(
seen := map[string]bool{} seen := map[string]bool{}
for _, resource := range resources { for _, resource := range resources {
resourceType := strings.TrimSpace(fmt.Sprint(resource["type"])) resourceType := strings.TrimSpace(fmt.Sprint(resource["type"]))
if canonicalType, ok := canonicalMessageResourceType(resourceType); ok {
resourceType = canonicalType
}
resourceID := strings.TrimSpace(fmt.Sprint(resource["resourceId"])) resourceID := strings.TrimSpace(fmt.Sprint(resource["resourceId"]))
download, _ := resource["download"].(map[string]any) download, _ := resource["download"].(map[string]any)
arguments, _ := download["arguments"].(map[string]any) arguments, _ := download["arguments"].(map[string]any)
@@ -692,6 +695,9 @@ func DownloadMessageResources(
plannedNames := map[string]int{} plannedNames := map[string]int{}
for _, resource := range resources { for _, resource := range resources {
resourceType := strings.TrimSpace(fmt.Sprint(resource["type"])) resourceType := strings.TrimSpace(fmt.Sprint(resource["type"]))
if canonicalType, ok := canonicalMessageResourceType(resourceType); ok {
resourceType = canonicalType
}
resourceID := strings.TrimSpace(fmt.Sprint(resource["resourceId"])) resourceID := strings.TrimSpace(fmt.Sprint(resource["resourceId"]))
download, _ := resource["download"].(map[string]any) download, _ := resource["download"].(map[string]any)
arguments, _ := download["arguments"].(map[string]any) arguments, _ := download["arguments"].(map[string]any)
@@ -24,7 +24,7 @@ func TestCrossPlatformCoverageListMessageRichProjection(t *testing.T) {
"content": `{"mediaId":"@image"}`, "content": `{"mediaId":"@image"}`,
"quotedMessage": map[string]any{ "quotedMessage": map[string]any{
"openMessageId": "quoted", "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]) 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) { func TestCrossPlatformCoverageMgetResourceDownloadOutcomes(t *testing.T) {
+30
View File
@@ -20,6 +20,36 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" "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) { func TestCrossPlatformCoverageMessagesSendCurrentUserImageAndUserResolution(t *testing.T) {
fake := &larkAlignmentCaller{} fake := &larkAlignmentCaller{}
helpers.InitDeps(fake) helpers.InitDeps(fake)
+32 -6
View File
@@ -89,7 +89,8 @@ var MessagesResourceDownload = shortcut.Shortcut{
if err := validateResourceDownloadOutput(rt.Str("output")); err != nil { if err := validateResourceDownloadOutput(rt.Str("output")); err != nil {
return err 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("message-id")) == "" ||
strings.TrimSpace(rt.Str("open-conversation-id")) == "") { strings.TrimSpace(rt.Str("open-conversation-id")) == "") {
return apperrors.NewValidation( return apperrors.NewValidation(
@@ -98,8 +99,9 @@ var MessagesResourceDownload = shortcut.Shortcut{
return nil return nil
}, },
Execute: func(rt *shortcut.RuntimeContext) error { Execute: func(rt *shortcut.RuntimeContext) error {
resourceType, _ := canonicalMessageResourceType(rt.Str("type"))
plan := map[string]any{ plan := map[string]any{
"resourceType": rt.Str("type"), "resourceType": resourceType,
"resourceId": rt.Str("resource-id"), "resourceId": rt.Str("resource-id"),
"messageId": rt.Str("message-id"), "messageId": rt.Str("message-id"),
"openConversationId": rt.Str("open-conversation-id"), "openConversationId": rt.Str("open-conversation-id"),
@@ -119,7 +121,7 @@ var MessagesResourceDownload = shortcut.Shortcut{
data, err := resolveMessageResourceDownloadData( data, err := resolveMessageResourceDownloadData(
rt, rt,
rt.Str("type"), resourceType,
rt.Str("resource-id"), rt.Str("resource-id"),
rt.Str("message-id"), rt.Str("message-id"),
rt.Str("open-conversation-id"), rt.Str("open-conversation-id"),
@@ -153,7 +155,7 @@ var MessagesResourceDownload = shortcut.Shortcut{
return rt.Output(map[string]any{ return rt.Output(map[string]any{
"messageId": rt.Str("message-id"), "messageId": rt.Str("message-id"),
"resourceId": rt.Str("resource-id"), "resourceId": rt.Str("resource-id"),
"resourceType": rt.Str("type"), "resourceType": resourceType,
"localPath": filepath.ToSlash(relativePath), "localPath": filepath.ToSlash(relativePath),
"sizeBytes": size, "sizeBytes": size,
}) })
@@ -164,6 +166,11 @@ func resolveMessageResourceDownloadData(
rt *shortcut.RuntimeContext, rt *shortcut.RuntimeContext,
resourceType, resourceID, messageID, conversationID string, resourceType, resourceID, messageID, conversationID string,
) (map[string]any, error) { ) (map[string]any, error) {
resourceType, ok := canonicalMessageResourceType(resourceType)
if !ok {
return nil, apperrors.NewValidation(fmt.Sprintf(
"不支持的消息资源类型 %q;仅支持 mediaId 或 fileId", resourceType))
}
if resourceType == "fileId" { if resourceType == "fileId" {
return rt.CallMCPData("drive", "download_file", map[string]any{ return rt.CallMCPData("drive", "download_file", map[string]any{
"fileId": resourceID, "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 { func validateResourceDownloadOutput(output string) error {
return validateResourceDownloadOutputFlag(output, "--output") return validateResourceDownloadOutputFlag(output, "--output")
} }
@@ -486,6 +504,8 @@ func downloadResourceAtomically(
clientCopy := *client clientCopy := *client
client = &clientCopy client = &clientCopy
originalRedirect := client.CheckRedirect originalRedirect := client.CheckRedirect
initialHost := strings.ToLower(parsedResourceURL.Hostname())
headersConfinedToInitialHost := true
client.CheckRedirect = func(req *http.Request, via []*http.Request) error { client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
if _, redirectErr := validateResourceDownloadURL(req.URL.String()); redirectErr != nil { if _, redirectErr := validateResourceDownloadURL(req.URL.String()); redirectErr != nil {
return apperrors.NewValidation(fmt.Sprintf( return apperrors.NewValidation(fmt.Sprintf(
@@ -494,8 +514,14 @@ func downloadResourceAtomically(
if len(via) >= 10 { if len(via) >= 10 {
return apperrors.NewAPI("资源下载重定向次数过多") return apperrors.NewAPI("资源下载重定向次数过多")
} }
if len(via) > 0 && if !strings.EqualFold(req.URL.Hostname(), initialHost) {
!strings.EqualFold(req.URL.Hostname(), via[len(via)-1].URL.Hostname()) { 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 { for key := range headers {
req.Header.Del(key) req.Header.Del(key)
} }
@@ -469,18 +469,29 @@ func TestCrossPlatformCoverageDownloadResourceRedirectGuards(t *testing.T) {
redirectCount := 0 redirectCount := 0
crossHost := &http.Client{Transport: resourceRoundTripper(func(request *http.Request) (*http.Response, error) { crossHost := &http.Client{Transport: resourceRoundTripper(func(request *http.Request) (*http.Response, error) {
redirectCount++ redirectCount++
if redirectCount == 1 { switch redirectCount {
case 1:
if request.Header.Get("X-Resource-Token") != "secret" { if request.Header.Get("X-Resource-Token") != "secret" {
t.Fatal("initial signed header missing") 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{ return &http.Response{
StatusCode: http.StatusFound, StatusCode: http.StatusFound,
Body: io.NopCloser(strings.NewReader("")), Body: io.NopCloser(strings.NewReader("")),
Header: http.Header{"Location": []string{"https://bucket.oss-cn-hangzhou.aliyuncs.com/final"}}, Header: http.Header{"Location": []string{"https://bucket.oss-cn-hangzhou.aliyuncs.com/final"}},
}, nil }, nil
} default:
if request.Header.Get("X-Resource-Token") != "" { if request.Header.Get("X-Resource-Token") != "" {
t.Fatal("server-supplied header leaked across download domains") t.Fatal("server-supplied header was restored on later same-host hop")
}
} }
return &http.Response{ return &http.Response{
StatusCode: http.StatusOK, StatusCode: http.StatusOK,
@@ -23,6 +23,29 @@ import (
"testing" "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) { func TestCrossPlatformCoverageResourceDownloadInfo(t *testing.T) {
url, headers, err := resourceDownloadInfo(map[string]any{ url, headers, err := resourceDownloadInfo(map[string]any{
"result": map[string]any{ "result": map[string]any{
+14 -14
View File
@@ -31,16 +31,16 @@ var MessagesSend = shortcut.Shortcut{
Intent: "当你希望用同一个入口选择 current-user、bot 或 webhook 身份发送消息时使用;命令会按身份校验目标、内容和凭据并路由到真实下层。current-user 支持文本/Markdown、已有 mediaId 图片、安全相对路径文件上传和幂等键;bot 支持群聊或批量单聊文本/Markdown;webhook 的目标由 token 所在群决定。不会把 user 文件能力伪装成 bot/webhook 等价能力。", Intent: "当你希望用同一个入口选择 current-user、bot 或 webhook 身份发送消息时使用;命令会按身份校验目标、内容和凭据并路由到真实下层。current-user 支持文本/Markdown、已有 mediaId 图片、安全相对路径文件上传和幂等键;bot 支持群聊或批量单聊文本/Markdown;webhook 的目标由 token 所在群决定。不会把 user 文件能力伪装成 bot/webhook 等价能力。",
Risk: shortcut.RiskWrite, Risk: shortcut.RiskWrite,
Flags: []shortcut.Flag{ Flags: []shortcut.Flag{
{Name: "identity", Type: shortcut.FlagString, Default: "user", Enum: []string{"user", "bot", "webhook"}, Desc: "发送身份;身份相关目标、内容和凭据不能为空,完整能力矩阵见命令说明"}, {Name: "identity", Type: shortcut.FlagString, Default: "user", Enum: []string{"user", "bot", "webhook"}, Desc: "发送身份;目标、凭据和幂等参数受发送身份能力矩阵约束"},
{Name: "as", Type: shortcut.FlagString, Enum: []string{"user", "bot", "webhook"}, Desc: "--identity 的 lark-cli 对齐别名"}, {Name: "as", Type: shortcut.FlagString, Enum: []string{"user", "bot", "webhook"}, Desc: "--identity 的 lark-cli 对齐别名;受发送身份能力矩阵约束"},
{Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId(user/bot 群聊)"}, {Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId(user/bot 群聊);受发送身份能力矩阵约束"},
{Name: "chat-id", Type: shortcut.FlagString, Desc: "--group 的 lark-cli 对齐别名"}, {Name: "chat-id", Type: shortcut.FlagString, Desc: "--group 的 lark-cli 对齐别名;受发送身份能力矩阵约束"},
{Name: "user", Type: shortcut.FlagString, Desc: "单聊接收者 userId(user;自动解析 openDingTalkId)"}, {Name: "user", Type: shortcut.FlagString, Desc: "单聊接收者 userId(user;自动解析 openDingTalkId);受发送身份能力矩阵约束"},
{Name: "open-dingtalk-id", Type: shortcut.FlagString, Desc: "单聊接收者 openDingTalkId(user)"}, {Name: "open-dingtalk-id", Type: shortcut.FlagString, Desc: "单聊接收者 openDingTalkId(user);受发送身份能力矩阵约束"},
{Name: "users", Type: shortcut.FlagStringSlice, Desc: "批量单聊接收者 userId(bot)"}, {Name: "users", Type: shortcut.FlagStringSlice, Desc: "批量单聊接收者 userId(bot);受发送身份能力矩阵约束"},
{Name: "open-dingtalk-ids", Type: shortcut.FlagStringSlice, Desc: "批量单聊接收者 openDingTalkId(bot)"}, {Name: "open-dingtalk-ids", Type: shortcut.FlagStringSlice, Desc: "批量单聊接收者 openDingTalkId(bot);受发送身份能力矩阵约束"},
{Name: "robot-code", Type: shortcut.FlagString, Desc: "机器人 Code(identity=bot 时使用)"}, {Name: "robot-code", Type: shortcut.FlagString, Desc: "机器人 Code(identity=bot 时使用);受发送身份能力矩阵约束"},
{Name: "webhook-token", Type: shortcut.FlagString, Desc: "自定义机器人 Webhook token(identity=webhook 时使用)"}, {Name: "webhook-token", Type: shortcut.FlagString, Desc: "自定义机器人 Webhook token(identity=webhook 时使用);受发送身份能力矩阵约束"},
{Name: "title", Type: shortcut.FlagString, Desc: "消息标题(不传则从正文生成)"}, {Name: "title", Type: shortcut.FlagString, Desc: "消息标题(不传则从正文生成)"},
{Name: "text", Type: shortcut.FlagString, Desc: "纯文本正文"}, {Name: "text", Type: shortcut.FlagString, Desc: "纯文本正文"},
{Name: "markdown", Type: shortcut.FlagString, Desc: "Markdown 正文"}, {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: "media-id", Type: shortcut.FlagString, Desc: "已有图片 mediaId(仅 user 的 image)"},
{Name: "file", Type: shortcut.FlagString, Desc: "工作目录内安全相对文件路径(仅 user 的 file/audio/video)"}, {Name: "file", Type: shortcut.FlagString, Desc: "工作目录内安全相对文件路径(仅 user 的 file/audio/video)"},
{Name: "file-path", Type: shortcut.FlagString, Desc: "--file 的兼容别名"}, {Name: "file-path", Type: shortcut.FlagString, Desc: "--file 的兼容别名"},
{Name: "uuid", Type: shortcut.FlagString, Desc: "幂等键(仅 user)"}, {Name: "uuid", Type: shortcut.FlagString, Desc: "幂等键(仅 user);受发送身份能力矩阵约束"},
{Name: "idempotency-key", Type: shortcut.FlagString, Desc: "--uuid 的 lark-cli 对齐别名(仅 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-open-dingtalk-ids", Type: shortcut.FlagStringSlice, Desc: "@ 的 openDingTalkId(user/bot 群聊)"},
{Name: "at-user-ids", Type: shortcut.FlagStringSlice, Desc: "@ 的 userId(bot/webhook)"}, {Name: "at-user-ids", Type: shortcut.FlagStringSlice, Desc: "@ 的 userId(bot/webhook)"},
{Name: "at-mobiles", Type: shortcut.FlagStringSlice, Desc: "@ 的手机号(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.ConstraintMutuallyExclusive, Flags: []string{"uuid", "idempotency-key"}},
{ {
Kind: shortcut.ConstraintCustom, Kind: shortcut.ConstraintCustom,
Flags: []string{"identity"}, Flags: []string{"identity", "as", "group", "chat-id", "user", "open-dingtalk-id", "users", "open-dingtalk-ids", "robot-code", "webhook-token", "uuid", "idempotency-key"},
Description: "身份相关目标、内容和凭据不能为空,完整能力矩阵见命令说明", Description: "目标、凭据和幂等参数受发送身份能力矩阵约束:user 必须指定一个群聊或单聊目标;bot 必须指定 robot-code 和一类目标;webhook 必须指定 webhook-token;幂等键仅 user 支持",
}, },
}, },
Tips: []string{ Tips: []string{
+40
View File
@@ -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) { func TestCrossPlatformCoverageMountRegistersFlagsAndUse(t *testing.T) {
s := Shortcut{ s := Shortcut{
Service: "contact", Service: "contact",
+1 -1
View File
@@ -240,7 +240,7 @@ func atMeProjectWithReactions(m map[string]any, includeReactions bool) map[strin
if quoted := chatmsg.QuotedMessage(m); len(quoted) > 0 { if quoted := chatmsg.QuotedMessage(m); len(quoted) > 0 {
row["quotedMessage"] = quoted row["quotedMessage"] = quoted
} }
if resources := chatmsg.Resources(m); len(resources) > 0 { if resources := chatmsg.ResourcesDeep(m); len(resources) > 0 {
row["resourceRefs"] = resources row["resourceRefs"] = resources
} }
projectForwarded := func(item map[string]any) map[string]any { projectForwarded := func(item map[string]any) map[string]any {
+8 -2
View File
@@ -22,6 +22,12 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg" "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 // ChatMessages: fetch the message list of one conversation (group OR single
// chat) and print a clean projected list (speaker / text / time) instead of a // chat) and print a clean projected list (speaker / text / time) instead of a
// raw dump. // raw dump.
@@ -86,7 +92,7 @@ var ChatMessages = shortcut.Shortcut{
if rt.Changed("time") && rt.Str("time") != "" { if rt.Changed("time") && rt.Str("time") != "" {
params["time"] = rt.Str("time") params["time"] = rt.Str("time")
} else { } 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 { if limit := rt.IntFirst("limit", "size"); limit > 0 {
params["limit"] = limit params["limit"] = limit
@@ -211,7 +217,7 @@ func projectChatMessageWithReactions(m map[string]any, includeReactions bool) ma
if quoted := chatmsg.QuotedMessage(m); len(quoted) > 0 { if quoted := chatmsg.QuotedMessage(m); len(quoted) > 0 {
row["quotedMessage"] = quoted row["quotedMessage"] = quoted
} }
if resources := chatmsg.Resources(m); len(resources) > 0 { if resources := chatmsg.ResourcesDeep(m); len(resources) > 0 {
row["resourceRefs"] = resources row["resourceRefs"] = resources
} }
projectForwarded := func(item map[string]any) map[string]any { 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}}, "emotionReplyList": []any{map[string]any{"emojiName": "赞", "replyCount": 1}},
"quotedMessage": map[string]any{ "quotedMessage": map[string]any{
"openMessageId": "quoted", "openMessageId": "quoted",
"content": "quoted body", "content": `{"mediaId":"@quoted-image"}`,
}, },
} }
for name, row := range map[string]map[string]any{ 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) 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])
}
} }
} }
+24 -5
View File
@@ -13,7 +13,7 @@ import (
) )
func TestCrossPlatformCoverageMessageReadShortcutsPublishResourceDownloadPlans(t *testing.T) { 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 { tests := []struct {
name string name string
tool string tool string
@@ -72,11 +72,19 @@ func TestCrossPlatformCoverageMessageReadShortcutsPublishResourceDownloadPlans(t
if err := json.Unmarshal(output.Bytes(), &payload); err != nil { if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatalf("decode output: %v\n%s", err, output.String()) 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) 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) 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) t.Fatalf("resource plan = %#v", ledger)
} }
}) })
@@ -104,7 +112,7 @@ func TestCrossPlatformCoverageChatMessagesDefaultsToRecentHistory(t *testing.T)
helpers.InitDeps(caller) helpers.InitDeps(caller)
root := newPlatformCoverageRoot() root := newPlatformCoverageRoot()
before := time.Now().Add(-2 * time.Second) 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 { if err := root.Execute(); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -116,7 +124,11 @@ func TestCrossPlatformCoverageChatMessagesDefaultsToRecentHistory(t *testing.T)
if call.args["forward"] != false { if call.args["forward"] != false {
t.Fatalf("default forward = %#v, want false", call.args["forward"]) 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 { if err != nil {
t.Fatal(err) 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) { func TestCrossPlatformCoverageChatMessagesPreservesExplicitTime(t *testing.T) {
caller := &platformCoverageCaller{} caller := &platformCoverageCaller{}
helpers.InitDeps(caller) helpers.InitDeps(caller)
+1 -1
View File
@@ -526,7 +526,7 @@ func searchMsgProjectWithReactions(m map[string]any, includeReactions bool) map[
if quoted := chatmsg.QuotedMessage(m); len(quoted) > 0 { if quoted := chatmsg.QuotedMessage(m); len(quoted) > 0 {
row["quotedMessage"] = quoted row["quotedMessage"] = quoted
} }
if resources := chatmsg.Resources(m); len(resources) > 0 { if resources := chatmsg.ResourcesDeep(m); len(resources) > 0 {
row["resourceRefs"] = resources row["resourceRefs"] = resources
} }
projectForwarded := func(item map[string]any) map[string]any { projectForwarded := func(item map[string]any) map[string]any {