From ab2fba868bdb81238ee452ebe5837d7023ba01a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=91=9E=E8=BE=BE?= Date: Mon, 3 Aug 2026 11:50:49 +0800 Subject: [PATCH] fix(chat): improve aligned shortcut reliability Add hidden compatibility flags and exact high-frequency routes to avoid help-driven recovery loops. Restore granular PAT tests and correct the Runtime Catalog versus curated Schema contract. --- internal/cli/param_aliases_generated.go | 20 +- internal/cli/schema_catalog/catalog.json | 10 +- internal/cli/schema_catalog/tools/chat.json | 24 +- internal/errors/coverage_edges_test.go | 59 +- internal/errors/pat_test.go | 1050 +++++++++++++++++ internal/shortcut/chat/chat_conversation.go | 22 +- internal/shortcut/chat/chat_group.go | 47 +- internal/shortcut/chat/chat_message.go | 41 +- .../chat/compatibility_coverage_test.go | 68 +- internal/shortcut/chat/lark_alignment.go | 24 +- internal/shortcut/runner.go | 31 +- internal/shortcut/shortcut_test.go | 15 + .../smart/compatibility_coverage_test.go | 14 + internal/shortcut/smart/dm.go | 9 +- internal/shortcut/smart/group_members.go | 7 +- scripts/gen_skill_shortcut_sections.py | 7 +- skills/multi/dingtalk-chat/SKILL.md | 102 +- test/scripts/chat_skill_scripts_test.py | 37 +- 18 files changed, 1416 insertions(+), 171 deletions(-) create mode 100644 internal/errors/pat_test.go diff --git a/internal/cli/param_aliases_generated.go b/internal/cli/param_aliases_generated.go index 36c4d8c0..d55189f7 100644 --- a/internal/cli/param_aliases_generated.go +++ b/internal/cli/param_aliases_generated.go @@ -131,12 +131,9 @@ var generatedParamAliases = []ParamAliasEntry{ { CLIPath: "chat +chat-bots", Aliases: map[string]string{ - "chat": "group", - "chat-id": "group", - "conversation-id": "group", - "open-conversation-id": "group", + "chat": "group", }, - Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, }, { CLIPath: "chat +chat-dismiss", @@ -151,12 +148,9 @@ var generatedParamAliases = []ParamAliasEntry{ { CLIPath: "chat +chat-invite-url", Aliases: map[string]string{ - "chat": "group", - "chat-id": "group", - "conversation-id": "group", - "open-conversation-id": "group", + "chat": "group", }, - Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, }, { CLIPath: "chat +chat-mute", @@ -313,11 +307,7 @@ var generatedParamAliases = []ParamAliasEntry{ }, { CLIPath: "chat +messages-mget", - Aliases: map[string]string{ - "message-ids": "msg-ids", - "open-message-ids": "msg-ids", - }, - Blocked: []string{"message-id", "msg-id", "open-message-id", "ref-msg-id", "src-msg-id"}, + Blocked: []string{"msg-id", "open-message-id", "ref-msg-id", "src-msg-id"}, }, { CLIPath: "chat +messages-read-status", diff --git a/internal/cli/schema_catalog/catalog.json b/internal/cli/schema_catalog/catalog.json index f5088b12..4267c709 100644 --- a/internal/cli/schema_catalog/catalog.json +++ b/internal/cli/schema_catalog/catalog.json @@ -1,7 +1,7 @@ { "version": 1, "surface_hash": "sha256:4897d582a472d9d3596f88fb4546e7795d6b9bc334d020712f5d895ce894f7a7", - "source_hash": "sha256:0493d43524350d7bb2db4f2d2985a500227e2dee2a7616c466ba075d1bc40ffb", + "source_hash": "sha256:113dbc3507c5e4d871802d567e960c4947b8066472c28fb8a6dc64e9fef36da9", "catalog": { "agent_metadata": { "products_with_metadata": 26, @@ -10493,7 +10493,7 @@ "cli_name": "+bot-find", "cli_path": "chat +bot-find", "confirmation": "not_required", - "description": "当你想找到平台上任意可用机器人(含他人创建或官方助手,例如某个日报/审批机器人)以便与其发起单聊时使用;输入关键词,返回含 openDingTalkId 的机器人列表。\n\n参数约束:\n - --query、--keyword 至少指定一个", + "description": "当你想找到平台上任意可用机器人(含他人创建或官方助手,例如某个日报/审批机器人)以便与其发起单聊时使用;输入关键词,返回含 openDingTalkId 的机器人列表。", "effect": "read", "idempotency": "idempotent", "interface_mode": "composite", @@ -10831,7 +10831,7 @@ "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 必须是工作目录内的相对路径,不允许绝对路径或 .. 逃逸", + "description": "当你想快速看某个会话里的消息(谁在什么时间说了什么),而不想拿到一大坨原始消息字段时使用;群聊传 --group(群会话 ID,openConversationId),单聊传 --user(对方 userId),两者互斥且必须二选一。省略 --time 时默认从当前时间向前读取最近消息;也可指定时间边界并用 --direction newer/older 控制方向。内部据此调用群聊或单聊的消息列表接口,再在本地投影出每条消息的发言人、文本和时间。默认只读且不会发送或修改任何消息;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘,按既有安全下载约定无需交互确认。\n\n参数约束:\n - --group、--user、--open-dingtalk-id 必须且只能指定一个\n - --output-dir 必须是工作目录内的相对路径,不允许绝对路径或 .. 逃逸", "effect": "read", "idempotency": "idempotent", "interface_mode": "composite", @@ -11013,7 +11013,7 @@ "cli_name": "+chat-search", "cli_path": "chat +chat-search", "confirmation": "not_required", - "description": "当你只记得群名称关键词、需要拿到群 openConversationId 以便发消息或管理该群时使用;按群名模糊搜索,只读分页返回匹配的群列表。\n\n参数约束:\n - --query、--keyword 至少指定一个", + "description": "当你只记得群名称关键词、需要拿到群 openConversationId 以便发消息或管理该群时使用;按群名模糊搜索,只读分页返回匹配的群列表。", "effect": "read", "idempotency": "idempotent", "interface_mode": "composite", @@ -11429,7 +11429,7 @@ "cli_name": "+messages-read-status", "cli_path": "chat +messages-read-status", "confirmation": "not_required", - "description": "当你想知道自己发出的某条消息有哪些人已读/未读时使用;只读,需传会话 openConversationId 和该消息 openMessageId,可指定目标成员列表。\n\n参数约束:\n - --conversation-id、--group、--id 必须且只能指定一个", + "description": "当你想知道自己发出的某条消息有哪些人已读/未读时使用;只读,需传会话 openConversationId 和该消息 openMessageId,可指定目标成员列表。", "effect": "read", "idempotency": "idempotent", "interface_mode": "composite", diff --git a/internal/cli/schema_catalog/tools/chat.json b/internal/cli/schema_catalog/tools/chat.json index 02a9adda..7ea74b6e 100644 --- a/internal/cli/schema_catalog/tools/chat.json +++ b/internal/cli/schema_catalog/tools/chat.json @@ -68516,7 +68516,7 @@ "cli_name": "+bot-find", "cli_path": "chat +bot-find", "confirmation": "not_required", - "description": "当你想找到平台上任意可用机器人(含他人创建或官方助手,例如某个日报/审批机器人)以便与其发起单聊时使用;输入关键词,返回含 openDingTalkId 的机器人列表。\n\n参数约束:\n - --query、--keyword 至少指定一个", + "description": "当你想找到平台上任意可用机器人(含他人创建或官方助手,例如某个日报/审批机器人)以便与其发起单聊时使用;输入关键词,返回含 openDingTalkId 的机器人列表。", "display": "群聊 / 消息 / 机器人", "effect": "read", "effect_source": "agent-hint", @@ -68614,13 +68614,13 @@ "precedence": "cobra_help", "selected": true, "source": "cobra_help", - "value": "当你想找到平台上任意可用机器人(含他人创建或官方助手,例如某个日报/审批机器人)以便与其发起单聊时使用;输入关键词,返回含 openDingTalkId 的机器人列表。\n\n参数约束:\n - --query、--keyword 至少指定一个" + "value": "当你想找到平台上任意可用机器人(含他人创建或官方助手,例如某个日报/审批机器人)以便与其发起单聊时使用;输入关键词,返回含 openDingTalkId 的机器人列表。" } ], "precedence": "cobra_help", "resolution": "highest_precedence", "source": "cobra_help", - "value": "当你想找到平台上任意可用机器人(含他人创建或官方助手,例如某个日报/审批机器人)以便与其发起单聊时使用;输入关键词,返回含 openDingTalkId 的机器人列表。\n\n参数约束:\n - --query、--keyword 至少指定一个" + "value": "当你想找到平台上任意可用机器人(含他人创建或官方助手,例如某个日报/审批机器人)以便与其发起单聊时使用;输入关键词,返回含 openDingTalkId 的机器人列表。" }, "effect": { "candidates": [ @@ -74874,7 +74874,7 @@ ] ] }, - "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 必须是工作目录内的相对路径,不允许绝对路径或 .. 逃逸", + "description": "当你想快速看某个会话里的消息(谁在什么时间说了什么),而不想拿到一大坨原始消息字段时使用;群聊传 --group(群会话 ID,openConversationId),单聊传 --user(对方 userId),两者互斥且必须二选一。省略 --time 时默认从当前时间向前读取最近消息;也可指定时间边界并用 --direction newer/older 控制方向。内部据此调用群聊或单聊的消息列表接口,再在本地投影出每条消息的发言人、文本和时间。默认只读且不会发送或修改任何消息;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘,按既有安全下载约定无需交互确认。\n\n参数约束:\n - --group、--user、--open-dingtalk-id 必须且只能指定一个\n - --output-dir 必须是工作目录内的相对路径,不允许绝对路径或 .. 逃逸", "display": "群聊 / 消息 / 机器人", "effect": "read", "effect_source": "agent-hint", @@ -74973,13 +74973,13 @@ "precedence": "cobra_help", "selected": true, "source": "cobra_help", - "value": "当你想快速看某个会话里的消息(谁在什么时间说了什么),而不想拿到一大坨原始消息字段时使用;群聊传 --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 必须是工作目录内的相对路径,不允许绝对路径或 .. 逃逸" + "value": "当你想快速看某个会话里的消息(谁在什么时间说了什么),而不想拿到一大坨原始消息字段时使用;群聊传 --group(群会话 ID,openConversationId),单聊传 --user(对方 userId),两者互斥且必须二选一。省略 --time 时默认从当前时间向前读取最近消息;也可指定时间边界并用 --direction newer/older 控制方向。内部据此调用群聊或单聊的消息列表接口,再在本地投影出每条消息的发言人、文本和时间。默认只读且不会发送或修改任何消息;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘,按既有安全下载约定无需交互确认。\n\n参数约束:\n - --group、--user、--open-dingtalk-id 必须且只能指定一个\n - --output-dir 必须是工作目录内的相对路径,不允许绝对路径或 .. 逃逸" } ], "precedence": "cobra_help", "resolution": "highest_precedence", "source": "cobra_help", - "value": "当你想快速看某个会话里的消息(谁在什么时间说了什么),而不想拿到一大坨原始消息字段时使用;群聊传 --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 必须是工作目录内的相对路径,不允许绝对路径或 .. 逃逸" + "value": "当你想快速看某个会话里的消息(谁在什么时间说了什么),而不想拿到一大坨原始消息字段时使用;群聊传 --group(群会话 ID,openConversationId),单聊传 --user(对方 userId),两者互斥且必须二选一。省略 --time 时默认从当前时间向前读取最近消息;也可指定时间边界并用 --direction newer/older 控制方向。内部据此调用群聊或单聊的消息列表接口,再在本地投影出每条消息的发言人、文本和时间。默认只读且不会发送或修改任何消息;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘,按既有安全下载约定无需交互确认。\n\n参数约束:\n - --group、--user、--open-dingtalk-id 必须且只能指定一个\n - --output-dir 必须是工作目录内的相对路径,不允许绝对路径或 .. 逃逸" }, "effect": { "candidates": [ @@ -79253,7 +79253,7 @@ "cli_name": "+chat-search", "cli_path": "chat +chat-search", "confirmation": "not_required", - "description": "当你只记得群名称关键词、需要拿到群 openConversationId 以便发消息或管理该群时使用;按群名模糊搜索,只读分页返回匹配的群列表。\n\n参数约束:\n - --query、--keyword 至少指定一个", + "description": "当你只记得群名称关键词、需要拿到群 openConversationId 以便发消息或管理该群时使用;按群名模糊搜索,只读分页返回匹配的群列表。", "display": "群聊 / 消息 / 机器人", "effect": "read", "effect_source": "agent-hint", @@ -79351,13 +79351,13 @@ "precedence": "cobra_help", "selected": true, "source": "cobra_help", - "value": "当你只记得群名称关键词、需要拿到群 openConversationId 以便发消息或管理该群时使用;按群名模糊搜索,只读分页返回匹配的群列表。\n\n参数约束:\n - --query、--keyword 至少指定一个" + "value": "当你只记得群名称关键词、需要拿到群 openConversationId 以便发消息或管理该群时使用;按群名模糊搜索,只读分页返回匹配的群列表。" } ], "precedence": "cobra_help", "resolution": "highest_precedence", "source": "cobra_help", - "value": "当你只记得群名称关键词、需要拿到群 openConversationId 以便发消息或管理该群时使用;按群名模糊搜索,只读分页返回匹配的群列表。\n\n参数约束:\n - --query、--keyword 至少指定一个" + "value": "当你只记得群名称关键词、需要拿到群 openConversationId 以便发消息或管理该群时使用;按群名模糊搜索,只读分页返回匹配的群列表。" }, "effect": { "candidates": [ @@ -88290,7 +88290,7 @@ "cli_name": "+messages-read-status", "cli_path": "chat +messages-read-status", "confirmation": "not_required", - "description": "当你想知道自己发出的某条消息有哪些人已读/未读时使用;只读,需传会话 openConversationId 和该消息 openMessageId,可指定目标成员列表。\n\n参数约束:\n - --conversation-id、--group、--id 必须且只能指定一个", + "description": "当你想知道自己发出的某条消息有哪些人已读/未读时使用;只读,需传会话 openConversationId 和该消息 openMessageId,可指定目标成员列表。", "display": "群聊 / 消息 / 机器人", "effect": "read", "effect_source": "agent-hint", @@ -88388,13 +88388,13 @@ "precedence": "cobra_help", "selected": true, "source": "cobra_help", - "value": "当你想知道自己发出的某条消息有哪些人已读/未读时使用;只读,需传会话 openConversationId 和该消息 openMessageId,可指定目标成员列表。\n\n参数约束:\n - --conversation-id、--group、--id 必须且只能指定一个" + "value": "当你想知道自己发出的某条消息有哪些人已读/未读时使用;只读,需传会话 openConversationId 和该消息 openMessageId,可指定目标成员列表。" } ], "precedence": "cobra_help", "resolution": "highest_precedence", "source": "cobra_help", - "value": "当你想知道自己发出的某条消息有哪些人已读/未读时使用;只读,需传会话 openConversationId 和该消息 openMessageId,可指定目标成员列表。\n\n参数约束:\n - --conversation-id、--group、--id 必须且只能指定一个" + "value": "当你想知道自己发出的某条消息有哪些人已读/未读时使用;只读,需传会话 openConversationId 和该消息 openMessageId,可指定目标成员列表。" }, "effect": { "candidates": [ diff --git a/internal/errors/coverage_edges_test.go b/internal/errors/coverage_edges_test.go index 6a4c6a79..cd14e084 100644 --- a/internal/errors/coverage_edges_test.go +++ b/internal/errors/coverage_edges_test.go @@ -207,7 +207,7 @@ func TestCrossPlatformCoveragePATURLAndMutationCoverageEdges(t *testing.T) { } } -func TestCrossPlatformCoveragePATClassificationAndBusinessHints(t *testing.T) { +func TestCrossPlatformCoveragePATClassification(t *testing.T) { patErr := &PATError{RawJSON: `{"code":"PAT_NO_PERMISSION"}`} if patErr.Error() != patErr.RawJSON || patErr.RawStderr() != patErr.RawJSON || patErr.ExitCode() != ExitCodePermission { t.Fatalf("PATError contract changed: %#v", patErr) @@ -319,34 +319,6 @@ func TestCrossPlatformCoveragePATClassificationAndBusinessHints(t *testing.T) { t.Fatal("authentication recovery hints lost the login command") } - hintCases := []struct { - body map[string]any - want string - }{ - {body: map[string]any{"errorMsg": "搜索内容不能为空"}, want: "doc search"}, - {body: map[string]any{"message": "User has no permission to access this email"}, want: "mailbox list"}, - {body: map[string]any{"error": "频率超限"}, want: "rate limit"}, - {body: map[string]any{"errorMsg": "参数错误"}, want: "parameters"}, - { - body: map[string]any{ - "error": map[string]any{"code": "IM_ERROR", "message": "listRoles null"}, - "summary": "context", - "code": "TOP_LEVEL", - }, - want: "list-my-groups", - }, - {body: map[string]any{"message": "OpendId is not in conversation"}, want: "实际加入"}, - {body: map[string]any{"message": "OpenId is not in conversation"}, want: "实际加入"}, - {body: map[string]any{"message": "The operator is not in this group chat"}, want: "源群"}, - {body: map[string]any{"message": "targetOpenConversationId和receiverUid不能同时为空"}, want: "--receiver"}, - {body: map[string]any{"summary": "unknown", "code": "UNKNOWN"}, want: "business error"}, - } - for _, tc := range hintCases { - if got := SuggestBusinessHint(tc.body); !strings.Contains(got, tc.want) { - t.Errorf("SuggestBusinessHint(%v) = %q, want containing %q", tc.body, got, tc.want) - } - } - if got := ClassifyPatAuthCheck(map[string]any{"code": "PAT_LOW_RISK_NO_PERMISSION"}); got == nil { t.Fatal("ClassifyPatAuthCheck() returned nil") } @@ -362,6 +334,35 @@ func TestCrossPlatformCoveragePATClassificationAndBusinessHints(t *testing.T) { } } +func TestSuggestBusinessHintChatRecovery(t *testing.T) { + tests := []struct { + name string + body map[string]any + want string + }{ + { + name: "missing group role context", + body: map[string]any{ + "error": map[string]any{"code": "IM_ERROR", "message": "listRoles null"}, + "summary": "context", + "code": "TOP_LEVEL", + }, + want: "list-my-groups", + }, + {name: "legacy open id spelling", body: map[string]any{"message": "OpendId is not in conversation"}, want: "实际加入"}, + {name: "open id outside conversation", body: map[string]any{"message": "OpenId is not in conversation"}, want: "实际加入"}, + {name: "operator outside source group", body: map[string]any{"message": "The operator is not in this group chat"}, want: "源群"}, + {name: "missing invitation receiver", body: map[string]any{"message": "targetOpenConversationId和receiverUid不能同时为空"}, want: "--receiver"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := SuggestBusinessHint(tc.body); !strings.Contains(got, tc.want) { + t.Errorf("SuggestBusinessHint(%v) = %q, want containing %q", tc.body, got, tc.want) + } + }) + } +} + func TestCrossPlatformCoveragePATSerializationAndPolicyEdges(t *testing.T) { oldHost := hostControlProvider oldBrowser := patBrowserProvider diff --git a/internal/errors/pat_test.go b/internal/errors/pat_test.go new file mode 100644 index 00000000..4cac296e --- /dev/null +++ b/internal/errors/pat_test.go @@ -0,0 +1,1050 @@ +// 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. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package errors + +import ( + "encoding/json" + stderrors "errors" + "net/url" + "strings" + "testing" +) + +// --------------------------------------------------------------------------- +// PATError basic behaviour +// --------------------------------------------------------------------------- + +func TestPATError_Implements(t *testing.T) { + t.Parallel() + raw := `{"success":false,"code":"PAT_NO_PERMISSION"}` + pe := &PATError{RawJSON: raw} + + if pe.Error() != raw { + t.Errorf("Error() = %q, want %q", pe.Error(), raw) + } + if pe.ExitCode() != ExitCodePermission { + t.Errorf("ExitCode() = %d, want %d", pe.ExitCode(), ExitCodePermission) + } + if pe.RawStderr() != raw { + t.Errorf("RawStderr() = %q, want %q", pe.RawStderr(), raw) + } +} + +func TestIsPATError_True(t *testing.T) { + t.Parallel() + err := &PATError{RawJSON: "{}"} + if !IsPATError(err) { + t.Fatal("expected IsPATError to return true for *PATError") + } +} + +func TestIsPATError_False(t *testing.T) { + t.Parallel() + err := stderrors.New("some other error") + if IsPATError(err) { + t.Fatal("expected IsPATError to return false for non-PATError") + } +} + +// --------------------------------------------------------------------------- +// IsPATNoPermissionCode +// --------------------------------------------------------------------------- + +func TestIsPATNoPermissionCode(t *testing.T) { + t.Parallel() + cases := []struct { + code string + want bool + }{ + {"PAT_NO_PERMISSION", true}, + {"PAT_LOW_RISK_NO_PERMISSION", true}, + {"PAT_MEDIUM_RISK_NO_PERMISSION", true}, + {"PAT_HIGH_RISK_NO_PERMISSION", true}, + {"PAT_ORG_POLICY_DENIED", true}, + {"AGENT_CODE_NOT_EXISTS", false}, + {"UNKNOWN_CODE", false}, + {"", false}, + } + for _, tc := range cases { + if got := IsPATNoPermissionCode(tc.code); got != tc.want { + t.Errorf("IsPATNoPermissionCode(%q) = %v, want %v", tc.code, got, tc.want) + } + } +} + +// --------------------------------------------------------------------------- +// getDWSGatewayErrorCode +// --------------------------------------------------------------------------- + +func TestGetDWSGatewayErrorCode_ErrorCode(t *testing.T) { + t.Parallel() + body := map[string]any{"errorCode": "DWS_SERVICE_UNAUTHORIZED"} + code, ok := getDWSGatewayErrorCode(body) + if !ok || code != "DWS_SERVICE_UNAUTHORIZED" { + t.Errorf("got (%q, %v), want (DWS_SERVICE_UNAUTHORIZED, true)", code, ok) + } +} + +func TestGetDWSGatewayErrorCode_ErrorCodeUnderscore(t *testing.T) { + t.Parallel() + body := map[string]any{"error_code": "DWS_AUTH_SERVICE_FAILED"} + code, ok := getDWSGatewayErrorCode(body) + if !ok || code != "DWS_AUTH_SERVICE_FAILED" { + t.Errorf("got (%q, %v), want (DWS_AUTH_SERVICE_FAILED, true)", code, ok) + } +} + +func TestGetDWSGatewayErrorCode_Unknown(t *testing.T) { + t.Parallel() + body := map[string]any{"errorCode": "SOME_OTHER_ERROR"} + _, ok := getDWSGatewayErrorCode(body) + if ok { + t.Fatal("expected ok=false for unknown error code") + } +} + +func TestGetDWSGatewayErrorCode_Empty(t *testing.T) { + t.Parallel() + body := map[string]any{} + _, ok := getDWSGatewayErrorCode(body) + if ok { + t.Fatal("expected ok=false for empty body") + } +} + +// --------------------------------------------------------------------------- +// isNotLoggedInError +// --------------------------------------------------------------------------- + +func TestIsNotLoggedInError_True(t *testing.T) { + t.Parallel() + body := map[string]any{"error": "Missing service_id or access_key in request headers"} + if !isNotLoggedInError(body) { + t.Fatal("expected true for Missing service_id message") + } +} + +func TestIsNotLoggedInError_False(t *testing.T) { + t.Parallel() + body := map[string]any{"error": "something else happened"} + if isNotLoggedInError(body) { + t.Fatal("expected false for unrelated error message") + } +} + +func TestIsNotLoggedInError_NoErrorField(t *testing.T) { + t.Parallel() + body := map[string]any{"message": "Missing service_id or access_key"} + if !isNotLoggedInError(body) { + t.Fatal("expected true when equivalent auth message is present in message") + } +} + +func TestGetDWSGatewayErrorCode_CodeField(t *testing.T) { + t.Parallel() + body := map[string]any{"code": "DWS_SERVICE_UNAUTHORIZED"} + code, ok := getDWSGatewayErrorCode(body) + if !ok || code != "DWS_SERVICE_UNAUTHORIZED" { + t.Fatalf("getDWSGatewayErrorCode() = (%q, %t), want DWS_SERVICE_UNAUTHORIZED, true", code, ok) + } +} + +// --------------------------------------------------------------------------- +// isBusinessError +// --------------------------------------------------------------------------- + +func TestIsBusinessError_ErrorField(t *testing.T) { + t.Parallel() + body := map[string]any{"error": "some error message"} + if !isBusinessError(body) { + t.Fatal("expected true when 'error' field is present") + } +} + +func TestIsBusinessError_SuccessBoolFalse(t *testing.T) { + t.Parallel() + body := map[string]any{"success": false} + if !isBusinessError(body) { + t.Fatal("expected true when success=false (bool)") + } +} + +func TestIsBusinessError_SuccessStringFalse(t *testing.T) { + t.Parallel() + body := map[string]any{"success": "False"} + if !isBusinessError(body) { + t.Fatal("expected true when success=\"False\" (string)") + } +} + +func TestIsBusinessError_SuccessTrue(t *testing.T) { + t.Parallel() + body := map[string]any{"success": true, "data": "ok"} + if isBusinessError(body) { + t.Fatal("expected false when success=true") + } +} + +func TestIsBusinessError_EmptyBody(t *testing.T) { + t.Parallel() + body := map[string]any{"data": "hello"} + if isBusinessError(body) { + t.Fatal("expected false for body without error indicators") + } +} + +// --------------------------------------------------------------------------- +// ClassifyToolResultContent +// --------------------------------------------------------------------------- + +func TestClassifyToolResultContent_GatewayAuth(t *testing.T) { + t.Parallel() + content := map[string]any{"errorCode": "DWS_SERVICE_UNAUTHORIZED", "message": "expired"} + err := ClassifyToolResultContent(content) + if err == nil { + t.Fatal("expected non-nil error for gateway auth") + } + var typed *Error + if !stderrors.As(err, &typed) { + t.Fatalf("expected *Error, got %T", err) + } + if typed.Category != CategoryAuth { + t.Errorf("Category = %v, want %v", typed.Category, CategoryAuth) + } + if typed.Reason != "gateway_auth_expired" { + t.Errorf("Reason = %q, want gateway_auth_expired", typed.Reason) + } +} + +func TestClassifyToolResultContent_PATPermission(t *testing.T) { + t.Parallel() + content := map[string]any{ + "code": "PAT_NO_PERMISSION", + "data": map[string]any{"desc": "需要授权"}, + } + err := ClassifyToolResultContent(content) + if err == nil { + t.Fatal("expected non-nil error for PAT permission") + } + var patErr *PATError + if !stderrors.As(err, &patErr) { + t.Fatalf("expected *PATError, got %T", err) + } + if !strings.Contains(patErr.RawJSON, "PAT_NO_PERMISSION") { + t.Errorf("RawJSON should contain PAT_NO_PERMISSION, got: %s", patErr.RawJSON) + } +} + +func TestClassifyToolResultContent_PATPermissionLegacyErrorCode(t *testing.T) { + t.Parallel() + content := map[string]any{ + "error_code": "PAT_LOW_RISK_NO_PERMISSION", + "data": map[string]any{"desc": "需要授权"}, + } + err := ClassifyToolResultContent(content) + if err == nil { + t.Fatal("expected non-nil error for legacy error_code PAT permission") + } + var patErr *PATError + if !stderrors.As(err, &patErr) { + t.Fatalf("expected *PATError, got %T", err) + } + if !strings.Contains(patErr.RawJSON, "PAT_LOW_RISK_NO_PERMISSION") { + t.Errorf("RawJSON should contain PAT_LOW_RISK_NO_PERMISSION, got: %s", patErr.RawJSON) + } +} + +func TestClassifyToolResultContent_PATAuthRequired(t *testing.T) { + t.Parallel() + content := map[string]any{ + "errorCode": "AGENT_CODE_NOT_EXISTS", + "data": map[string]any{"agentCode": "agt-missing"}, + } + err := ClassifyToolResultContent(content) + if err == nil { + t.Fatal("expected non-nil error for PAT auth-required selector") + } + var patErr *PATError + if !stderrors.As(err, &patErr) { + t.Fatalf("expected *PATError, got %T", err) + } + if !strings.Contains(patErr.RawJSON, "AGENT_CODE_NOT_EXISTS") { + t.Errorf("RawJSON should contain AGENT_CODE_NOT_EXISTS, got: %s", patErr.RawJSON) + } +} + +func TestCrossPlatformCoverageClassifyToolResultContentPATOrgPolicyDenied(t *testing.T) { + t.Parallel() + content := map[string]any{ + "success": false, + "code": "PAT_ORG_POLICY_DENIED", + "message": "business error: code PAT_ORG_POLICY_DENIED", + "data": map[string]any{ + "policyDesc": "组织策略禁止访问该开源数据权限", + "scope": "contact.user.read", + }, + } + err := ClassifyToolResultContent(content) + if err == nil { + t.Fatal("expected non-nil error for PAT org policy denial") + } + var patErr *PATError + if !stderrors.As(err, &patErr) { + t.Fatalf("expected *PATError, got %T", err) + } + if patErr.ExitCode() != ExitCodePermission { + t.Fatalf("ExitCode() = %d, want %d", patErr.ExitCode(), ExitCodePermission) + } + var parsed map[string]any + if err := json.Unmarshal([]byte(patErr.RawJSON), &parsed); err != nil { + t.Fatalf("RawJSON is not valid JSON: %v; raw=%s", err, patErr.RawJSON) + } + data, ok := parsed["data"].(map[string]any) + if !ok { + t.Fatalf("parsed.data missing or wrong type: %#v", parsed["data"]) + } + if got, _ := parsed["code"].(string); got != "PAT_ORG_POLICY_DENIED" { + t.Fatalf("parsed.code = %q, want PAT_ORG_POLICY_DENIED", got) + } + if got, _ := data["hint"].(string); !strings.Contains(got, "组织策略") || !strings.Contains(got, "DWS/PAT") { + t.Fatalf("data.hint = %q, want explicit org policy hint", got) + } + if got, _ := data["scope"].(string); got != "contact.user.read" { + t.Fatalf("data.scope = %q, want contact.user.read", got) + } + if got, _ := data["openBrowser"].(bool); got { + t.Fatalf("data.openBrowser = true, want false for terminal org policy denial") + } +} + +func TestClassifyToolResultContent_NoError(t *testing.T) { + t.Parallel() + content := map[string]any{"success": true, "data": "ok"} + if err := ClassifyToolResultContent(content); err != nil { + t.Fatalf("expected nil error, got %v", err) + } +} + +// --------------------------------------------------------------------------- +// ClassifyMCPResponseText +// --------------------------------------------------------------------------- + +func TestClassifyMCPResponseText_GatewayAuth(t *testing.T) { + t.Parallel() + text := `{"errorCode":"DWS_SERVICE_UNAUTHORIZED","message":"token expired"}` + err := ClassifyMCPResponseText(text) + if err == nil { + t.Fatal("expected non-nil error") + } + var typed *Error + if !stderrors.As(err, &typed) { + t.Fatalf("expected *Error, got %T", err) + } + if typed.Reason != "gateway_auth_expired" { + t.Errorf("Reason = %q, want gateway_auth_expired", typed.Reason) + } +} + +func TestClassifyMCPResponseText_NotLoggedIn(t *testing.T) { + t.Parallel() + text := `{"error":"Missing service_id or access_key in headers"}` + err := ClassifyMCPResponseText(text) + if err == nil { + t.Fatal("expected non-nil error") + } + var typed *Error + if !stderrors.As(err, &typed) { + t.Fatalf("expected *Error, got %T", err) + } + if typed.Reason != "not_configured" { + t.Errorf("Reason = %q, want not_configured", typed.Reason) + } +} + +func TestClassifyMCPResponseText_PATPermission(t *testing.T) { + t.Parallel() + text := `{"code":"PAT_HIGH_RISK_NO_PERMISSION","data":{"desc":"high risk"}}` + err := ClassifyMCPResponseText(text) + if err == nil { + t.Fatal("expected non-nil error") + } + var patErr *PATError + if !stderrors.As(err, &patErr) { + t.Fatalf("expected *PATError, got %T", err) + } + if !strings.Contains(patErr.RawJSON, "PAT_HIGH_RISK_NO_PERMISSION") { + t.Errorf("RawJSON should contain code, got: %s", patErr.RawJSON) + } +} + +func TestClassifyMCPResponseText_PATPermissionLegacyErrorCode(t *testing.T) { + t.Parallel() + text := `{"error_code":"PAT_MEDIUM_RISK_NO_PERMISSION","data":{"desc":"legacy"}}` + err := ClassifyMCPResponseText(text) + if err == nil { + t.Fatal("expected non-nil error") + } + var patErr *PATError + if !stderrors.As(err, &patErr) { + t.Fatalf("expected *PATError, got %T", err) + } + if !strings.Contains(patErr.RawJSON, "PAT_MEDIUM_RISK_NO_PERMISSION") { + t.Errorf("RawJSON should contain legacy code, got: %s", patErr.RawJSON) + } +} + +func TestClassifyMCPResponseText_PATAuthRequired(t *testing.T) { + t.Parallel() + text := `{"success":false,"code":"PAT_SCOPE_AUTH_REQUIRED","data":{"missingScope":"mail:send"}}` + err := ClassifyMCPResponseText(text) + if err == nil { + t.Fatal("expected non-nil error") + } + var patErr *PATError + if !stderrors.As(err, &patErr) { + t.Fatalf("expected *PATError, got %T", err) + } + if !strings.Contains(patErr.RawJSON, "PAT_SCOPE_AUTH_REQUIRED") { + t.Errorf("RawJSON should contain PAT_SCOPE_AUTH_REQUIRED, got: %s", patErr.RawJSON) + } + if !strings.Contains(patErr.RawJSON, "missingScope") { + t.Errorf("RawJSON should preserve missingScope, got: %s", patErr.RawJSON) + } +} + +func TestClassifyMCPResponseText_PATBatchAuthPending(t *testing.T) { + t.Parallel() + text := `{"success":false,"code":"PAT_BATCH_AUTH_PENDING","data":{"flowId":"flow-1","uri":"https://example.test/auth"}}` + err := ClassifyMCPResponseText(text) + if err == nil { + t.Fatal("expected non-nil error") + } + var patErr *PATError + if !stderrors.As(err, &patErr) { + t.Fatalf("expected *PATError, got %T", err) + } + if !strings.Contains(patErr.RawJSON, "PAT_BATCH_AUTH_PENDING") { + t.Errorf("RawJSON should contain PAT_BATCH_AUTH_PENDING, got: %s", patErr.RawJSON) + } + if !strings.Contains(patErr.RawJSON, "flow-1") { + t.Errorf("RawJSON should preserve flowId, got: %s", patErr.RawJSON) + } +} + +func TestCrossPlatformCoverageClassifyMCPResponseTextPATOrgPolicyDenied(t *testing.T) { + t.Parallel() + text := `{"success":false,"code":"PAT_ORG_POLICY_DENIED","message":"business error: code PAT_ORG_POLICY_DENIED"}` + err := ClassifyMCPResponseText(text) + if err == nil { + t.Fatal("expected non-nil error") + } + var patErr *PATError + if !stderrors.As(err, &patErr) { + t.Fatalf("expected *PATError, got %T", err) + } + var parsed map[string]any + if err := json.Unmarshal([]byte(patErr.RawJSON), &parsed); err != nil { + t.Fatalf("RawJSON is not valid JSON: %v; raw=%s", err, patErr.RawJSON) + } + data, ok := parsed["data"].(map[string]any) + if !ok { + t.Fatalf("parsed.data missing or wrong type: %#v", parsed["data"]) + } + if got, _ := data["message"].(string); got != "business error: code PAT_ORG_POLICY_DENIED" { + t.Fatalf("data.message = %q, want original server message", got) + } + if got, _ := data["hint"].(string); !strings.Contains(got, "组织策略") { + t.Fatalf("data.hint = %q, want explicit org policy hint", got) + } + if got, _ := data["openBrowser"].(bool); got { + t.Fatalf("data.openBrowser = true, want false for terminal org policy denial") + } +} + +func TestClassifyMCPResponseText_BusinessError(t *testing.T) { + t.Parallel() + text := `{"success":false,"errorMsg":"搜索内容不能为空"}` + err := ClassifyMCPResponseText(text) + if err == nil { + t.Fatal("expected non-nil error") + } + var typed *Error + if !stderrors.As(err, &typed) { + t.Fatalf("expected *Error, got %T", err) + } + if typed.Reason != "business_error" { + t.Errorf("Reason = %q, want business_error", typed.Reason) + } + if !strings.Contains(typed.Hint, "搜索关键词") { + t.Errorf("Hint should contain search suggestion, got: %s", typed.Hint) + } +} + +func TestClassifyMCPResponseText_InvalidJSON(t *testing.T) { + t.Parallel() + text := "not json at all" + if err := ClassifyMCPResponseText(text); err != nil { + t.Fatalf("expected nil for invalid JSON, got %v", err) + } +} + +func TestClassifyMCPResponseText_NoError(t *testing.T) { + t.Parallel() + text := `{"success":true,"data":"hello"}` + if err := ClassifyMCPResponseText(text); err != nil { + t.Fatalf("expected nil for success response, got %v", err) + } +} + +// --------------------------------------------------------------------------- +// ClassifyPatAuthCheck +// --------------------------------------------------------------------------- + +func TestClassifyPatAuthCheck_PATNoPermission(t *testing.T) { + t.Parallel() + content := map[string]any{"code": "PAT_NO_PERMISSION", "data": map[string]any{"flowId": "f1"}} + patErr := ClassifyPatAuthCheck(content) + if patErr == nil { + t.Fatal("expected non-nil *PATError") + } + if !strings.Contains(patErr.RawJSON, "PAT_NO_PERMISSION") { + t.Errorf("RawJSON should contain code, got: %s", patErr.RawJSON) + } +} + +func TestClassifyPatAuthCheck_LegacyErrorCode(t *testing.T) { + t.Parallel() + content := map[string]any{"error_code": "PAT_HIGH_RISK_NO_PERMISSION", "data": map[string]any{"flowId": "f1"}} + patErr := ClassifyPatAuthCheck(content) + if patErr == nil { + t.Fatal("expected non-nil *PATError for legacy error_code") + } + if !strings.Contains(patErr.RawJSON, "PAT_HIGH_RISK_NO_PERMISSION") { + t.Errorf("RawJSON should contain PAT_HIGH_RISK_NO_PERMISSION, got: %s", patErr.RawJSON) + } +} + +func TestClassifyPatAuthCheck_AgentCodeNotExists(t *testing.T) { + t.Parallel() + content := map[string]any{"errorCode": "AGENT_CODE_NOT_EXISTS", "data": map[string]any{"clientId": "c1"}} + patErr := ClassifyPatAuthCheck(content) + if patErr == nil { + t.Fatal("expected non-nil *PATError for AGENT_CODE_NOT_EXISTS") + } + if !strings.Contains(patErr.RawJSON, "AGENT_CODE_NOT_EXISTS") { + t.Errorf("RawJSON should contain AGENT_CODE_NOT_EXISTS, got: %s", patErr.RawJSON) + } +} + +// TestClassifyPatAuthCheck_scope_auth_required pins the PAT_SCOPE_AUTH_REQUIRED +// selector (part of the frozen PAT-family enum; see patAuthRequiredCodes in +// internal/errors/pat.go) as a PATError with exit=4 so hosts can kick the +// `dws auth login --scope ` branch. +func TestClassifyPatAuthCheck_scope_auth_required(t *testing.T) { + t.Parallel() + content := map[string]any{ + "success": false, + "code": "PAT_SCOPE_AUTH_REQUIRED", + "data": map[string]any{"missingScope": "mail:send"}, + } + patErr := ClassifyPatAuthCheck(content) + if patErr == nil { + t.Fatal("expected non-nil *PATError for PAT_SCOPE_AUTH_REQUIRED") + } + + // Error value MUST satisfy the ExitCoder contract (exit=4) so the + // process exits with the PAT Frozen code regardless of wrapping. + var ec interface{ ExitCode() int } = patErr + if ec.ExitCode() != ExitCodePermission { + t.Errorf("ExitCode() = %d, want %d", ec.ExitCode(), ExitCodePermission) + } + + // Host-visible RawStderr must carry the selector and, crucially, the + // missingScope field that drives `dws auth login --scope `. + raw := patErr.RawStderr() + if !strings.Contains(raw, "PAT_SCOPE_AUTH_REQUIRED") { + t.Errorf("RawStderr missing selector, got: %s", raw) + } + if !strings.Contains(raw, "missingScope") { + t.Errorf("RawStderr missing missingScope field, got: %s", raw) + } + if !strings.Contains(raw, "mail:send") { + t.Errorf("RawStderr missing missingScope value, got: %s", raw) + } +} + +func TestClassifyPatAuthCheck_NoMatch(t *testing.T) { + t.Parallel() + content := map[string]any{"code": "SOME_BUSINESS_ERROR", "message": "oops"} + if patErr := ClassifyPatAuthCheck(content); patErr != nil { + t.Fatalf("expected nil, got %v", patErr) + } +} + +func TestClassifyPatAuthCheck_EmptyContent(t *testing.T) { + t.Parallel() + content := map[string]any{} + if patErr := ClassifyPatAuthCheck(content); patErr != nil { + t.Fatalf("expected nil for empty content, got %v", patErr) + } +} + +// --------------------------------------------------------------------------- +// AsPatAuthCheckError +// --------------------------------------------------------------------------- + +func TestAsPatAuthCheckError_Wrapped(t *testing.T) { + t.Parallel() + inner := &PATError{RawJSON: `{"code":"PAT_NO_PERMISSION"}`} + wrapped := stderrors.Join(stderrors.New("context"), inner) + got := AsPatAuthCheckError(wrapped) + if got == nil { + t.Fatal("expected non-nil *PATError from wrapped error") + } + if got.RawJSON != inner.RawJSON { + t.Errorf("RawJSON = %q, want %q", got.RawJSON, inner.RawJSON) + } +} + +func TestAsPatAuthCheckError_NotPAT(t *testing.T) { + t.Parallel() + err := stderrors.New("just a plain error") + if got := AsPatAuthCheckError(err); got != nil { + t.Fatalf("expected nil for non-PAT error, got %v", got) + } +} + +// --------------------------------------------------------------------------- +// cleanPATJSON +// --------------------------------------------------------------------------- + +func TestCleanPATJSON_WithData(t *testing.T) { + t.Parallel() + body := map[string]any{ + "success": false, + "code": "PAT_NO_PERMISSION", + "data": map[string]any{ + "desc": "需要授权", + "flowId": "f123", + "class": "com.foo.Bar", + }, + } + result := cleanPATJSON(body, "PAT_NO_PERMISSION") + if !strings.Contains(result, "PAT_NO_PERMISSION") { + t.Errorf("expected code in output, got: %s", result) + } + if !strings.Contains(result, "flowId") { + t.Errorf("expected flowId in data, got: %s", result) + } + if strings.Contains(result, "class") { + t.Errorf("expected class field to be stripped, got: %s", result) + } + if !strings.Contains(result, `"openBrowser":true`) { + t.Errorf("expected openBrowser default in output, got: %s", result) + } +} + +func TestCleanPATJSON_WithoutData(t *testing.T) { + t.Parallel() + body := map[string]any{ + "success": false, + "code": "PAT_NO_PERMISSION", + "message": "no permission", + "extra": "value", + } + result := cleanPATJSON(body, "PAT_NO_PERMISSION") + if !strings.Contains(result, "extra") { + t.Errorf("expected extra field in fallback data, got: %s", result) + } + // Top-level stripped fields should not appear + if strings.Contains(result, `"message"`) { + t.Errorf("expected message to be stripped from top level, got: %s", result) + } +} + +// TestCleanPATJSON_InjectsHostControlWhenClawSet verifies the +// single-injection invariant: when the bootstrap wires a non-empty +// clawType provider, cleanPATJSON MUST emit data.hostControl. +func TestCleanPATJSON_InjectsHostControlWhenClawSet(t *testing.T) { + // Not parallel: mutates the package-level provider. + t.Cleanup(func() { SetHostControlProvider(nil) }) + SetHostControlProvider(func() string { return "my-copilot" }) + + body := map[string]any{ + "success": false, + "code": "PAT_NO_PERMISSION", + "data": map[string]any{ + "desc": "需要授权", + "flowId": "f-1", + "callbacks": []any{"cb1", "cb2"}, + }, + } + raw := cleanPATJSON(body, "PAT_NO_PERMISSION") + + var parsed map[string]any + if err := json.Unmarshal([]byte(raw), &parsed); err != nil { + t.Fatalf("unmarshal cleanPATJSON output: %v\nraw=%s", err, raw) + } + data, ok := parsed["data"].(map[string]any) + if !ok { + t.Fatalf("expected data object, got %T", parsed["data"]) + } + hc, ok := data["hostControl"].(map[string]any) + if !ok { + t.Fatalf("expected data.hostControl to be a map, got %T\nraw=%s", data["hostControl"], raw) + } + if got, _ := hc["clawType"].(string); got != "my-copilot" { + t.Errorf("hostControl.clawType = %q, want %q", got, "my-copilot") + } + if got, _ := hc["callbackOwner"].(string); got != "host" { + t.Errorf("hostControl.callbackOwner = %q, want %q", got, "host") + } + if got, _ := hc["mode"].(string); got != "host" { + t.Errorf("hostControl.mode = %q, want %q", got, "host") + } + if _, ok := data["callbacks"]; ok { + t.Fatalf("cleanPATJSON should strip callbacks in host-owned mode, got: %v", data["callbacks"]) + } +} + +// TestCleanPATJSON_OmitsHostControlByDefault verifies that cleanPATJSON +// does NOT include a hostControl block when the provider is unset or +// returns empty (default CLI-owned mode). +func TestCleanPATJSON_OmitsHostControlByDefault(t *testing.T) { + // Not parallel: reads the package-level provider. + t.Cleanup(func() { SetHostControlProvider(nil) }) + SetHostControlProvider(nil) + + body := map[string]any{ + "success": false, + "code": "PAT_NO_PERMISSION", + "data": map[string]any{ + "desc": "need auth", + }, + } + raw := cleanPATJSON(body, "PAT_NO_PERMISSION") + if strings.Contains(raw, `"hostControl"`) { + t.Fatalf("cleanPATJSON should omit hostControl in default mode, got: %s", raw) + } + + SetHostControlProvider(func() string { return "" }) + raw = cleanPATJSON(body, "PAT_NO_PERMISSION") + if strings.Contains(raw, `"hostControl"`) { + t.Fatalf("cleanPATJSON should omit hostControl when provider returns empty, got: %s", raw) + } +} + +func TestCleanPATJSON_UsesBrowserPolicyProvider(t *testing.T) { + t.Cleanup(func() { + SetHostControlProvider(nil) + SetPATOpenBrowserProvider(nil) + }) + SetHostControlProvider(nil) + SetPATOpenBrowserProvider(func() bool { return false }) + + body := map[string]any{ + "success": false, + "code": "PAT_NO_PERMISSION", + "data": map[string]any{ + "desc": "need auth", + }, + } + raw := cleanPATJSON(body, "PAT_NO_PERMISSION") + + var parsed map[string]any + if err := json.Unmarshal([]byte(raw), &parsed); err != nil { + t.Fatalf("unmarshal cleanPATJSON output: %v\nraw=%s", err, raw) + } + data, _ := parsed["data"].(map[string]any) + if got, ok := data["openBrowser"].(bool); !ok || got { + t.Fatalf("data.openBrowser = %#v, want false", data["openBrowser"]) + } +} + +// TestCleanPATJSON_SingleLineOutput pins down the wire invariant: stderr +// JSON MUST be emitted as a single line (no embedded \n, no pretty-print +// indentation) so that naïve host parsers reading stderr line-by-line stay +// correct. Regression guard against accidental reintroduction of +// json.MarshalIndent. +func TestCleanPATJSON_SingleLineOutput(t *testing.T) { + t.Parallel() + body := map[string]any{ + "success": false, + "code": "PAT_LOW_RISK_NO_PERMISSION", + "data": map[string]any{ + "requiredScopes": []any{"aitable.record:read"}, + "grantOptions": []any{"session", "permanent"}, + "displayName": "读取记录", + "productName": "AI 表格", + }, + } + raw := cleanPATJSON(body, "PAT_LOW_RISK_NO_PERMISSION") + + if strings.Contains(raw, "\n") { + t.Fatalf("cleanPATJSON output must be single-line, got embedded newline:\n%s", raw) + } + if strings.HasPrefix(raw, " ") || strings.HasPrefix(raw, "\t") { + t.Fatalf("cleanPATJSON output must not be indented, got leading whitespace: %q", raw) + } + + // Contract: the payload must remain a directly json.Unmarshal-able + // object, even after the single-line constraint is enforced. + var parsed map[string]any + if err := json.Unmarshal([]byte(raw), &parsed); err != nil { + t.Fatalf("single-line output must round-trip via json.Unmarshal: %v\nraw=%s", err, raw) + } + if code, _ := parsed["code"].(string); code != "PAT_LOW_RISK_NO_PERMISSION" { + t.Errorf("code = %q, want %q", code, "PAT_LOW_RISK_NO_PERMISSION") + } +} + +func TestCleanPATJSON_PreservesOpaqueURIVerbatim(t *testing.T) { + t.Parallel() + rawURI := "https://open-dev.dingtalk.com/fe/old?hash=%23%2FpersonalAuthorization%3FflowId%3D50dff7654b7444e88ced7489b07cce8d%26userCode%3DQ8RY-X6E9#/personalAuthorization?flowId=50dff7654b7444e88ced7489b07cce8d&userCode=Q8RY-X6E9" + body := map[string]any{ + "success": false, + "code": "PAT_MEDIUM_RISK_NO_PERMISSION", + "data": map[string]any{ + "desc": "在浏览器中打开以下链接进行认证", + "flowId": "50dff7654b7444e88ced7489b07cce8d", + "uri": rawURI, + }, + } + + result := cleanPATJSON(body, "PAT_MEDIUM_RISK_NO_PERMISSION") + if strings.Contains(result, `\u0026`) { + t.Fatalf("cleanPATJSON escaped ampersands in URL: %s", result) + } + if !strings.Contains(result, "&userCode=Q8RY-X6E9") { + t.Fatalf("cleanPATJSON output missing literal ampersand route separator: %s", result) + } + + if strings.Contains(result, `\u0026`) { + t.Fatalf("cleanPATJSON should keep URL ampersands readable for mobile copy/linkify, got: %s", result) + } + if !strings.Contains(result, "&userCode=Q8RY-X6E9") { + t.Fatalf("cleanPATJSON output missing readable fragment separator, got: %s", result) + } + + var parsed map[string]any + if err := json.Unmarshal([]byte(result), &parsed); err != nil { + t.Fatalf("unmarshal cleanPATJSON output: %v\nraw=%s", err, result) + } + data, _ := parsed["data"].(map[string]any) + if got, _ := data["uri"].(string); got != rawURI { + t.Fatalf("data.uri = %q, want verbatim %q", got, rawURI) + } + if _, ok := data["authUrl"]; ok { + t.Fatalf("data.authUrl should be omitted when data.uri is present") + } + if _, ok := data["authorizationUrl"]; ok { + t.Fatalf("data.authorizationUrl should be omitted when data.uri is present") + } +} + +func TestCleanPATJSON_BackfillsSingleURIFromAuthURL(t *testing.T) { + t.Parallel() + rawURI := "https://open-dev.dingtalk.com/fe/old?hash=%23%2FpersonalAuthorization%3FflowId%3D50dff7654b7444e88ced7489b07cce8d%26userCode%3DQ8RY-X6E9#/personalAuthorization?flowId=50dff7654b7444e88ced7489b07cce8d&userCode=Q8RY-X6E9" + body := map[string]any{ + "success": false, + "code": "PAT_BATCH_AUTH_PENDING", + "data": map[string]any{ + "flowId": "50dff7654b7444e88ced7489b07cce8d", + "authUrl": rawURI, + }, + } + + result := cleanPATJSON(body, "PAT_BATCH_AUTH_PENDING") + var parsed map[string]any + if err := json.Unmarshal([]byte(result), &parsed); err != nil { + t.Fatalf("unmarshal cleanPATJSON output: %v\nraw=%s", err, result) + } + data, _ := parsed["data"].(map[string]any) + if got, _ := data["uri"].(string); got != rawURI { + t.Fatalf("data.uri = %q, want %q", got, rawURI) + } + if _, ok := data["authUrl"]; ok { + t.Fatalf("data.authUrl should be omitted after backfilling data.uri") + } + if _, ok := data["authorizationUrl"]; ok { + t.Fatalf("data.authorizationUrl should be omitted after backfilling data.uri") + } +} + +func TestPATAuthorizationURL_NormalizesLegacyHashRoute(t *testing.T) { + t.Parallel() + rawURI := "https://open-dev.dingtalk.com/fe/old#%2FpersonalAuthorization%3FflowId%3D77108a9d0e6f4b74b769c04eb451e7d9%26userCode%3DWSAX-EEF2" + want := "https://open-dev.dingtalk.com/fe/old?hash=%23%2FpersonalAuthorization%3FflowId%3D77108a9d0e6f4b74b769c04eb451e7d9%26userCode%3DWSAX-EEF2#/personalAuthorization?flowId=77108a9d0e6f4b74b769c04eb451e7d9&userCode=WSAX-EEF2" + + if got := PATAuthorizationURL(rawURI); got != want { + t.Fatalf("PATAuthorizationURL() = %q, want %q", got, want) + } +} + +func TestPATAuthorizationURL_NormalizesLegacyHashRoutePreservesExtraQuery(t *testing.T) { + t.Parallel() + rawURI := "https://open-dev.dingtalk.com/fe/old#%2FpersonalAuthorization%3FflowId%3D77108a9d0e6f4b74b769c04eb451e7d9%26userCode%3DWSAX-EEF2%26agentCode%3Dcodex%26scene%3Ddesktop%26redirect%3Dhttps%253A%252F%252Fexample.com%252Fcallback%253Fa%253D1" + + got := PATAuthorizationURL(rawURI) + + if got == rawURI { + t.Fatal("expected legacy hash route to be normalized") + } + parsed, err := url.Parse(got) + if err != nil { + t.Fatalf("parse normalized URL: %v\nurl=%s", err, got) + } + hash := parsed.Query().Get("hash") + if hash == "" { + t.Fatalf("expected normalized URL to include hash query, got: %s", got) + } + if hash != "#"+parsed.Fragment { + t.Fatalf("hash query = %q, want fragment route %q", hash, "#"+parsed.Fragment) + } + rawQuery, ok := strings.CutPrefix(parsed.Fragment, "/personalAuthorization?") + if !ok { + t.Fatalf("fragment = %q, want personalAuthorization route", parsed.Fragment) + } + values, err := url.ParseQuery(rawQuery) + if err != nil { + t.Fatalf("parse normalized route query: %v\nquery=%s", err, rawQuery) + } + want := map[string]string{ + "flowId": "77108a9d0e6f4b74b769c04eb451e7d9", + "userCode": "WSAX-EEF2", + "agentCode": "codex", + "scene": "desktop", + "redirect": "https://example.com/callback?a=1", + } + for key, wantValue := range want { + if gotValue := values.Get(key); gotValue != wantValue { + t.Fatalf("route query %s = %q, want %q", key, gotValue, wantValue) + } + } +} + +func TestCleanPATJSON_NormalizesSingleURI(t *testing.T) { + t.Parallel() + rawURI := "https://open-dev.dingtalk.com/fe/old#%2FpersonalAuthorization%3FflowId%3D56b12fd3201d4efab9a9138672cf4deb%26userCode%3DCFTC-27ZN" + want := "https://open-dev.dingtalk.com/fe/old?hash=%23%2FpersonalAuthorization%3FflowId%3D56b12fd3201d4efab9a9138672cf4deb%26userCode%3DCFTC-27ZN#/personalAuthorization?flowId=56b12fd3201d4efab9a9138672cf4deb&userCode=CFTC-27ZN" + body := map[string]any{ + "success": false, + "code": "PAT_MEDIUM_RISK_NO_PERMISSION", + "data": map[string]any{ + "desc": "在浏览器中打开以下链接进行认证", + "flowId": "56b12fd3201d4efab9a9138672cf4deb", + "uri": rawURI, + }, + } + + result := cleanPATJSON(body, "PAT_MEDIUM_RISK_NO_PERMISSION") + if strings.Contains(result, `\u0026`) { + t.Fatalf("cleanPATJSON escaped ampersands in normalized URL: %s", result) + } + if !strings.Contains(result, "&userCode=CFTC-27ZN") { + t.Fatalf("cleanPATJSON output missing literal ampersand route separator: %s", result) + } + + var parsed map[string]any + if err := json.Unmarshal([]byte(result), &parsed); err != nil { + t.Fatalf("unmarshal cleanPATJSON output: %v\nraw=%s", err, result) + } + data, _ := parsed["data"].(map[string]any) + if got, _ := data["uri"].(string); got != want { + t.Fatalf("data.uri = %q, want normalized %q", got, want) + } + if _, ok := data["authUrl"]; ok { + t.Fatalf("data.authUrl should be omitted after normalizing data.uri") + } + if _, ok := data["authorizationUrl"]; ok { + t.Fatalf("data.authorizationUrl should be omitted after normalizing data.uri") + } +} + +// --------------------------------------------------------------------------- +// stripClassFields +// --------------------------------------------------------------------------- + +func TestStripClassFields_Map(t *testing.T) { + t.Parallel() + input := map[string]any{ + "name": "test", + "class": "com.foo.Bar", + "nested": map[string]any{ + "value": 42, + "class": "com.baz.Qux", + }, + } + result := stripClassFields(input).(map[string]any) + if _, ok := result["class"]; ok { + t.Error("top-level class should be removed") + } + nested := result["nested"].(map[string]any) + if _, ok := nested["class"]; ok { + t.Error("nested class should be removed") + } + if nested["value"] != 42 { + t.Errorf("nested value should be preserved, got %v", nested["value"]) + } +} + +func TestStripClassFields_Array(t *testing.T) { + t.Parallel() + input := []any{ + map[string]any{"id": 1, "class": "Foo"}, + map[string]any{"id": 2}, + } + result := stripClassFields(input).([]any) + first := result[0].(map[string]any) + if _, ok := first["class"]; ok { + t.Error("class in array element should be removed") + } + if first["id"] != 1 { + t.Error("other fields in array element should be preserved") + } +} + +func TestStripClassFields_Scalar(t *testing.T) { + t.Parallel() + if stripClassFields("hello") != "hello" { + t.Error("scalar string should pass through unchanged") + } + if stripClassFields(42) != 42 { + t.Error("scalar int should pass through unchanged") + } +} + +// --------------------------------------------------------------------------- +// suggestForBusinessErrorText +// --------------------------------------------------------------------------- + +func TestSuggestForBusinessErrorText(t *testing.T) { + t.Parallel() + cases := []struct { + body map[string]any + contains string + }{ + {map[string]any{"errorMsg": "搜索内容不能为空"}, "搜索关键词"}, + {map[string]any{"message": "User has no permission to access this email"}, "邮箱"}, + {map[string]any{"error": "频率超限"}, "rate limit"}, + {map[string]any{"errorMsg": "参数错误"}, "parameters"}, + {map[string]any{"error": "unknown"}, "business error"}, + } + for _, tc := range cases { + hint := suggestForBusinessErrorText(tc.body) + if !strings.Contains(strings.ToLower(hint), strings.ToLower(tc.contains)) { + t.Errorf("suggestForBusinessErrorText(%v) = %q, want to contain %q", tc.body, hint, tc.contains) + } + } +} diff --git a/internal/shortcut/chat/chat_conversation.go b/internal/shortcut/chat/chat_conversation.go index 7343dbf9..bd9e971e 100644 --- a/internal/shortcut/chat/chat_conversation.go +++ b/internal/shortcut/chat/chat_conversation.go @@ -62,13 +62,18 @@ var ConversationSetTop = shortcut.Shortcut{ Flags: []shortcut.Flag{ {Name: "conversation-id", Type: shortcut.FlagString, Desc: "单个会话 openConversationId"}, {Name: "conversation-ids", Type: shortcut.FlagStringSlice, Desc: "多个会话 openConversationId(最多 10 个)"}, + {Name: "open-conversation-id", Type: shortcut.FlagString, Desc: "--conversation-id 的兼容别名", Hidden: true}, + {Name: "chat-id", Type: shortcut.FlagString, Desc: "--conversation-id 的兼容别名", Hidden: true}, + {Name: "chat-ids", Type: shortcut.FlagStringSlice, Desc: "--conversation-ids 的兼容别名", Hidden: true}, {Name: "off", Type: shortcut.FlagBool, Desc: "取消置顶(不传则设置置顶)"}, + {Name: "top", Type: shortcut.FlagBool, Default: "true", Desc: "置顶状态兼容参数", Hidden: true}, }, Constraints: []shortcut.Constraint{ - {Kind: shortcut.ConstraintAtLeastOne, Flags: []string{"conversation-id", "conversation-ids"}}, + {Kind: shortcut.ConstraintAtLeastOne, Flags: []string{"conversation-id", "conversation-ids", "open-conversation-id", "chat-id", "chat-ids"}}, + {Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"off", "top"}}, { Kind: shortcut.ConstraintCustom, - Flags: []string{"conversation-id", "conversation-ids"}, + Flags: []string{"conversation-id", "conversation-ids", "open-conversation-id", "chat-id", "chat-ids"}, Description: "会话 ID 去重后必须为 1-10 个", }, }, @@ -85,6 +90,10 @@ var ConversationSetTop = shortcut.Shortcut{ }, Execute: func(rt *shortcut.RuntimeContext) error { ids := conversationSetTopIDs(rt) + top := !rt.Bool("off") + if rt.Changed("top") { + top = rt.Bool("top") + } items := make([]shortcutBatchWrite, 0, len(ids)) for _, id := range ids { items = append(items, shortcutBatchWrite{ @@ -92,7 +101,7 @@ var ConversationSetTop = shortcut.Shortcut{ arguments: map[string]any{ "openConversationId": id, "cid": id, - "top": !rt.Bool("off"), + "top": top, }, }) } @@ -102,8 +111,11 @@ var ConversationSetTop = shortcut.Shortcut{ func conversationSetTopIDs(rt *shortcut.RuntimeContext) []string { values := append([]string{}, rt.StrSlice("conversation-ids")...) - if value := rt.Str("conversation-id"); value != "" { - values = append(values, value) + values = append(values, rt.StrSlice("chat-ids")...) + for _, name := range []string{"conversation-id", "open-conversation-id", "chat-id"} { + if value := rt.Str(name); value != "" { + values = append(values, value) + } } return uniqueShortcutStrings(values) } diff --git a/internal/shortcut/chat/chat_group.go b/internal/shortcut/chat/chat_group.go index eab450f9..0fd7adcb 100644 --- a/internal/shortcut/chat/chat_group.go +++ b/internal/shortcut/chat/chat_group.go @@ -69,15 +69,25 @@ var ChatMembersGet = shortcut.Shortcut{ Intent: "当你已有若干成员的 openDingTalkId、需要批量获取他们在该群内的详情(群昵称、角色等)时使用;只读,需传群 openConversationId 和成员 openDingTalkId 列表。", Risk: shortcut.RiskRead, Flags: []shortcut.Flag{ - {Name: "id", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true}, - {Name: "users", Type: shortcut.FlagStringSlice, Desc: "成员 openDingTalkId 列表", Required: true}, + {Name: "id", Type: shortcut.FlagString, Desc: "群 openConversationId(必填)"}, + {Name: "group", Type: shortcut.FlagString, Desc: "--id 的兼容别名", Hidden: true}, + {Name: "chat-id", Type: shortcut.FlagString, Desc: "--id 的兼容别名", Hidden: true}, + {Name: "conversation-id", Type: shortcut.FlagString, Desc: "--id 的兼容别名", Hidden: true}, + {Name: "open-conversation-id", Type: shortcut.FlagString, Desc: "--id 的兼容别名", Hidden: true}, + {Name: "users", Type: shortcut.FlagStringSlice, Desc: "成员 openDingTalkId 列表(必填)"}, + {Name: "open-dingtalk-ids", Type: shortcut.FlagStringSlice, Desc: "--users 的兼容别名", Hidden: true}, + }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintExactlyOne, Flags: []string{"id", "group", "chat-id", "conversation-id", "open-conversation-id"}}, + {Kind: shortcut.ConstraintExactlyOne, Flags: []string{"users", "open-dingtalk-ids"}}, }, Tips: []string{`dws chat +chat-members-get --id --users odid1,odid2`}, Execute: func(rt *shortcut.RuntimeContext) error { + conversationID := rt.StrFirst("id", "group", "chat-id", "conversation-id", "open-conversation-id") return rt.CallMCP("list_group_member_by_ids", map[string]any{ - "openConversationId": rt.Str("id"), - "cid": rt.Str("id"), - "memberOpenDingTalkIds": rt.StrSlice("users"), + "openConversationId": conversationID, + "cid": conversationID, + "memberOpenDingTalkIds": rt.StrSliceFirst("users", "open-dingtalk-ids"), }) }, } @@ -122,14 +132,22 @@ var ChatInviteURL = shortcut.Shortcut{ Intent: "当你想拿到一条群邀请链接分享给别人加群时使用;只读生成链接,需传群 openConversationId,可用 --expires-seconds 设置有效期(0 表示永久)。", Risk: shortcut.RiskRead, Flags: []shortcut.Flag{ - {Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true}, + {Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId(必填)"}, + {Name: "id", Type: shortcut.FlagString, Desc: "--group 的兼容别名", Hidden: true}, + {Name: "chat-id", Type: shortcut.FlagString, Desc: "--group 的兼容别名", Hidden: true}, + {Name: "conversation-id", Type: shortcut.FlagString, Desc: "--group 的兼容别名", Hidden: true}, + {Name: "open-conversation-id", Type: shortcut.FlagString, Desc: "--group 的兼容别名", Hidden: true}, {Name: "expires-seconds", Type: shortcut.FlagInt, Desc: "链接有效期(秒),0 表示永久"}, }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintExactlyOne, Flags: []string{"group", "id", "chat-id", "conversation-id", "open-conversation-id"}}, + }, Tips: []string{`dws chat +chat-invite-url --group `}, Execute: func(rt *shortcut.RuntimeContext) error { + conversationID := rt.StrFirst("group", "id", "chat-id", "conversation-id", "open-conversation-id") params := map[string]any{ - "openConversationId": rt.Str("group"), - "cid": rt.Str("group"), + "openConversationId": conversationID, + "cid": conversationID, } if rt.Changed("expires-seconds") { params["expiresSeconds"] = rt.Int("expires-seconds") @@ -552,11 +570,20 @@ var ChatBots = shortcut.Shortcut{ Intent: "当你想查看某个群里已添加了哪些机器人时使用;需传群 openConversationId,只读返回群内机器人列表(含 openBotId,供后续移除)。", Risk: shortcut.RiskRead, Flags: []shortcut.Flag{ - {Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true}, + {Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId(必填)"}, + {Name: "id", Type: shortcut.FlagString, Desc: "--group 的兼容别名", Hidden: true}, + {Name: "chat-id", Type: shortcut.FlagString, Desc: "--group 的兼容别名", Hidden: true}, + {Name: "conversation-id", Type: shortcut.FlagString, Desc: "--group 的兼容别名", Hidden: true}, + {Name: "open-conversation-id", Type: shortcut.FlagString, Desc: "--group 的兼容别名", Hidden: true}, + }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintExactlyOne, Flags: []string{"group", "id", "chat-id", "conversation-id", "open-conversation-id"}}, }, Tips: []string{`dws chat +chat-bots --group `}, Execute: func(rt *shortcut.RuntimeContext) error { - data, err := rt.CallMCPData("bot", "list_group_bots", map[string]any{"openConversationId": rt.Str("group")}) + data, err := rt.CallMCPData("bot", "list_group_bots", map[string]any{ + "openConversationId": rt.StrFirst("group", "id", "chat-id", "conversation-id", "open-conversation-id"), + }) if err != nil { return err } diff --git a/internal/shortcut/chat/chat_message.go b/internal/shortcut/chat/chat_message.go index 4ec4aa8e..ea8cdcfa 100644 --- a/internal/shortcut/chat/chat_message.go +++ b/internal/shortcut/chat/chat_message.go @@ -147,14 +147,23 @@ var MessagesRecall = shortcut.Shortcut{ Intent: "当你想撤回当前用户刚发出的某条消息时使用;会实际撤回消息,需传会话 openConversationId 和消息 openMessageId。", Risk: shortcut.RiskWrite, Flags: []shortcut.Flag{ - {Name: "conversation-id", Type: shortcut.FlagString, Desc: "会话 openConversationId", Required: true}, - {Name: "msg-id", Type: shortcut.FlagString, Desc: "消息 openMessageId", Required: true}, + {Name: "conversation-id", Type: shortcut.FlagString, Desc: "会话 openConversationId(必填)"}, + {Name: "group", Type: shortcut.FlagString, Desc: "--conversation-id 的兼容别名", Hidden: true}, + {Name: "chat-id", Type: shortcut.FlagString, Desc: "--conversation-id 的兼容别名", Hidden: true}, + {Name: "open-conversation-id", Type: shortcut.FlagString, Desc: "--conversation-id 的兼容别名", Hidden: true}, + {Name: "msg-id", Type: shortcut.FlagString, Desc: "消息 openMessageId(必填)"}, + {Name: "message-id", Type: shortcut.FlagString, Desc: "--msg-id 的兼容别名", Hidden: true}, + {Name: "open-message-id", Type: shortcut.FlagString, Desc: "--msg-id 的兼容别名", Hidden: true}, + }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintExactlyOne, Flags: []string{"conversation-id", "group", "chat-id", "open-conversation-id"}}, + {Kind: shortcut.ConstraintExactlyOne, Flags: []string{"msg-id", "message-id", "open-message-id"}}, }, Tips: []string{`dws chat +messages-recall --conversation-id --msg-id `}, Execute: func(rt *shortcut.RuntimeContext) error { return rt.CallMCP("recall_message", map[string]any{ - "openConversationId": rt.Str("conversation-id"), - "openMessageId": rt.Str("msg-id"), + "openConversationId": rt.StrFirst("conversation-id", "group", "chat-id", "open-conversation-id"), + "openMessageId": rt.StrFirst("msg-id", "message-id", "open-message-id"), }) }, } @@ -529,26 +538,30 @@ var MessagesMget = shortcut.Shortcut{ Intent: "当你已有一批消息 openMsgId、需要批量取回完整详情、reaction 和可执行资源引用时使用;一次最多 50 条。--download-resources 可把所有可识别 mediaId/fileId 安全下载到工作目录内,并逐资源返回成功/失败 ledger;本地下载路径受限于工作目录、默认不覆盖同名文件,按既有安全下载约定无需交互确认。", Risk: shortcut.RiskRead, Flags: append([]shortcut.Flag{ - {Name: "msg-ids", Type: shortcut.FlagStringSlice, Desc: "消息 openMsgId 列表;--msg-ids 去重后必须包含 1-50 条消息 ID", Required: true}, + {Name: "msg-ids", Type: shortcut.FlagStringSlice, Desc: "消息 openMsgId 列表;--msg-ids 去重后必须包含 1-50 条消息 ID(必填)"}, + {Name: "message-id", Type: shortcut.FlagStringSlice, Desc: "--msg-ids 的兼容别名", Hidden: true}, + {Name: "message-ids", Type: shortcut.FlagStringSlice, Desc: "--msg-ids 的兼容别名", Hidden: true}, + {Name: "open-message-ids", Type: shortcut.FlagStringSlice, Desc: "--msg-ids 的兼容别名", Hidden: true}, {Name: "no-reactions", Type: shortcut.FlagBool, Desc: "不输出消息 reaction(默认输出)"}, }, MessageResourceDownloadFlags()...), Constraints: append([]shortcut.Constraint{ + {Kind: shortcut.ConstraintExactlyOne, Flags: []string{"msg-ids", "message-id", "message-ids", "open-message-ids"}}, { Kind: shortcut.ConstraintCustom, - Flags: []string{"msg-ids"}, + Flags: []string{"msg-ids", "message-id", "message-ids", "open-message-ids"}, Description: "--msg-ids 去重后必须包含 1-50 条消息 ID", }, }, MessageResourceDownloadConstraints()...), Tips: []string{`dws chat +messages-mget --msg-ids msgId1,msgId2`}, Validate: func(rt *shortcut.RuntimeContext) error { - ids := uniqueShortcutStrings(rt.StrSlice("msg-ids")) + ids := messageMgetIDs(rt) if len(ids) < 1 || len(ids) > 50 { return fmt.Errorf("--msg-ids 去重后必须包含 1-50 条消息 ID,当前 %d 条", len(ids)) } return ValidateMessageResourceDownload(rt) }, Execute: func(rt *shortcut.RuntimeContext) error { - ids := uniqueShortcutStrings(rt.StrSlice("msg-ids")) + ids := messageMgetIDs(rt) data, err := rt.CallMCPData("im", "list_messages_by_ids", map[string]any{"openMsgIds": ids}) if err != nil { return err @@ -581,6 +594,10 @@ var MessagesMget = shortcut.Shortcut{ }, } +func messageMgetIDs(rt *shortcut.RuntimeContext) []string { + return uniqueShortcutStrings(rt.StrSliceFirst("msg-ids", "message-id", "message-ids", "open-message-ids")) +} + // MessageResourceDownloadFlags returns the common opt-in resource workflow used // by message list, search, mget, @me and thread-reading Shortcuts. func MessageResourceDownloadFlags() []shortcut.Flag { @@ -833,11 +850,15 @@ var MessagesQuerySendStatus = shortcut.Shortcut{ Intent: "当你发消息后拿到 openTaskId、想确认这条消息是否发送成功时使用;只读返回发送状态,需传 --open-task-id。", Risk: shortcut.RiskRead, Flags: []shortcut.Flag{ - {Name: "open-task-id", Type: shortcut.FlagString, Desc: "发送消息时返回的 openTaskId", Required: true}, + {Name: "open-task-id", Type: shortcut.FlagString, Desc: "发送消息时返回的 openTaskId(必填)"}, + {Name: "task-id", Type: shortcut.FlagString, Desc: "--open-task-id 的兼容别名", Hidden: true}, + }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintExactlyOne, Flags: []string{"open-task-id", "task-id"}}, }, Tips: []string{`dws chat +messages-query-send-status --open-task-id `}, Execute: func(rt *shortcut.RuntimeContext) error { - return rt.CallMCP("query_message_send_status", map[string]any{"openTaskId": rt.Str("open-task-id")}) + return rt.CallMCP("query_message_send_status", map[string]any{"openTaskId": rt.StrFirst("open-task-id", "task-id")}) }, } diff --git a/internal/shortcut/chat/compatibility_coverage_test.go b/internal/shortcut/chat/compatibility_coverage_test.go index e7d1d057..faa1131a 100644 --- a/internal/shortcut/chat/compatibility_coverage_test.go +++ b/internal/shortcut/chat/compatibility_coverage_test.go @@ -129,6 +129,72 @@ func TestCrossPlatformCoverageCompatibilityAliases(t *testing.T) { wantTool: "query_msg_read_status", wantArgs: map[string]any{"openConversationId": "cid-1"}, }, + { + name: "chat update id and title aliases", + argv: []string{"chat", "+chat-update", "--open-conversation-id", "cid-1", "--title", "新群名", "--yes"}, + wantProduct: "chat", + wantTool: "update_group_name", + wantArgs: map[string]any{"openconversation_id": "cid-1", "group_name": "新群名"}, + }, + { + name: "member get id and users aliases", + argv: []string{"chat", "+chat-members-get", "--conversation-id", "cid-1", "--open-dingtalk-ids", "D1,D2"}, + wantProduct: "im", + wantTool: "list_group_member_by_ids", + wantArgs: map[string]any{ + "openConversationId": "cid-1", + "memberOpenDingTalkIds": []string{"D1", "D2"}, + }, + }, + { + name: "invite url id alias", + argv: []string{"chat", "+chat-invite-url", "--chat-id", "cid-1"}, + wantProduct: "im", + wantTool: "get_group_invite_url", + wantArgs: map[string]any{"openConversationId": "cid-1"}, + }, + { + name: "chat bots id alias", + argv: []string{"chat", "+chat-bots", "--id", "cid-1"}, + wantProduct: "bot", + wantTool: "list_group_bots", + wantArgs: map[string]any{"openConversationId": "cid-1"}, + }, + { + name: "recall message aliases", + argv: []string{"chat", "+messages-recall", "--chat-id", "cid-1", "--message-id", "msg-1", "--yes"}, + wantProduct: "im", + wantTool: "recall_message", + wantArgs: map[string]any{"openConversationId": "cid-1", "openMessageId": "msg-1"}, + }, + { + name: "message mget message id alias", + argv: []string{"chat", "+messages-mget", "--message-id", "msg-1,msg-2"}, + wantProduct: "im", + wantTool: "list_messages_by_ids", + wantArgs: map[string]any{"openMsgIds": []string{"msg-1", "msg-2"}}, + }, + { + name: "send status task id alias", + argv: []string{"chat", "+messages-query-send-status", "--task-id", "task-1"}, + wantProduct: "im", + wantTool: "query_message_send_status", + wantArgs: map[string]any{"openTaskId": "task-1"}, + }, + { + name: "conversation top aliases", + argv: []string{"chat", "+conversation-set-top", "--open-conversation-id", "cid-1", "--top=false", "--yes"}, + wantProduct: "im", + wantTool: "set_top_conversation", + wantArgs: map[string]any{"openConversationId": "cid-1", "top": false}, + }, + { + name: "favorite list limit alias", + argv: []string{"chat", "+flag-list", "--limit", "7"}, + wantProduct: "im", + wantTool: "list_message_favorites", + wantArgs: map[string]any{"size": "7"}, + }, { name: "at all mute matches live MCP schema", argv: []string{"chat", "+conversation-mute-at-all", "--conversation-id", "cid-1", "--yes"}, @@ -190,7 +256,7 @@ func TestCrossPlatformCoverageCompatibilityAliases(t *testing.T) { t.Fatalf("call = %s/%s, want %s/%s", fake.product, fake.tool, tc.wantProduct, tc.wantTool) } for key, want := range tc.wantArgs { - if got := fake.args[key]; got != want { + if got := fake.args[key]; !reflect.DeepEqual(got, want) { t.Errorf("%s = %#v, want %#v", key, got, want) } } diff --git a/internal/shortcut/chat/lark_alignment.go b/internal/shortcut/chat/lark_alignment.go index f7679d22..a82c33ae 100644 --- a/internal/shortcut/chat/lark_alignment.go +++ b/internal/shortcut/chat/lark_alignment.go @@ -119,14 +119,24 @@ var ChatUpdate = shortcut.Shortcut{ Intent: "当你只需要修改群名称时使用;这是 lark-cli +chat-update 的诚实子集,只接受群 openConversationId 和新名称。修改群 description、个人备注、群昵称或其他群设置时不要使用。", Risk: shortcut.RiskWrite, Flags: []shortcut.Flag{ - {Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true}, - {Name: "name", Type: shortcut.FlagString, Desc: "新的群名称", Required: true}, + {Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId(必填)"}, + {Name: "id", Type: shortcut.FlagString, Desc: "--group 的兼容别名", Hidden: true}, + {Name: "chat-id", Type: shortcut.FlagString, Desc: "--group 的兼容别名", Hidden: true}, + {Name: "conversation-id", Type: shortcut.FlagString, Desc: "--group 的兼容别名", Hidden: true}, + {Name: "open-conversation-id", Type: shortcut.FlagString, Desc: "--group 的兼容别名", Hidden: true}, + {Name: "name", Type: shortcut.FlagString, Desc: "新的群名称(必填)"}, + {Name: "title", Type: shortcut.FlagString, Desc: "--name 的兼容别名", Hidden: true}, + {Name: "new-title", Type: shortcut.FlagString, Desc: "--name 的兼容别名", Hidden: true}, + }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintExactlyOne, Flags: []string{"group", "id", "chat-id", "conversation-id", "open-conversation-id"}}, + {Kind: shortcut.ConstraintExactlyOne, Flags: []string{"name", "title", "new-title"}}, }, Tips: []string{`dws chat +chat-update --group --name "新群名"`}, Execute: func(rt *shortcut.RuntimeContext) error { return rt.CallMCP("update_group_name", map[string]any{ - "openconversation_id": rt.Str("group"), - "group_name": rt.Str("name"), + "openconversation_id": rt.StrFirst("group", "id", "chat-id", "conversation-id", "open-conversation-id"), + "group_name": rt.StrFirst("name", "title", "new-title"), }) }, } @@ -391,6 +401,8 @@ var FlagList = shortcut.Shortcut{ Flags: []shortcut.Flag{ {Name: "cursor", Type: shortcut.FlagInt, Default: "0", Desc: "数字分页游标,首次传 0"}, {Name: "size", Type: shortcut.FlagInt, Default: "20", Desc: "每页数量,范围 1-100"}, + {Name: "limit", Type: shortcut.FlagInt, Desc: "--size 的兼容别名", Hidden: true}, + {Name: "max", Type: shortcut.FlagInt, Desc: "--size 的兼容别名", Hidden: true}, }, Constraints: []shortcut.Constraint{{ Kind: shortcut.ConstraintCustom, @@ -402,7 +414,7 @@ var FlagList = shortcut.Shortcut{ if rt.Int("cursor") < 0 { return apperrors.NewValidation("--cursor 必须大于等于 0") } - if size := rt.Int("size"); size < 1 || size > 100 { + if size := rt.IntFirst("size", "limit", "max"); size < 1 || size > 100 { return apperrors.NewValidation("--size 必须在 1-100 之间") } return nil @@ -410,7 +422,7 @@ var FlagList = shortcut.Shortcut{ Execute: func(rt *shortcut.RuntimeContext) error { return rt.CallMCP("list_message_favorites", map[string]any{ "cursor": rt.Int("cursor"), - "size": strconv.Itoa(rt.Int("size")), + "size": strconv.Itoa(rt.IntFirst("size", "limit", "max")), }) }, } diff --git a/internal/shortcut/runner.go b/internal/shortcut/runner.go index 30fd411a..bd35dde5 100644 --- a/internal/shortcut/runner.go +++ b/internal/shortcut/runner.go @@ -96,6 +96,17 @@ func (rt *RuntimeContext) StrSlice(name string) []string { return v } +// StrSliceFirst returns the first non-empty string-slice value across a +// primary flag and its compatibility aliases. +func (rt *RuntimeContext) StrSliceFirst(names ...string) []string { + for _, name := range names { + if v := rt.StrSlice(name); hasNonEmptyString(v) { + return v + } + } + return nil +} + // Changed reports whether the user explicitly set the flag on the command line. func (rt *RuntimeContext) Changed(name string) bool { f := rt.cmd.Flags().Lookup(name) @@ -358,9 +369,27 @@ func shortcutLongHelp(s Shortcut) string { if len(s.Constraints) == 0 { return long } + publicFlags := make(map[string]bool, len(s.Flags)) + for _, flag := range s.Flags { + publicFlags[flag.Name] = !flag.Hidden + } lines := make([]string, 0, len(s.Constraints)) for _, constraint := range s.Constraints { - lines = append(lines, " - "+constraintHelp(constraint)) + visible := constraint + visible.Flags = make([]string, 0, len(constraint.Flags)) + for _, flag := range constraint.Flags { + if publicFlags[flag] { + visible.Flags = append(visible.Flags, flag) + } + } + if len(visible.Flags) == 0 || + (visible.Kind != ConstraintCustom && len(visible.Flags) < 2) { + continue + } + lines = append(lines, " - "+constraintHelp(visible)) + } + if len(lines) == 0 { + return long } return long + "\n\n参数约束:\n" + strings.Join(lines, "\n") } diff --git a/internal/shortcut/shortcut_test.go b/internal/shortcut/shortcut_test.go index 4ddd607d..e4d4355e 100644 --- a/internal/shortcut/shortcut_test.go +++ b/internal/shortcut/shortcut_test.go @@ -16,6 +16,7 @@ package shortcut import ( "bytes" "os" + "reflect" "strings" "testing" @@ -137,6 +138,7 @@ func TestCrossPlatformCoverageSchemaConstraintCollapsesHiddenAliases(t *testing. cmd := mount(Shortcut{ Service: "chat", Command: "+search", + Intent: "搜索消息", Flags: []Flag{ {Name: "query", Type: FlagString}, {Name: "keyword", Type: FlagString, Hidden: true}, @@ -165,6 +167,11 @@ func TestCrossPlatformCoverageSchemaConstraintCollapsesHiddenAliases(t *testing. if raw := cmd.Annotations["dws.schema.constraints"]; raw != "" { t.Fatalf("hidden compatibility alias leaked into public Schema constraints: %q", raw) } + for _, hidden := range []string{"--keyword", "--legacy-id"} { + if strings.Contains(cmd.Long, hidden) { + t.Fatalf("hidden compatibility alias leaked into long help: %q", cmd.Long) + } + } } func TestCrossPlatformCoverageAliasAndAIMessageTagHelpers(t *testing.T) { @@ -179,6 +186,8 @@ func TestCrossPlatformCoverageAliasAndAIMessageTagHelpers(t *testing.T) { Flags: []Flag{ {Name: "query", Type: FlagString}, {Name: "keyword", Type: FlagString}, + {Name: "ids", Type: FlagStringSlice}, + {Name: "legacy-ids", Type: FlagStringSlice}, {Name: "limit", Type: FlagInt, Default: "20"}, {Name: "size", Type: FlagInt}, aiTag, @@ -193,6 +202,12 @@ func TestCrossPlatformCoverageAliasAndAIMessageTagHelpers(t *testing.T) { if got := rt.StrFirst("query", "keyword"); got != "树莓派" { t.Fatalf("StrFirst() = %q, want 树莓派", got) } + if err := cmd.Flags().Set("legacy-ids", "a,b"); err != nil { + t.Fatal(err) + } + if got := rt.StrSliceFirst("ids", "legacy-ids"); !reflect.DeepEqual(got, []string{"a", "b"}) { + t.Fatalf("StrSliceFirst() = %#v, want [a b]", got) + } if got := rt.IntFirst("limit", "size"); got != 20 { t.Fatalf("IntFirst() default = %d, want 20", got) } diff --git a/internal/shortcut/smart/compatibility_coverage_test.go b/internal/shortcut/smart/compatibility_coverage_test.go index 4bfd3fb5..32e90d7a 100644 --- a/internal/shortcut/smart/compatibility_coverage_test.go +++ b/internal/shortcut/smart/compatibility_coverage_test.go @@ -218,6 +218,20 @@ func TestCrossPlatformCoverageCompatibilityAliases(t *testing.T) { wantTool: "search_messages", wantArgs: map[string]any{"openConversationIds": []string{"cid-1"}, "keyword": "树莓派"}, }, + { + name: "dm name alias", + argv: []string{"chat", "+dm", "--name", "张三", "--text", "你好", "--yes"}, + wantProduct: "chat", + wantTool: "send_personal_message", + wantArgs: map[string]any{"receiverOpenDingTalkId": "open1"}, + }, + { + name: "chat members list id alias", + argv: []string{"chat", "+chat-members-list", "--chat-id", "cid-1", "--member-types", "user"}, + wantProduct: "chat", + wantTool: "get_group_members", + wantArgs: map[string]any{"openconversation_id": "cid-1"}, + }, } for _, tc := range tests { diff --git a/internal/shortcut/smart/dm.go b/internal/shortcut/smart/dm.go index f456afdd..0522f4ad 100644 --- a/internal/shortcut/smart/dm.go +++ b/internal/shortcut/smart/dm.go @@ -40,16 +40,21 @@ var DM = shortcut.Shortcut{ "内部先按姓名搜通讯录解析出唯一用户,并用其 openDingTalkId 发送,姓名匹配到多人时会列出候选让你区分。会真实发出消息。", Risk: shortcut.RiskWrite, Flags: []shortcut.Flag{ - {Name: "to", Type: shortcut.FlagString, Desc: "收件人姓名/花名", Required: true}, + {Name: "to", Type: shortcut.FlagString, Desc: "收件人姓名/花名(必填)"}, + {Name: "name", Type: shortcut.FlagString, Desc: "--to 的兼容别名", Hidden: true}, + {Name: "keyword", Type: shortcut.FlagString, Desc: "--to 的兼容别名", Hidden: true}, {Name: "text", Type: shortcut.FlagString, Desc: "消息内容(支持 Markdown)", Required: true}, shortcut.AIMessageTagFlag(), }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintExactlyOne, Flags: []string{"to", "name", "keyword"}}, + }, Tips: []string{`dws chat +dm --to 张三 --text "周报发我一下"`}, Execute: func(rt *shortcut.RuntimeContext) error { text := rt.Str("text") // Step 1 — resolve the recipient name to a unique userId. - user, err := resolveOpenDingTalkUser(rt, rt.Str("to")) + user, err := resolveOpenDingTalkUser(rt, rt.StrFirst("to", "name", "keyword")) if err != nil { return err } diff --git a/internal/shortcut/smart/group_members.go b/internal/shortcut/smart/group_members.go index 1232086c..d4d8fdfd 100644 --- a/internal/shortcut/smart/group_members.go +++ b/internal/shortcut/smart/group_members.go @@ -109,17 +109,20 @@ var ChatMembersList = shortcut.Shortcut{ Flags: []shortcut.Flag{ {Name: "group", Type: shortcut.FlagString, Desc: "群名称(与 --conversation-id 二选一)"}, {Name: "conversation-id", Type: shortcut.FlagString, Desc: "群 openConversationId(与 --group 二选一)"}, + {Name: "id", Type: shortcut.FlagString, Desc: "--conversation-id 的兼容别名", Hidden: true}, + {Name: "chat-id", Type: shortcut.FlagString, Desc: "--conversation-id 的兼容别名", Hidden: true}, + {Name: "open-conversation-id", Type: shortcut.FlagString, Desc: "--conversation-id 的兼容别名", Hidden: true}, {Name: "member-types", Type: shortcut.FlagStringSlice, Desc: "成员类型:user,bot;不传则同时返回"}, }, Constraints: []shortcut.Constraint{ - {Kind: shortcut.ConstraintExactlyOne, Flags: []string{"group", "conversation-id"}}, + {Kind: shortcut.ConstraintExactlyOne, Flags: []string{"group", "conversation-id", "id", "chat-id", "open-conversation-id"}}, }, Tips: []string{ `dws chat +chat-members-list --group "项目冲刺"`, `dws chat +chat-members-list --conversation-id --member-types user,bot`, }, Execute: func(rt *shortcut.RuntimeContext) error { - groupID := strings.TrimSpace(rt.Str("conversation-id")) + groupID := strings.TrimSpace(rt.StrFirst("conversation-id", "id", "chat-id", "open-conversation-id")) groupName := strings.TrimSpace(rt.Str("group")) if groupID == "" { resolved, err := resolveGroupName(rt, groupName) diff --git a/scripts/gen_skill_shortcut_sections.py b/scripts/gen_skill_shortcut_sections.py index 2ce7866f..fe174081 100644 --- a/scripts/gen_skill_shortcut_sections.py +++ b/scripts/gen_skill_shortcut_sections.py @@ -50,8 +50,9 @@ PRODUCT_START = "" PRODUCT_END = "" # Large, high-frequency product skills should route known intents directly and -# keep their full shortcut inventory in Runtime Catalog/Schema. Add services -# here only after verifying that the product skill has its own reviewed routing +# keep their full shortcut inventory in the Runtime Shortcut Catalog. Curated +# entries may additionally be queried through leaf Schema. Add services here +# only after verifying that the product skill has its own reviewed routing # section and intent table; compacting a sparse skill without an alternative # route would make its shortcuts harder to discover. COMPACT_PRODUCT_SERVICES = {"chat"} @@ -137,7 +138,7 @@ def compact_product_section(service: str, rows: list[dict[str, Any]]) -> str: return f"""{PRODUCT_START} ## Shortcut 发现(按需) -`{md_escape(service)}` 当前有 {len(rows)} 条公开 shortcut,完整清单保留在 Runtime Catalog 与 Schema,不在高频产品根 Skill 中重复展开。已知意图直接使用下方的优先路由、意图表或任务 reference;命令已选中时直接执行,只在参数/安全语义不确定时读取 leaf Schema,在当前 Cobra flags 不确定时读取 leaf Help。 +`{md_escape(service)}` 当前有 {len(rows)} 条公开 shortcut。完整清单保留在 Runtime Shortcut Catalog;已完成 Schema curation 的子集可通过 leaf Schema 查询。高频产品根 Skill 不重复展开完整清单。已知意图直接使用下方的优先路由、意图表或任务 reference;命令已选中时直接执行,只在参数/安全语义不确定时读取 leaf Schema,在当前 Cobra flags 不确定时读取 leaf Help。 仅当现有路由和 reference 都无法定位低频能力时,才执行 `dws shortcut list --service {md_escape(service)} --compact --format json` 做最后回退;不要为已知高频意图加载完整 Shortcut Catalog 或产品级 Schema。 {PRODUCT_END}""" diff --git a/skills/multi/dingtalk-chat/SKILL.md b/skills/multi/dingtalk-chat/SKILL.md index 1b17b165..9677dcd5 100644 --- a/skills/multi/dingtalk-chat/SKILL.md +++ b/skills/multi/dingtalk-chat/SKILL.md @@ -20,7 +20,7 @@ metadata: ## Shortcut 发现(按需) -`chat` 当前有 97 条公开 shortcut,完整清单保留在 Runtime Catalog 与 Schema,不在高频产品根 Skill 中重复展开。已知意图直接使用下方的优先路由、意图表或任务 reference;命令已选中时直接执行,只在参数/安全语义不确定时读取 leaf Schema,在当前 Cobra flags 不确定时读取 leaf Help。 +`chat` 当前有 97 条公开 shortcut。完整清单保留在 Runtime Shortcut Catalog;已完成 Schema curation 的子集可通过 leaf Schema 查询。高频产品根 Skill 不重复展开完整清单。已知意图直接使用下方的优先路由、意图表或任务 reference;命令已选中时直接执行,只在参数/安全语义不确定时读取 leaf Schema,在当前 Cobra flags 不确定时读取 leaf Help。 仅当现有路由和 reference 都无法定位低频能力时,才执行 `dws shortcut list --service chat --compact --format json` 做最后回退;不要为已知高频意图加载完整 Shortcut Catalog 或产品级 Schema。 @@ -37,24 +37,25 @@ Skip a script if its interpreter (for example, `python3`) is unavailable. reviewed route matches. - Once selected, execute directly. Read leaf Schema only when parameters, constraints, or safety are uncertain; read leaf `--help` only when flags are - uncertain. If absent from Schema, use the same path in the full Catalog. + uncertain. If absent from Schema, use the same path in the Runtime Shortcut + Catalog. - For `confirmation=user_required`, confirm before adding `--yes`. On source conflict, use the safer interpretation and report it. -### 高频选择 +### 高频直接执行骨架 -| 用户意图 | 首选 Shortcut | 关键边界 | -|---|---|---| -| 当前用户 / 应用机器人 / Webhook 发消息 | `+messages-send --as user|bot|webhook` | Never mix identities | -| 按姓名发单聊 / 按群名发群消息 | `+dm` / `+send-to-group` | Resolves real person/chat IDs | -| 拉单个群聊或单聊消息 | `+chat-messages` | Choose one of `--group`, `--user`, `--open-dingtalk-id` | -| 组合搜索消息 | `+search-msg` | Keyword, sender, @, chat, type, time, `--page-all` | -| 查询 @ 我的消息 | `+at-me` | Defaults to 7 days; window and pagination are adjustable | -| 按消息 ID 批量取详情与 reaction | `+messages-mget` | Max 50; supports `--no-reactions` | -| 读取已知 thread/topic 的全部回复 | `+thread-replies` | `--group` required; choose thread or topic ID | -| 下载单个 mediaId/fileId | `+messages-resource-download` | mediaId needs message/chat context; fileId does not | -| 创建并按需立即更新流式卡片 | `+messages-send-card` | Choose one target; `--content` controls immediate update | -| 清空会话消息 / 删除分组 / 解散群 | `+conversation-clear-messages` / `+category-delete` / `+chat-dismiss` | 高风险; add `--yes` only after Schema confirmation | +命中后照抄参数名,不先调用 `--help`。 + +| 用户意图 | 精确 Shortcut 骨架 | +|---|---| +| 姓名发单聊 / 群名发群消息 | `+dm --to <姓名> --text <内容>` / `+send-to-group --group <群名> --text <内容>` | +| 三种身份发消息 | `+messages-send --as user|bot|webhook`;按下方身份模板补参数 | +| 改群名 | `+chat-update --group --name <新群名>` | +| 列成员 / 批查成员 | `+chat-members-list --group <群名>` 或 `--conversation-id ` / `+chat-members-get --id --users ` | +| 消息详情 / 撤回 / 发送状态 | `+messages-mget --msg-ids ` / `+messages-recall --conversation-id --msg-id ` / `+messages-query-send-status --open-task-id ` | +| 查 @ 我的消息 | 全局用 `+at-me --days `;限定群用 `+search-msg --at-me --group `,不要给 `+at-me` 猜群参数 | +| 群邀请链接 / 群机器人 | 先 `+chat-search --query <群名>` 取 cid,再 `+chat-invite-url --group ` / `+chat-bots --group ` | +| 会话置顶 / 收藏列表 | `+conversation-set-top --conversation-id [--off]` / `+flag-list --size <1-100>` | ### 统一发送 @@ -132,66 +133,29 @@ Load branch details on demand: [消息](references/chat/chat-message.md), [机器人与 Webhook](references/chat/chat-bot.md), and [会话状态与分组](references/chat/chat-conversation.md). -## 核心意图表 +## 核心意图与执行边界 Use this table to disambiguate identity, chat type, and operation. Prefer public -Shortcuts; otherwise use atomic commands. Every Chat workflow must work without -Python. +Shortcuts; otherwise use the atomic fallback. Apply the shared `--format json` +rule and take every downstream ID from actual output. Every Chat workflow +must work without Python. -| 用户说 | 分支 / 原子回退 | -|---|---| -| “发给某人” | Current-user DM: resolve `openDingTalkId` / `userId`, then `message send` | -| “发到某群” | Current-user group message: `chat search`, then `message send --group` | -| “用应用机器人发” | Use `message send-by-bot`; never impersonate the current user | -| “Webhook 推送” | Use `message send-by-webhook`; mention text and @ flags must agree | -| “拉某个会话的消息” | Use `message list`; locate the chat and set time bounds first | -| “搜消息关键词 / 组合搜索” | Use `message search` for keywords; `search-advanced` for sender/@/multi-chat filters | -| “撤回用户消息 / 机器人消息” | Use `message recall` / `recall-by-bot`; their message IDs differ | -| “群消息翻页导出” | Use `+chat-messages`; follow pagination and save merged JSON if requested | -| “查和某人的聊天记录” | Resolve the person, then use `+chat-messages` with one user ID | -| “机器人多群广播” | Confirm once, then call `+messages-send-by-bot` per group | +| 用户说 | 首选路由 / 原子回退 | 必须保留的执行边界 | +|---|---|---| +| “发给某人” | `+dm --to <姓名> --text <内容>` / `message send --open-dingtalk-id` | Resolve one real person; never pass a name as an ID | +| “发到某群” | `+send-to-group --group <群名> --text <内容>` / `chat search` → `message send --group` | Resolve one real cid; mentions/`@all` use `+messages-send` with that cid | +| “用应用机器人发” | `+messages-send --as bot` / `message send-by-bot` | Never impersonate the current user; keep robot and target IDs from real output | +| “Webhook 推送” | `+messages-send --as webhook` / `message send-by-webhook` | Webhook identity is separate; mention text and `--at-*` flags must agree | +| “建群 / 拉人进群” | `+chat-create --name <群名> --users ` / `group members add` | Resolve every member to a real `userId`; extract the new cid before follow-up actions | +| “拉某个会话的消息” | `+chat-messages` / `message list` | Choose one group or DM target and use the user's time range or an explicitly narrowed boundary | +| “搜消息关键词 / 组合搜索” | `+search-msg --query <关键词>`;群内 @我用 `--at-me --group ` | Add only real filters; use `--page-all` only when complete pagination is needed | +| “撤回用户消息 / 机器人消息” | `+messages-recall --conversation-id --msg-id ` / `recall-by-bot` | Only on explicit recall; IDs must come from the same identity and conversation | +| “群消息翻页导出” | `+chat-messages`; save merged JSON if requested | Follow pagination to completion and report partial results instead of claiming a complete export | +| “查和某人的聊天记录” | Resolve the person, then use `+chat-messages` with one user ID | Stop for ambiguous people; do not merge different users with the same name | +| “机器人多群广播” | Call `+messages-send-by-bot` once per resolved group | Confirm the recipient set once, preserve one message body, and return a per-group success/failure ledger | Detailed native CLI loops are in [01-messaging.md](references/01-messaging.md). -## 关键 SOP - -These SOPs constrain identity, IDs, time bounds, and success checks without -overriding Shortcut priority. Follow a matching leaf; otherwise use the atomic -commands below. Add `--format json` to structured calls and take downstream -IDs from actual output. - -### 发消息 - -1. Resolve a person with - `dws aisearch person --keyword "<姓名>" --dimension name`; prefer - `openDingTalkId`, otherwise `userId`. Resolve a chat with - `dws chat search --query "<群名>"` to get `openConversationId`. -2. Without a matching Shortcut, use `message send --open-dingtalk-id` - (`--user` if needed) for DMs and `message send --group` for groups. Local - files/audio/video use `--msg-type file|audio|video --file-path`. -3. Judge success from structured output. `openTaskId` queries send status; it - is not the message ID required for recall. - -### 建群或拉人 - -1. Resolve every member to a real `userId` with `aisearch person`; never pass - names as member arguments. -2. Without a matching Shortcut, create with - `group create --name "<群名>" --users ` or add members with - `group members add --id --users `. -3. Extract `openConversationId` from creation output. Confirm ambiguous chat - names or members. - -### 拉取或撤回消息 - -1. Resolve a group with `chat search`; resolve a DM target to `userId` or - `openDingTalkId`. -2. Without a matching Shortcut, run `message list` only with the user's time - range or an explicitly narrowed boundary. -3. Only on an explicit recall request, take the real `openMessageId` from the - message list, then call - `message recall --conversation-id --msg-id `. - ## 低频操作原子回退入口 Use this table only to locate atomic fallbacks when no public Shortcut matches. diff --git a/test/scripts/chat_skill_scripts_test.py b/test/scripts/chat_skill_scripts_test.py index 762ec5e8..1d32a197 100644 --- a/test/scripts/chat_skill_scripts_test.py +++ b/test/scripts/chat_skill_scripts_test.py @@ -46,10 +46,45 @@ class ChatSkillScriptPathTest(unittest.TestCase): chat_rows = [ row for row in catalog['results'] if row.get('service') == 'chat' ] + schema_catalog = json.loads( + ( + ROOT + / 'internal' + / 'cli' + / 'schema_catalog' + / 'tools' + / 'chat.json' + ).read_text(encoding='utf-8') + ) + schema_shortcuts = [ + path + for path in schema_catalog['tools'] + if path.startswith('chat.shortcut_') + ] + exclusions = json.loads( + ( + ROOT / 'internal' / 'cli' / 'schema_command_exclusions.json' + ).read_text(encoding='utf-8') + ) + pending_shortcuts = next( + group['commands'] + for group in exclusions['groups'] + if group['id'] == 'chat-shortcuts-pending-schema-curation' + ) self.assertEqual(97, len(chat_rows)) + self.assertEqual(47, len(schema_shortcuts)) + self.assertEqual(50, len(pending_shortcuts)) + self.assertEqual( + len(chat_rows), len(schema_shortcuts) + len(pending_shortcuts) + ) self.assertIn('## Shortcut 发现(按需)', block) self.assertIn('`chat` 当前有 97 条公开 shortcut', block) - self.assertIn('Runtime Catalog 与 Schema', block) + self.assertIn( + '完整清单保留在 Runtime Shortcut Catalog;已完成 Schema curation ' + '的子集可通过 leaf Schema 查询。', + block, + ) + self.assertNotIn('Runtime Catalog 与 Schema', block) self.assertIn( 'dws shortcut list --service chat --compact --format json', block,