diff --git a/docs/shortcut-public-catalog.json b/docs/shortcut-public-catalog.json index cc79f404..1e139225 100644 --- a/docs/shortcut-public-catalog.json +++ b/docs/shortcut-public-catalog.json @@ -1,6 +1,6 @@ { - "generated_at": "2026-07-15T16:58:39.143193", - "count": 210, + "generated_at": "2026-07-29T00:06:19.285348", + "count": 265, "results": [ { "suite": "read", @@ -479,298 +479,974 @@ "status": "real-ok" }, { - "suite": "read", + "suite": "semantic", "service": "chat", "command": "+at-me", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "自动构造时间窗,分页拉取跨会话 @我 消息,并保留身份、引用、reaction、resourceRefs 与完整性。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "chat", "command": "+bot-find", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "仅绑定并投影全部可用机器人搜索;底层 leaf 已接受关键词和分页参数。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "chat", "command": "+bot-search", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "仅绑定并投影当前用户创建的机器人搜索;无跨工具编排,默认由 bot Schema leaf 承担。", + "availability": "available" }, { - "suite": "write", + "suite": "semantic", "service": "chat", "command": "+broadcast", "risk": "write", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "按多人姓名逐一解析目标、拒绝歧义、生成可执行 dry-run 计划并隔离单人发送失败。", + "availability": "available" }, { - "suite": "write", + "suite": "semantic", + "service": "chat", + "command": "+category-add-conversation", + "risk": "write", + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "将会话加入分组是一对一关系写入;默认由 leaf 承担。", + "availability": "available" + }, + { + "suite": "semantic", "service": "chat", "command": "+category-create", "risk": "write", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "创建会话分组是一对一写入;默认由 Runtime Schema leaf 承担。", + "availability": "available" }, { - "suite": "write", + "suite": "semantic", "service": "chat", "command": "+category-delete", - "risk": "write", - "status": "real-ok" + "risk": "high-risk-write", + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "删除会话分组是一对一高风险写入;应直接使用带删除确认的 leaf。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "chat", "command": "+category-list", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "会话分组列表只做字段投影;底层 leaf 已可直接读取。", + "availability": "available" }, { - "suite": "write", + "suite": "semantic", + "service": "chat", + "command": "+category-list-conversations", + "risk": "read", + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "按分组列会话是一对一分页读取;无跨工具编排。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "chat", + "command": "+category-remove-conversation", + "risk": "write", + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "将会话移出分组是一对一关系写入;默认由 leaf 承担。", + "availability": "available" + }, + { + "suite": "semantic", "service": "chat", "command": "+category-rename", "risk": "write", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "重命名会话分组是一对一写入;无额外语义增益。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", + "service": "chat", + "command": "+chat-add-bot", + "risk": "write", + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "机器人入群是一对一治理写入;尚无按名称解析机器人编排。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "chat", + "command": "+chat-audit-join", + "risk": "write", + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "审批单条入群申请是一对一治理写入;应直接使用带动作枚举的 leaf。", + "availability": "available" + }, + { + "suite": "semantic", "service": "chat", "command": "+chat-bots", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "群机器人列表只做字段投影;完整成员视图使用 +chat-members-list。", + "availability": "available" }, { - "suite": "write", + "suite": "semantic", + "service": "chat", + "command": "+chat-create", + "risk": "write", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "自动解析当前用户并加入初始成员以满足群主约束,规范化创建结果,同时明确不支持 Lark-only owner/description/bot/visibility 语义。", + "availability": "available" + }, + { + "suite": "semantic", "service": "chat", "command": "+chat-dismiss", "risk": "high-risk-write", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "解散群聊不可逆且是一对一操作;应由 Runtime Schema leaf 的高风险门禁承载。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", + "service": "chat", + "command": "+chat-get-by-id", + "risk": "read", + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "按数字群号取群信息是一对一读取;与 openConversationId 不可互换。", + "availability": "available" + }, + { + "suite": "semantic", "service": "chat", "command": "+chat-invite-url", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "群邀请链接是一对一读取;Shortcut 未增加生命周期或分享编排。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "chat", "command": "+chat-list-all", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "alias_internal", + "semantic_delta": "与 +my-groups 重复;保留旧命令兼容,默认入口使用带类型过滤和完整性语义的 +my-groups。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "chat", "command": "+chat-list-join-requests", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "入群申请列表是一对一分页读取;无额外审批编排。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "chat", "command": "+chat-list-mine", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "群主/管理员群列表只做字段投影;精确角色筛选由底层 leaf 提供。", + "availability": "available" }, { - "suite": "write", + "suite": "semantic", + "service": "chat", + "command": "+chat-members-get", + "risk": "read", + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "按成员 ID 批量取详情是一对一读取;默认由 Runtime Schema leaf 承担。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "chat", + "command": "+chat-members-list", + "risk": "read", + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "支持群名唯一解析或直接会话 ID,组合用户与机器人两个下层工具并分桶输出完整性。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "chat", + "command": "+chat-messages", + "risk": "read", + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "统一群聊与两类单聊目标,输出稳定消息身份、引用、reaction、resourceRefs、时间边界翻页和可读正文。", + "availability": "available" + }, + { + "suite": "semantic", "service": "chat", "command": "+chat-mute", "risk": "write", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "全员禁言是一对一群治理操作;默认由 leaf 精确路由。", + "availability": "available" }, { - "suite": "write", + "suite": "semantic", + "service": "chat", + "command": "+chat-mute-member", + "risk": "write", + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "成员禁言是一对一群治理操作;默认由 leaf 精确路由。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "chat", + "command": "+chat-quit", + "risk": "write", + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "退出群聊是一对一成员关系写入;默认由 Runtime Schema leaf 承担。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "chat", + "command": "+chat-remove-bot", + "risk": "high-risk-write", + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "移除群机器人是一对一高风险治理写入;默认由 leaf 承担。", + "availability": "available" + }, + { + "suite": "semantic", "service": "chat", "command": "+chat-role-add", "risk": "write", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "添加群身份是一对一写入;默认由 leaf 承担。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "chat", "command": "+chat-role-list", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "群身份列表是一对一读取;无额外成员富化。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "chat", "command": "+chat-role-query-user", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "查询成员群身份是一对一读取;无额外语义编排。", + "availability": "available" }, { - "suite": "write", + "suite": "semantic", + "service": "chat", + "command": "+chat-role-remove", + "risk": "high-risk-write", + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "删除群身份是一对一高风险写入;默认由带确认的 leaf 承担。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "chat", + "command": "+chat-role-remove-user", + "risk": "write", + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "移除成员指定群身份是一对一写入;默认由 leaf 承担。", + "availability": "available" + }, + { + "suite": "semantic", "service": "chat", "command": "+chat-role-set-user", "risk": "write", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "覆盖成员全部群身份是一对一高影响写入;默认由 leaf 承担。", + "availability": "available" }, { - "suite": "write", + "suite": "semantic", "service": "chat", "command": "+chat-role-update", "risk": "write", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "更新群身份是一对一写入;默认由 leaf 承担。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "chat", "command": "+chat-search", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "群关键词搜索只做字段投影;底层 search_groups leaf 已提供分页。", + "availability": "available" }, { - "suite": "write", + "suite": "semantic", "service": "chat", "command": "+chat-set-admin", "risk": "write", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "管理员设置是一对一群治理操作;默认由 Runtime Schema leaf 承担。", + "availability": "available" }, { - "suite": "write", + "suite": "semantic", "service": "chat", "command": "+chat-set-history", "risk": "write", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "新成员历史可见范围是一对一群设置;无额外编排。", + "availability": "available" }, { - "suite": "write", + "suite": "semantic", + "service": "chat", + "command": "+chat-transfer-owner", + "risk": "write", + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "转让群主是一对一高影响治理操作;应直接使用精确 leaf 与安全确认。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "chat", + "command": "+chat-update", + "risk": "write", + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "只发布 DWS 与 lark-cli 共有的群名称更新子集;不接受或静默忽略 description。", + "availability": "available" + }, + { + "suite": "semantic", "service": "chat", "command": "+chat-update-alias", "risk": "write", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "更新本人群备注是一对一写入;默认由 leaf 承担。", + "availability": "available" }, { - "suite": "write", + "suite": "semantic", + "service": "chat", + "command": "+chat-update-icon", + "risk": "write", + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "更新群头像是一对一媒体 ID 写入;尚无本地媒体解析编排。", + "availability": "available" + }, + { + "suite": "semantic", "service": "chat", "command": "+chat-update-nick", "risk": "write", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "更新本人群昵称是一对一写入;默认由 leaf 承担。", + "availability": "available" }, { - "suite": "write", + "suite": "semantic", + "service": "chat", + "command": "+chat-update-settings", + "risk": "write", + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "群 settingKey/status 是精确原子设置;Shortcut 未增加语义枚举。", + "availability": "available" + }, + { + "suite": "semantic", "service": "chat", "command": "+conversation-clear-all-red-point", - "risk": "high-risk-write", - "status": "real-ok" + "risk": "write", + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "全局清红点是一对一批量状态写入;高影响意图应直接选择带安全事实的 leaf。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", + "service": "chat", + "command": "+conversation-clear-messages", + "risk": "high-risk-write", + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "清空聊天记录不可逆且是一对一操作;应由 Runtime Schema leaf 的高风险门禁承载。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "chat", + "command": "+conversation-clear-red-point", + "risk": "write", + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "清除单会话红点是一对一操作;默认由 Runtime Schema leaf 精确选择。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "chat", + "command": "+conversation-hide", + "risk": "write", + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "隐藏会话是一对一写入;默认由 Runtime Schema leaf 承担。", + "availability": "available" + }, + { + "suite": "semantic", "service": "chat", "command": "+conversation-info", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "统一群聊 openConversationId 与单聊 openDingTalkId 两种目标,并路由到对应读取工具。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "chat", "command": "+conversation-list", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "会话分页列表只做字段投影;底层 leaf 已提供 limit/cursor/exclude-muted。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "chat", "command": "+conversation-list-top", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "将下层 singleChat 规范化为 Agent 友好的 group/direct 类型,并支持 --type all|group|direct 本地过滤。", + "availability": "available" }, { - "suite": "write", + "suite": "semantic", + "service": "chat", + "command": "+conversation-mark-read", + "risk": "write", + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "消息已读标记是一对一写入;无额外解析或编排。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "chat", + "command": "+conversation-mark-unread", + "risk": "write", + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "标记未读是一对一会话操作;Shortcut 未增加额外语义编排。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "chat", + "command": "+conversation-mute", + "risk": "write", + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "会话免打扰是一对一状态写入;无名称解析、编排或恢复增益。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "chat", + "command": "+conversation-set-top", + "risk": "write", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "把会话置顶扩展为最多 10 个目标的逐项写入,统一输出 requested/succeeded/failed ledger。", + "availability": "available" + }, + { + "suite": "semantic", "service": "chat", "command": "+dm", "risk": "write", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "把自然语言姓名安全解析为唯一用户,再以当前用户身份发送并提供真实 dry-run 计划。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", + "service": "chat", + "command": "+feed-group-query-item", + "risk": "read", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "读取 DingTalk 会话分组后按多个 openConversationId 本地精确过滤;只有完整响应才判定 notFound,若下层仍有后续页但无可执行游标则返回 unresolved 与失败 ledger,且不伪造 Lark deleted_items。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "chat", + "command": "+flag-cancel", + "risk": "write", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "批量取消当前用户的 DingTalk message favorite,并逐项报告失败;不伪造 Lark 的双层 best-effort 取消。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "chat", + "command": "+flag-create", + "risk": "write", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "把 Lark Flag 意图忠实映射到 DingTalk message favorite,并为同会话最多 10 条消息提供逐项成功/失败 ledger;不改写 Pin、会话置顶或 feed-layer thread flag。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "chat", + "command": "+flag-list", + "risk": "read", + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "先创建真实 message favorite,再分别通过 native leaf 与 Shortcut 读取非空列表;两层返回相同收藏项且包含新建 Fixture,已完成非空结果证明。", + "availability": "available" + }, + { + "suite": "semantic", "service": "chat", "command": "+group-members", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "alias_internal", + "semantic_delta": "按群名列用户成员已由 +chat-members-list 覆盖;保留旧入口兼容。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", + "service": "chat", + "command": "+messages-add-emoji", + "risk": "write", + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "添加 emoji reaction 是精确原子操作;默认由 Runtime Schema leaf 承担。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "chat", + "command": "+messages-add-text-emotion", + "risk": "write", + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "添加文字表情是精确原子操作;默认由 Runtime Schema leaf 承担。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "chat", + "command": "+messages-batch-recall-by-bot", + "risk": "write", + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "机器人单聊批量撤回是一对一批量操作;默认由 bot leaf 承担。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "chat", + "command": "+messages-batch-send-by-bot", + "risk": "write", + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "机器人批量单聊是一对一批量发送;默认由 bot leaf 承担。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "chat", + "command": "+messages-combine-forward", + "risk": "write", + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "使用两个真实群、两条非空源消息分别完成 native leaf 与 Shortcut 双层回归,并以 mget 回读确认两条目标合并消息均实际存在且包含完整 forwarded 子消息。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "chat", + "command": "+messages-create-text-emotion", + "risk": "write", + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "创建文字表情模板是一对一写入;默认由 Runtime Schema leaf 承担。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "chat", + "command": "+messages-forward", + "risk": "write", + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "转发单条消息是一对一操作;默认由 Runtime Schema leaf 承担。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "chat", + "command": "+messages-forward-topic", + "risk": "write", + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "话题转发是一对一消息操作;默认由 leaf 承担。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "chat", + "command": "+messages-list", + "risk": "read", + "status": "reviewed_available", + "disposition": "alias_internal", + "semantic_delta": "群历史读取与 +chat-messages 重复;保留旧参数兼容,默认入口使用统一群/单聊投影。", + "availability": "available" + }, + { + "suite": "semantic", "service": "chat", "command": "+messages-list-direct", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "alias_internal", + "semantic_delta": "单聊历史读取与 +chat-messages 重复;保留 user/openDingTalkId 兼容,默认入口使用统一历史投影。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "chat", "command": "+messages-list-pin", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "Pin 列表只做字段投影;无额外语义编排。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "chat", "command": "+messages-list-unread-conversations", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "alias_internal", + "semantic_delta": "未读会话列表与 +unread-chats 重复;保留旧入口兼容,默认使用稳定投影入口。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "chat", "command": "+messages-mget", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "按最多 50 个消息 ID 批量读取并输出稳定消息投影、reaction 与 resourceRefs;可用 --download-resources 复用 HTTPS、相对路径、无覆盖和原子落盘防护,逐资源返回下载失败 ledger。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "chat", "command": "+messages-query-send-status", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "发送状态查询是一对一读取;等待/轮询送达尚未形成语义编排。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "chat", "command": "+messages-read-status", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "已读状态查询是一对一读取;默认由 Runtime Schema leaf 承担。", + "availability": "available" }, { - "suite": "write", + "suite": "semantic", + "service": "chat", + "command": "+messages-recall", + "risk": "write", + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "当前用户撤回是一对一消息操作;默认由 leaf 承担。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "chat", + "command": "+messages-recall-by-bot", + "risk": "write", + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "机器人群消息撤回是一对一身份专用操作;默认由 bot leaf 承担。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "chat", + "command": "+messages-remove-emoji", + "risk": "write", + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "移除 emoji reaction 是精确原子操作;默认由 Runtime Schema leaf 承担。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "chat", + "command": "+messages-remove-text-emotion", + "risk": "write", + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "移除文字表情是精确原子操作;默认由 Runtime Schema leaf 承担。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "chat", + "command": "+messages-reply", + "risk": "write", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "提供当前用户纯文本引用回复的稳定入口、幂等键和 AI 标记,并通过 mget 自动补全原发送者;明确不支持 bot、富媒体或 thread writer。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "chat", + "command": "+messages-resource-download", + "risk": "read", + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "把临时资源 URL 解析、工作目录内安全路径、默认不覆盖、临时文件下载和原子发布封装为结构化单步结果。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "chat", + "command": "+messages-resource-url", + "risk": "read", + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "仅换取临时资源 URL;尚未提供安全落盘、去重和局部失败隔离,不作为默认 Semantic Shortcut。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "chat", + "command": "+messages-send", + "risk": "write", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "用统一 identity 参数路由 current-user、bot、webhook 文本/Markdown 发送;按身份校验目标与凭据,幂等键只在真实支持的 user 分支开放,媒体上传仍诚实留在 native leaf。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "chat", + "command": "+messages-send-by-bot", + "risk": "write", + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "机器人群发 Markdown 是一对一身份明确的发送;默认由 bot leaf 承担。", + "availability": "available" + }, + { + "suite": "semantic", "service": "chat", "command": "+messages-send-by-webhook", "risk": "write", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "Webhook 发送是一对一 token 目标写入;默认由 bot leaf 承担。", + "availability": "available" }, { - "suite": "write", + "suite": "semantic", + "service": "chat", + "command": "+messages-send-card", + "risk": "write", + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "创建流式卡片是一对一写入;完整卡片生命周期需由 send/update leaf 明确编排。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "chat", + "command": "+messages-set-pin", + "risk": "write", + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "Pin 消息是一对一操作;默认由 Runtime Schema leaf 承担。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "chat", + "command": "+messages-set-top", + "risk": "write", + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "置顶消息是一对一操作;默认由 Runtime Schema leaf 承担。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "chat", + "command": "+messages-unset-pin", + "risk": "write", + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "取消 Pin 是一对一操作;默认由 Runtime Schema leaf 承担。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "chat", + "command": "+messages-unset-top", + "risk": "write", + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "取消置顶消息是一对一操作;默认由 Runtime Schema leaf 承担。", + "availability": "available" + }, + { + "suite": "semantic", "service": "chat", "command": "+messages-update-card", "risk": "write", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "更新流式卡片是一对一写入;默认由 Runtime Schema leaf 承担。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "chat", "command": "+my-groups", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "提供稳定群字段、本地类型过滤和可继续的分页完整性,替代重复群列表入口。", + "availability": "available" }, { - "suite": "write", + "suite": "semantic", + "service": "chat", + "command": "+search-msg", + "risk": "read", + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "统一关键词、发送者、@对象、会话、消息类型和机器人来源过滤;支持精确时间窗、page-all、50 条一组 mget 富化,并以 failure ledger 显式报告截断或富化失败。", + "availability": "available" + }, + { + "suite": "semantic", "service": "chat", "command": "+send-to-group", "risk": "write", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "按群名解析唯一会话、拒绝歧义并生成当前用户发送的真实 dry-run 计划。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", + "service": "chat", + "command": "+thread-replies", + "risk": "read", + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "接受消息列表直接返回的 threadId(兼容 topicId),拉取回复并输出稳定身份、引用、reaction、resourceRefs、可读正文和时间边界分页。", + "availability": "available" + }, + { + "suite": "semantic", "service": "chat", "command": "+unread-chats", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "把未读会话投影为会话身份、名称和未读计数,并保留下层分页完整性。", + "availability": "available" }, { "suite": "read", diff --git a/internal/app/dry_run_guards_test.go b/internal/app/dry_run_guards_test.go index 9e4ccb88..fbe6d6a2 100644 --- a/internal/app/dry_run_guards_test.go +++ b/internal/app/dry_run_guards_test.go @@ -11,6 +11,7 @@ import ( "testing" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" ) func TestToolCallerAdapterDryRunNeverInvokesRunner(t *testing.T) { @@ -36,6 +37,66 @@ func TestToolCallerAdapterDryRunNeverInvokesRunner(t *testing.T) { } } +func TestToolCallerAdapterDryRunAllowsOnlyExplicitReadCapability(t *testing.T) { + runner := &readOnlyDryRunRunner{} + caller := newToolCallerAdapter(runner, &GlobalFlags{DryRun: true, Format: "json"}) + + result, err := caller.(edition.ReadToolCaller).CallReadTool( + context.Background(), + "im", + "search_groups", + map[string]any{"keyword": "project"}, + ) + if err != nil { + t.Fatalf("CallReadTool() error = %v", err) + } + if got := runner.readCalls.Load(); got != 1 { + t.Fatalf("read calls = %d, want 1", got) + } + if got := runner.regularCalls.Load(); got != 0 { + t.Fatalf("regular calls = %d, want 0", got) + } + if runner.invocation.DryRun { + t.Fatal("read-only invocation was left in dry-run mode") + } + if result == nil || len(result.Content) != 1 || !strings.Contains(result.Content[0].Text, `"read":true`) { + t.Fatalf("read result = %#v", result) + } + + failClosed := newToolCallerAdapter(&countingErrorRunner{}, &GlobalFlags{DryRun: true}) + if _, err := failClosed.(edition.ReadToolCaller).CallReadTool( + context.Background(), "im", "search_groups", nil, + ); err == nil { + t.Fatal("runner without read-only capability was accepted") + } +} + +func TestCrossPlatformCoverageReadOnlyGuardErrorPaths(t *testing.T) { + var nilAdapter *toolCallerAdapter + if _, err := nilAdapter.CallReadTool(context.Background(), "im", "search_groups", nil); err == nil { + t.Fatal("nil adapter accepted a read-only call") + } + + regularRunner := &capturingSuccessRunner{} + regular := newToolCallerAdapter(regularRunner, &GlobalFlags{DryRun: false, Format: "json"}) + if _, err := regular.(edition.ReadToolCaller).CallReadTool(context.Background(), "im", "search_groups", nil); err != nil { + t.Fatalf("non-dry read should use the regular runner: %v", err) + } + if got := regularRunner.calls.Load(); got != 1 { + t.Fatalf("regular runner calls = %d, want 1", got) + } + + readFailure := newToolCallerAdapter(&failingReadOnlyRunner{}, &GlobalFlags{DryRun: true, Format: "json"}) + if _, err := readFailure.(edition.ReadToolCaller).CallReadTool(context.Background(), "im", "search_groups", nil); err == nil { + t.Fatal("read-only runner error was swallowed") + } + + var nilRuntime *runtimeRunner + if _, err := nilRuntime.RunReadOnly(context.Background(), executor.Invocation{}); err == nil { + t.Fatal("nil runtime runner accepted a read-only call") + } +} + func TestRuntimeRunnerGlobalDryRunStopsBeforeInjectedFallback(t *testing.T) { fallback := &countingErrorRunner{} runner := &runtimeRunner{globalFlags: &GlobalFlags{DryRun: true}, fallback: fallback} @@ -56,6 +117,38 @@ func TestRuntimeRunnerGlobalDryRunStopsBeforeInjectedFallback(t *testing.T) { } } +func TestRuntimeRunnerReadOnlyClonePreservesGlobalDryRunBarrier(t *testing.T) { + fallback := &capturingSuccessRunner{} + flags := &GlobalFlags{DryRun: true} + runner := &runtimeRunner{globalFlags: flags, fallback: fallback} + invocation := executor.NewHelperInvocation( + "test", + "im", + "search_groups", + map[string]any{"keyword": "project"}, + ) + + if _, err := runner.RunReadOnly(context.Background(), invocation); err != nil { + t.Fatalf("RunReadOnly() error = %v", err) + } + if got := fallback.calls.Load(); got != 1 { + t.Fatalf("fallback calls = %d, want 1", got) + } + if fallback.invocation.DryRun { + t.Fatal("read-only fallback invocation was left in dry-run mode") + } + if !flags.DryRun { + t.Fatal("RunReadOnly mutated the process-wide dry-run flag") + } + + if _, err := runner.Run(context.Background(), invocation); err != nil { + t.Fatalf("ordinary Run() error = %v", err) + } + if got := fallback.calls.Load(); got != 1 { + t.Fatalf("ordinary dry-run reached fallback; calls = %d", got) + } +} + type countingErrorRunner struct { calls atomic.Int64 } @@ -64,3 +157,47 @@ func (r *countingErrorRunner) Run(context.Context, executor.Invocation) (executo r.calls.Add(1) return executor.Result{}, errors.New("runner must not be called") } + +type readOnlyDryRunRunner struct { + regularCalls atomic.Int64 + readCalls atomic.Int64 + invocation executor.Invocation +} + +func (r *readOnlyDryRunRunner) Run(context.Context, executor.Invocation) (executor.Result, error) { + r.regularCalls.Add(1) + return executor.Result{}, errors.New("regular runner must not be called") +} + +func (r *readOnlyDryRunRunner) RunReadOnly(_ context.Context, invocation executor.Invocation) (executor.Result, error) { + r.readCalls.Add(1) + r.invocation = invocation + return executor.Result{ + Invocation: invocation, + Response: map[string]any{"read": true}, + }, nil +} + +type capturingSuccessRunner struct { + calls atomic.Int64 + invocation executor.Invocation +} + +func (r *capturingSuccessRunner) Run(_ context.Context, invocation executor.Invocation) (executor.Result, error) { + r.calls.Add(1) + r.invocation = invocation + return executor.Result{ + Invocation: invocation, + Response: map[string]any{"read": true}, + }, nil +} + +type failingReadOnlyRunner struct{} + +func (*failingReadOnlyRunner) Run(context.Context, executor.Invocation) (executor.Result, error) { + return executor.Result{}, errors.New("regular runner must not be called") +} + +func (*failingReadOnlyRunner) RunReadOnly(context.Context, executor.Invocation) (executor.Result, error) { + return executor.Result{}, errors.New("read failed") +} diff --git a/internal/app/root_help_test.go b/internal/app/root_help_test.go index 22ff73fe..762e6f57 100644 --- a/internal/app/root_help_test.go +++ b/internal/app/root_help_test.go @@ -380,7 +380,7 @@ func TestRootKeepsSVIPChatCompatibilityFlags(t *testing.T) { } searchAdvanced := mustFindCommand(t, root, "chat", "message", "search-advanced") - for _, flag := range []string{"sender", "senders", "sender-ids"} { + for _, flag := range []string{"sender", "senders", "sender-ids", "message-type", "only-robot", "conversation-type"} { if searchAdvanced.Flags().Lookup(flag) == nil { t.Fatalf("chat message search-advanced missing --%s", flag) } diff --git a/internal/app/runner.go b/internal/app/runner.go index 51e62275..ae9a7f95 100644 --- a/internal/app/runner.go +++ b/internal/app/runner.go @@ -224,6 +224,24 @@ func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation) return r.runSingle(ctx, invocation, true) } +// RunReadOnly executes one already-classified read lookup for a semantic +// Shortcut that is building a dry-run plan. It clones the runtime flags and +// clears DryRun only on that clone: the process-wide caller and every ordinary +// ToolCaller invocation retain the global execution barrier. +func (r *runtimeRunner) RunReadOnly(ctx context.Context, invocation executor.Invocation) (executor.Result, error) { + if r == nil { + return executor.Result{}, fmt.Errorf("runtime runner is not configured") + } + clone := *r + if r.globalFlags != nil { + flags := *r.globalFlags + flags.DryRun = false + clone.globalFlags = &flags + } + invocation.DryRun = false + return clone.Run(ctx, invocation) +} + func (r *runtimeRunner) runSingle(ctx context.Context, invocation executor.Invocation, prefetchToken bool) (executor.Result, error) { if r.loader == nil || r.transport == nil { return r.fallback.Run(ctx, invocation) diff --git a/internal/app/schema_shortcut_contract_test.go b/internal/app/schema_shortcut_contract_test.go index 628b792d..5c29ea8f 100644 --- a/internal/app/schema_shortcut_contract_test.go +++ b/internal/app/schema_shortcut_contract_test.go @@ -11,22 +11,26 @@ import ( "strings" "testing" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" ) -const publicShortcutSchemaCount = 210 +const ( + publicShortcutCount = 265 + schemaPublishedShortcutCount = 210 +) -func TestEmbeddedSchemaPublishesEveryPublicShortcutContract(t *testing.T) { +func TestEmbeddedSchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) { tools := embeddedSchemaAllToolsForHelpFlagTest(t, NewRootCommand()) - public := make([]shortcut.Shortcut, 0, publicShortcutSchemaCount) + public := make([]shortcut.Shortcut, 0, publicShortcutCount) for _, candidate := range shortcut.All() { if candidate.UserDefined || !shortcut.InPublicCatalog(candidate.Service, candidate.Command) { continue } public = append(public, candidate) } - if got := len(public); got != publicShortcutSchemaCount { - t.Fatalf("public built-in shortcuts = %d, want %d", got, publicShortcutSchemaCount) + if got := len(public); got != publicShortcutCount { + t.Fatalf("public built-in shortcuts = %d, want %d", got, publicShortcutCount) } deliveredShortcuts := 0 @@ -35,17 +39,35 @@ func TestEmbeddedSchemaPublishesEveryPublicShortcutContract(t *testing.T) { deliveredShortcuts++ } } - if deliveredShortcuts != publicShortcutSchemaCount { - t.Fatalf("embedded schema --all shortcut tools = %d, want %d", deliveredShortcuts, publicShortcutSchemaCount) + if deliveredShortcuts != schemaPublishedShortcutCount { + t.Fatalf("embedded schema --all shortcut tools = %d, want %d", deliveredShortcuts, schemaPublishedShortcutCount) } + exclusions, err := cli.EmbeddedRuntimeSchemaExclusions() + if err != nil { + t.Fatal(err) + } + excludedPaths := make(map[string]bool, len(exclusions)) + for _, exclusion := range exclusions { + if !exclusion.Reviewed || strings.TrimSpace(exclusion.Reason) == "" { + t.Fatalf("unreviewed public command exclusion: %#v", exclusion) + } + excludedPaths[exclusion.CLIPath] = true + } + + excludedShortcuts := 0 for _, declared := range public { declared := declared t.Run(declared.Service+"/"+strings.TrimPrefix(declared.Command, "+"), func(t *testing.T) { canonical := shortcutSchemaCanonical(declared) tool := tools[canonical] if tool == nil { - t.Fatalf("embedded schema --all is missing %s (%s %s)", canonical, declared.Service, declared.Command) + cliPath := declared.Service + " " + declared.Command + if !excludedPaths[cliPath] { + t.Fatalf("embedded schema --all is missing %s (%s) without an exact reviewed exclusion", canonical, cliPath) + } + excludedShortcuts++ + return } assertEmbeddedShortcutIdentityAndSelection(t, tool, declared, canonical) assertEmbeddedShortcutSafetyAndInterface(t, tool, declared, canonical) @@ -53,6 +75,9 @@ func TestEmbeddedSchemaPublishesEveryPublicShortcutContract(t *testing.T) { assertEmbeddedShortcutConstraints(t, tool, declared, canonical) }) } + if got, want := excludedShortcuts, publicShortcutCount-schemaPublishedShortcutCount; got != want { + t.Fatalf("exactly excluded public shortcuts = %d, want %d", got, want) + } } func TestEmbeddedShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T) { @@ -328,9 +353,9 @@ func assertEmbeddedShortcutConstraints( case shortcut.ConstraintCustom: for _, flagName := range flags { description := schemaContractString(schemaContractMap(tool["parameters"])[flagName]["description"]) - for _, requiredText := range []string{"原文不能为空", "不能重复"} { - if !strings.Contains(description, requiredText) { - t.Errorf("%s --%s description does not publish custom constraint %q: %q", canonical, flagName, requiredText, description) + for _, evidence := range shortcutCustomConstraintEvidence(constraint.Description) { + if !strings.Contains(description, evidence) { + t.Errorf("%s --%s description does not publish custom constraint evidence %q: %q", canonical, flagName, evidence, description) } } } @@ -349,6 +374,33 @@ func assertEmbeddedShortcutConstraints( } } +func shortcutCustomConstraintEvidence(description string) []string { + // Custom constraints are prose rather than a typed wire contract. Require + // their decision-relevant facts to survive in the delivered parameter + // description while allowing the renderer to reorder connective wording. + probes := []string{ + "原文=>替换", + "不能为空", + "不能重复", + "大于 0", + "工作目录", + "相对路径", + "绝对路径", + "..", + "最多 15 个字符", + } + evidence := make([]string, 0, len(probes)) + for _, probe := range probes { + if strings.Contains(description, probe) { + evidence = append(evidence, probe) + } + } + if len(evidence) > 0 { + return evidence + } + return []string{strings.TrimSpace(description)} +} + func mustShortcutJSON(value any) string { encoded, err := json.Marshal(value) if err != nil { diff --git a/internal/app/tool_caller_adapter.go b/internal/app/tool_caller_adapter.go index 111187cb..db5fdee1 100644 --- a/internal/app/tool_caller_adapter.go +++ b/internal/app/tool_caller_adapter.go @@ -66,6 +66,33 @@ func (a *toolCallerAdapter) CallTool(ctx context.Context, productID, toolName st return convertResult(result), nil } +type dryRunReadRunner interface { + RunReadOnly(context.Context, executor.Invocation) (executor.Result, error) +} + +// CallReadTool executes a Shortcut's explicitly classified read lookup while +// the outer command is in dry-run mode. Ordinary CallTool remains protected by +// the global execution barrier. The runner capability is optional and fails +// closed so an injected runner cannot accidentally receive a real call. +func (a *toolCallerAdapter) CallReadTool(ctx context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) { + if a == nil || a.runner == nil { + return nil, fmt.Errorf("ToolCaller runner is not configured") + } + if !a.DryRun() { + return a.CallTool(ctx, productID, toolName, args) + } + readRunner, ok := a.runner.(dryRunReadRunner) + if !ok { + return nil, fmt.Errorf("ToolCaller runner does not support read-only dry-run lookups") + } + inv := executor.NewHelperInvocation("overlay."+productID+"."+toolName, productID, toolName, args) + result, err := readRunner.RunReadOnly(ctx, inv) + if err != nil { + return nil, err + } + return convertResult(result), nil +} + // CallToolWithToken invokes a helper with an in-memory token override. It is // used during login before the new token has been persisted to any profile // slot. diff --git a/internal/app/usage_recording.go b/internal/app/usage_recording.go index b6ead5df..3e06d8ff 100644 --- a/internal/app/usage_recording.go +++ b/internal/app/usage_recording.go @@ -39,6 +39,19 @@ func (r recordingToolCaller) CallTool(ctx context.Context, product, tool string, return res, err } +func (r recordingToolCaller) CallReadTool(ctx context.Context, product, tool string, args map[string]any) (*edition.ToolResult, error) { + inner, ok := r.inner.(edition.ReadToolCaller) + if !ok { + return nil, fmt.Errorf("ToolCaller read-only dry-run lookup is not configured") + } + recordedArgs := cloneToolArgs(args) + res, err := inner.CallReadTool(ctx, product, tool, args) + // This is a real read even though the outer command is a dry-run. Record it + // as such so usage evidence does not misclassify the lookup as skipped. + usage.Append(product, tool, recordedArgs, err == nil, false) + return res, err +} + func (r recordingToolCaller) CallToolWithToken( ctx context.Context, token, product, tool string, diff --git a/internal/app/usage_recording_test.go b/internal/app/usage_recording_test.go index 3b9705d4..a8f45268 100644 --- a/internal/app/usage_recording_test.go +++ b/internal/app/usage_recording_test.go @@ -27,6 +27,7 @@ type crossPlatformCoverageCaller struct { args map[string]any token string dryRun bool + reads int } func (c *crossPlatformCoverageCaller) CallTool(_ context.Context, _, _ string, args map[string]any) (*edition.ToolResult, error) { @@ -44,6 +45,16 @@ func (c *crossPlatformCoverageCaller) CallToolWithToken( return &edition.ToolResult{}, nil } +func (c *crossPlatformCoverageCaller) CallReadTool( + _ context.Context, + _, _ string, + args map[string]any, +) (*edition.ToolResult, error) { + c.reads++ + c.args = args + return &edition.ToolResult{}, nil +} + func (*crossPlatformCoverageCaller) Format() string { return "json" } func (c *crossPlatformCoverageCaller) DryRun() bool { return c.dryRun } func (*crossPlatformCoverageCaller) Fields() string { return "id,name" } @@ -129,6 +140,51 @@ func TestCrossPlatformCoverageRecordingToolCaller(t *testing.T) { } } +func TestCrossPlatformCoverageRecordingReadToolCaller(t *testing.T) { + t.Setenv("DWS_CONFIG_DIR", t.TempDir()) + t.Setenv("DWS_USAGE_TRACKING", "1") + + inner := &crossPlatformCoverageCaller{dryRun: true} + caller := newRecordingToolCaller(inner) + readCaller, ok := caller.(edition.ReadToolCaller) + if !ok { + t.Fatal("recording caller dropped read-only capability") + } + if _, err := readCaller.CallReadTool( + context.Background(), + "im", + "search_groups", + map[string]any{"open_conversation_id": "cid_x"}, + ); err != nil { + t.Fatal(err) + } + if inner.reads != 1 || inner.args["open_conversation_id"] != "cid_x" { + t.Fatalf("read forwarding = reads %d args %#v", inner.reads, inner.args) + } + records, err := usage.Read() + if err != nil { + t.Fatal(err) + } + if len(records) != 1 || !records[0].OK { + t.Fatalf("real read must be recorded as successful: %#v", records) + } + + withoutRead := recordingToolCaller{inner: nonReadToolCaller{}} + if _, err := withoutRead.CallReadTool(context.Background(), "im", "search_groups", nil); err == nil { + t.Fatal("recording caller accepted an inner caller without read support") + } +} + +type nonReadToolCaller struct{} + +func (nonReadToolCaller) CallTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) { + return &edition.ToolResult{}, nil +} +func (nonReadToolCaller) Format() string { return "json" } +func (nonReadToolCaller) DryRun() bool { return false } +func (nonReadToolCaller) Fields() string { return "" } +func (nonReadToolCaller) JQ() string { return "" } + func TestCrossPlatformCoverageRootPublishesShortcutCommands(t *testing.T) { configDir := t.TempDir() t.Setenv("DWS_CONFIG_DIR", configDir) diff --git a/internal/cli/schema_agent_metadata/chat.json b/internal/cli/schema_agent_metadata/chat.json index 32d9bd45..a067a765 100644 --- a/internal/cli/schema_agent_metadata/chat.json +++ b/internal/cli/schema_agent_metadata/chat.json @@ -1259,7 +1259,7 @@ }, "use_when": { "value": [ - "当你想新建一个会话分组来归类会话时使用;会实际创建分组并返回其 ID,需传分组名称 --title。" + "当你想新建一个会话分组来归类会话时使用;会实际创建分组并返回其 ID,需传最多 15 个字符的分组名称 --title。" ], "source": "internal/cli/schema_hints/selection/chat.json", "precedence": "reviewed_explicit", @@ -1268,7 +1268,7 @@ "candidates": [ { "value": [ - "当你想新建一个会话分组来归类会话时使用;会实际创建分组并返回其 ID,需传分组名称 --title。" + "当你想新建一个会话分组来归类会话时使用;会实际创建分组并返回其 ID,需传最多 15 个字符的分组名称 --title。" ], "source": "internal/cli/schema_hints/selection/chat.json", "precedence": "reviewed_explicit", @@ -1291,7 +1291,7 @@ "internal/cli/schema_hints/selection/chat.json" ], "use_when": [ - "当你想新建一个会话分组来归类会话时使用;会实际创建分组并返回其 ID,需传分组名称 --title。" + "当你想新建一个会话分组来归类会话时使用;会实际创建分组并返回其 ID,需传最多 15 个字符的分组名称 --title。" ] }, "chat +category-delete": { @@ -2033,7 +2033,7 @@ }, "use_when": { "value": [ - "当你想重命名已有的自定义会话分组时使用;会实际更新分组名称,需传 categoryId 和新名称 --title。" + "当你想重命名已有的自定义会话分组时使用;会实际更新分组名称,需传 categoryId 和最多 15 个字符的新名称 --title。" ], "source": "internal/cli/schema_hints/selection/chat.json", "precedence": "reviewed_explicit", @@ -2042,7 +2042,7 @@ "candidates": [ { "value": [ - "当你想重命名已有的自定义会话分组时使用;会实际更新分组名称,需传 categoryId 和新名称 --title。" + "当你想重命名已有的自定义会话分组时使用;会实际更新分组名称,需传 categoryId 和最多 15 个字符的新名称 --title。" ], "source": "internal/cli/schema_hints/selection/chat.json", "precedence": "reviewed_explicit", @@ -2065,7 +2065,7 @@ "internal/cli/schema_hints/selection/chat.json" ], "use_when": [ - "当你想重命名已有的自定义会话分组时使用;会实际更新分组名称,需传 categoryId 和新名称 --title。" + "当你想重命名已有的自定义会话分组时使用;会实际更新分组名称,需传 categoryId 和最多 15 个字符的新名称 --title。" ] }, "chat +chat-bots": { @@ -7229,32 +7229,33 @@ ] }, "chat +conversation-list-top": { - "agent_summary": "拉取置顶会话列表", + "agent_summary": "拉取置顶会话列表,可只看群聊或单聊", "agent_summary_source": "dws-agent-selection/chat", "availability": "available", "avoid_when": [ - "需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令" + "用户要查看群内被 Pin 的消息而不是侧边栏置顶会话时,改用 +messages-list-pin" ], "confirmation": "not_required", "effect": "read", "effect_source": "agent-hint", "examples": [ - "dws chat +conversation-list-top --limit 1000" + "dws chat +conversation-list-top --type group --limit 1000", + "dws chat +conversation-list-top --type direct --limit 1000" ], "field_provenance": { "agent_summary": { - "value": "拉取置顶会话列表", + "value": "拉取置顶会话列表,可只看群聊或单聊", "source": "internal/cli/schema_hints/selection/chat.json", "precedence": "reviewed_explicit", "resolution": "highest_precedence", - "review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.", + "review_reason": "Reviewed after live testing list_top_conversations: singleChat is present on every tested row and is normalized locally to group/direct without adding a lower-service parameter.", "candidates": [ { - "value": "拉取置顶会话列表", + "value": "拉取置顶会话列表,可只看群聊或单聊", "source": "internal/cli/schema_hints/selection/chat.json", "precedence": "reviewed_explicit", "selected": true, - "review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts." + "review_reason": "Reviewed after live testing list_top_conversations: singleChat is present on every tested row and is normalized locally to group/direct without adding a lower-service parameter." } ] }, @@ -7263,34 +7264,34 @@ "source": "internal/cli/schema_hints/metadata/chat.json", "precedence": "reviewed_explicit", "resolution": "highest_precedence", - "review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.", + "review_reason": "Reviewed against the mounted Shortcut: --type all|group|direct is a local semantic filter over list_top_conversations.singleChat, so the composite interface owns validation and normalized output without changing lower-service arguments.", "candidates": [ { "value": "available", "source": "internal/cli/schema_hints/metadata/chat.json", "precedence": "reviewed_explicit", "selected": true, - "review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity." + "review_reason": "Reviewed against the mounted Shortcut: --type all|group|direct is a local semantic filter over list_top_conversations.singleChat, so the composite interface owns validation and normalized output without changing lower-service arguments." } ] }, "avoid_when": { "value": [ - "需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令" + "用户要查看群内被 Pin 的消息而不是侧边栏置顶会话时,改用 +messages-list-pin" ], "source": "internal/cli/schema_hints/selection/chat.json", "precedence": "reviewed_explicit", "resolution": "highest_precedence", - "review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.", + "review_reason": "Reviewed after live testing list_top_conversations: singleChat is present on every tested row and is normalized locally to group/direct without adding a lower-service parameter.", "candidates": [ { "value": [ - "需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令" + "用户要查看群内被 Pin 的消息而不是侧边栏置顶会话时,改用 +messages-list-pin" ], "source": "internal/cli/schema_hints/selection/chat.json", "precedence": "reviewed_explicit", "selected": true, - "review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts." + "review_reason": "Reviewed after live testing list_top_conversations: singleChat is present on every tested row and is normalized locally to group/direct without adding a lower-service parameter." } ] }, @@ -7299,14 +7300,14 @@ "source": "internal/cli/schema_hints/metadata/chat.json", "precedence": "reviewed_explicit", "resolution": "highest_precedence", - "review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.", + "review_reason": "Reviewed against the mounted Shortcut: --type all|group|direct is a local semantic filter over list_top_conversations.singleChat, so the composite interface owns validation and normalized output without changing lower-service arguments.", "candidates": [ { "value": "not_required", "source": "internal/cli/schema_hints/metadata/chat.json", "precedence": "reviewed_explicit", "selected": true, - "review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity." + "review_reason": "Reviewed against the mounted Shortcut: --type all|group|direct is a local semantic filter over list_top_conversations.singleChat, so the composite interface owns validation and normalized output without changing lower-service arguments." } ] }, @@ -7315,34 +7316,36 @@ "source": "internal/cli/schema_hints/metadata/chat.json", "precedence": "reviewed_explicit", "resolution": "highest_precedence", - "review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.", + "review_reason": "Reviewed against the mounted Shortcut: --type all|group|direct is a local semantic filter over list_top_conversations.singleChat, so the composite interface owns validation and normalized output without changing lower-service arguments.", "candidates": [ { "value": "read", "source": "internal/cli/schema_hints/metadata/chat.json", "precedence": "reviewed_explicit", "selected": true, - "review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity." + "review_reason": "Reviewed against the mounted Shortcut: --type all|group|direct is a local semantic filter over list_top_conversations.singleChat, so the composite interface owns validation and normalized output without changing lower-service arguments." } ] }, "examples": { "value": [ - "dws chat +conversation-list-top --limit 1000" + "dws chat +conversation-list-top --type group --limit 1000", + "dws chat +conversation-list-top --type direct --limit 1000" ], "source": "internal/cli/schema_hints/selection/chat.json", "precedence": "reviewed_explicit", "resolution": "highest_precedence", - "review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.", + "review_reason": "Reviewed after live testing list_top_conversations: singleChat is present on every tested row and is normalized locally to group/direct without adding a lower-service parameter.", "candidates": [ { "value": [ - "dws chat +conversation-list-top --limit 1000" + "dws chat +conversation-list-top --type group --limit 1000", + "dws chat +conversation-list-top --type direct --limit 1000" ], "source": "internal/cli/schema_hints/selection/chat.json", "precedence": "reviewed_explicit", "selected": true, - "review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts." + "review_reason": "Reviewed after live testing list_top_conversations: singleChat is present on every tested row and is normalized locally to group/direct without adding a lower-service parameter." } ] }, @@ -7351,14 +7354,14 @@ "source": "internal/cli/schema_hints/metadata/chat.json", "precedence": "reviewed_explicit", "resolution": "highest_precedence", - "review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.", + "review_reason": "Reviewed against the mounted Shortcut: --type all|group|direct is a local semantic filter over list_top_conversations.singleChat, so the composite interface owns validation and normalized output without changing lower-service arguments.", "candidates": [ { "value": "idempotent", "source": "internal/cli/schema_hints/metadata/chat.json", "precedence": "reviewed_explicit", "selected": true, - "review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity." + "review_reason": "Reviewed against the mounted Shortcut: --type all|group|direct is a local semantic filter over list_top_conversations.singleChat, so the composite interface owns validation and normalized output without changing lower-service arguments." } ] }, @@ -7367,14 +7370,14 @@ "source": "internal/cli/schema_hints/metadata/chat.json", "precedence": "reviewed_explicit", "resolution": "highest_precedence", - "review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.", + "review_reason": "Reviewed against the mounted Shortcut: --type all|group|direct is a local semantic filter over list_top_conversations.singleChat, so the composite interface owns validation and normalized output without changing lower-service arguments.", "candidates": [ { "value": "composite", "source": "internal/cli/schema_hints/metadata/chat.json", "precedence": "reviewed_explicit", "selected": true, - "review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity." + "review_reason": "Reviewed against the mounted Shortcut: --type all|group|direct is a local semantic filter over list_top_conversations.singleChat, so the composite interface owns validation and normalized output without changing lower-service arguments." } ] }, @@ -7383,14 +7386,14 @@ "source": "internal/cli/schema_hints/metadata/chat.json", "precedence": "reviewed_explicit", "resolution": "highest_precedence", - "review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.", + "review_reason": "Reviewed against the mounted Shortcut: --type all|group|direct is a local semantic filter over list_top_conversations.singleChat, so the composite interface owns validation and normalized output without changing lower-service arguments.", "candidates": [ { "value": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.", "source": "internal/cli/schema_hints/metadata/chat.json", "precedence": "reviewed_explicit", "selected": true, - "review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity." + "review_reason": "Reviewed against the mounted Shortcut: --type all|group|direct is a local semantic filter over list_top_conversations.singleChat, so the composite interface owns validation and normalized output without changing lower-service arguments." } ] }, @@ -7415,21 +7418,21 @@ "source": "internal/cli/schema_hints/metadata/chat.json", "precedence": "reviewed_explicit", "resolution": "highest_precedence", - "review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.", + "review_reason": "Reviewed against the mounted Shortcut: --type all|group|direct is a local semantic filter over list_top_conversations.singleChat, so the composite interface owns validation and normalized output without changing lower-service arguments.", "candidates": [ { "value": true, "source": "internal/cli/schema_hints/metadata/chat.json", "precedence": "reviewed_explicit", "selected": true, - "review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity." + "review_reason": "Reviewed against the mounted Shortcut: --type all|group|direct is a local semantic filter over list_top_conversations.singleChat, so the composite interface owns validation and normalized output without changing lower-service arguments." }, { "value": true, "source": "internal/cli/schema_hints/selection/chat.json", "precedence": "reviewed_explicit", "selected": false, - "review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts." + "review_reason": "Reviewed after live testing list_top_conversations: singleChat is present on every tested row and is normalized locally to group/direct without adding a lower-service parameter." } ] }, @@ -7438,34 +7441,34 @@ "source": "internal/cli/schema_hints/metadata/chat.json", "precedence": "reviewed_explicit", "resolution": "highest_precedence", - "review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.", + "review_reason": "Reviewed against the mounted Shortcut: --type all|group|direct is a local semantic filter over list_top_conversations.singleChat, so the composite interface owns validation and normalized output without changing lower-service arguments.", "candidates": [ { "value": "low", "source": "internal/cli/schema_hints/metadata/chat.json", "precedence": "reviewed_explicit", "selected": true, - "review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity." + "review_reason": "Reviewed against the mounted Shortcut: --type all|group|direct is a local semantic filter over list_top_conversations.singleChat, so the composite interface owns validation and normalized output without changing lower-service arguments." } ] }, "use_when": { "value": [ - "当你只想查看被置顶的那些会话时使用;只读分页返回置顶会话列表,可用 --exclude-muted 排除已免打扰会话。" + "当你只想查看被置顶的那些会话时使用;只读分页返回置顶会话列表,并把下层 singleChat 规范化为 conversationType=group|direct。可用 --type group 只看群聊、--type direct 只看单聊,或用 --exclude-muted 排除已免打扰会话。" ], "source": "internal/cli/schema_hints/selection/chat.json", "precedence": "reviewed_explicit", "resolution": "highest_precedence", - "review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.", + "review_reason": "Reviewed after live testing list_top_conversations: singleChat is present on every tested row and is normalized locally to group/direct without adding a lower-service parameter.", "candidates": [ { "value": [ - "当你只想查看被置顶的那些会话时使用;只读分页返回置顶会话列表,可用 --exclude-muted 排除已免打扰会话。" + "当你只想查看被置顶的那些会话时使用;只读分页返回置顶会话列表,并把下层 singleChat 规范化为 conversationType=group|direct。可用 --type group 只看群聊、--type direct 只看单聊,或用 --exclude-muted 排除已免打扰会话。" ], "source": "internal/cli/schema_hints/selection/chat.json", "precedence": "reviewed_explicit", "selected": true, - "review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts." + "review_reason": "Reviewed after live testing list_top_conversations: singleChat is present on every tested row and is normalized locally to group/direct without adding a lower-service parameter." } ] } @@ -7483,7 +7486,7 @@ "internal/cli/schema_hints/selection/chat.json" ], "use_when": [ - "当你只想查看被置顶的那些会话时使用;只读分页返回置顶会话列表,可用 --exclude-muted 排除已免打扰会话。" + "当你只想查看被置顶的那些会话时使用;只读分页返回置顶会话列表,并把下层 singleChat 规范化为 conversationType=group|direct。可用 --type group 只看群聊、--type direct 只看单聊,或用 --exclude-muted 排除已免打扰会话。" ] }, "chat +dm": { @@ -8795,14 +8798,14 @@ "source": "internal/cli/schema_hints/selection/chat.json", "precedence": "reviewed_explicit", "resolution": "highest_precedence", - "review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.", + "review_reason": "Reviewed against the built-in Shortcut, executable Cobra flags, safe relative-path download contract, and per-resource failure ledger; this selection update advertises the new optional resource workflow without changing its runtime safety facts.", "candidates": [ { "value": "根据消息 ID 批量查询消息(最多 50 条)", "source": "internal/cli/schema_hints/selection/chat.json", "precedence": "reviewed_explicit", "selected": true, - "review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts." + "review_reason": "Reviewed against the built-in Shortcut, executable Cobra flags, safe relative-path download contract, and per-resource failure ledger; this selection update advertises the new optional resource workflow without changing its runtime safety facts." } ] }, @@ -8829,7 +8832,7 @@ "source": "internal/cli/schema_hints/selection/chat.json", "precedence": "reviewed_explicit", "resolution": "highest_precedence", - "review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.", + "review_reason": "Reviewed against the built-in Shortcut, executable Cobra flags, safe relative-path download contract, and per-resource failure ledger; this selection update advertises the new optional resource workflow without changing its runtime safety facts.", "candidates": [ { "value": [ @@ -8838,7 +8841,7 @@ "source": "internal/cli/schema_hints/selection/chat.json", "precedence": "reviewed_explicit", "selected": true, - "review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts." + "review_reason": "Reviewed against the built-in Shortcut, executable Cobra flags, safe relative-path download contract, and per-resource failure ledger; this selection update advertises the new optional resource workflow without changing its runtime safety facts." } ] }, @@ -8881,7 +8884,7 @@ "source": "internal/cli/schema_hints/selection/chat.json", "precedence": "reviewed_explicit", "resolution": "highest_precedence", - "review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.", + "review_reason": "Reviewed against the built-in Shortcut, executable Cobra flags, safe relative-path download contract, and per-resource failure ledger; this selection update advertises the new optional resource workflow without changing its runtime safety facts.", "candidates": [ { "value": [ @@ -8890,7 +8893,7 @@ "source": "internal/cli/schema_hints/selection/chat.json", "precedence": "reviewed_explicit", "selected": true, - "review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts." + "review_reason": "Reviewed against the built-in Shortcut, executable Cobra flags, safe relative-path download contract, and per-resource failure ledger; this selection update advertises the new optional resource workflow without changing its runtime safety facts." } ] }, @@ -8977,7 +8980,7 @@ "source": "internal/cli/schema_hints/selection/chat.json", "precedence": "reviewed_explicit", "selected": false, - "review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts." + "review_reason": "Reviewed against the built-in Shortcut, executable Cobra flags, safe relative-path download contract, and per-resource failure ledger; this selection update advertises the new optional resource workflow without changing its runtime safety facts." } ] }, @@ -8999,21 +9002,21 @@ }, "use_when": { "value": [ - "当你已有一批消息 openMsgId、需要批量取回它们的详细内容时使用;只读,一次最多查询 50 条。" + "当你已有一批消息 openMsgId、需要批量取回完整详情、reaction 和可执行资源引用时使用;一次最多 50 条。--download-resources 可把所有可识别 mediaId 安全下载到工作目录内,并逐资源返回成功/失败 ledger。" ], "source": "internal/cli/schema_hints/selection/chat.json", "precedence": "reviewed_explicit", "resolution": "highest_precedence", - "review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.", + "review_reason": "Reviewed against the built-in Shortcut, executable Cobra flags, safe relative-path download contract, and per-resource failure ledger; this selection update advertises the new optional resource workflow without changing its runtime safety facts.", "candidates": [ { "value": [ - "当你已有一批消息 openMsgId、需要批量取回它们的详细内容时使用;只读,一次最多查询 50 条。" + "当你已有一批消息 openMsgId、需要批量取回完整详情、reaction 和可执行资源引用时使用;一次最多 50 条。--download-resources 可把所有可识别 mediaId 安全下载到工作目录内,并逐资源返回成功/失败 ledger。" ], "source": "internal/cli/schema_hints/selection/chat.json", "precedence": "reviewed_explicit", "selected": true, - "review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts." + "review_reason": "Reviewed against the built-in Shortcut, executable Cobra flags, safe relative-path download contract, and per-resource failure ledger; this selection update advertises the new optional resource workflow without changing its runtime safety facts." } ] } @@ -9031,7 +9034,7 @@ "internal/cli/schema_hints/selection/chat.json" ], "use_when": [ - "当你已有一批消息 openMsgId、需要批量取回它们的详细内容时使用;只读,一次最多查询 50 条。" + "当你已有一批消息 openMsgId、需要批量取回完整详情、reaction 和可执行资源引用时使用;一次最多 50 条。--download-resources 可把所有可识别 mediaId 安全下载到工作目录内,并逐资源返回成功/失败 ledger。" ] }, "chat +messages-query-send-status": { diff --git a/internal/cli/schema_agent_metadata/index.json b/internal/cli/schema_agent_metadata/index.json index 96d8b976..c09b5eed 100644 --- a/internal/cli/schema_agent_metadata/index.json +++ b/internal/cli/schema_agent_metadata/index.json @@ -1,6 +1,6 @@ { "version": 1, - "source_hash": "sha256:f71480e58eda8f2be041e07d7faf6fb20f5c21f861995d3c4dacc311b46c3e81", + "source_hash": "sha256:1fed2a8bc5328212fdf646c3e68d465a2a440b2d303c1ad120981e06c97da487", "surface_hash": "sha256:4cf8460240b19f896c3a330c69982cbb5f57aa8576cdf30373172082eea893ed", "coverage": { "surface_products": 26, diff --git a/internal/cli/schema_agent_metadata_audit.json b/internal/cli/schema_agent_metadata_audit.json index 61308aab..625f7155 100644 --- a/internal/cli/schema_agent_metadata_audit.json +++ b/internal/cli/schema_agent_metadata_audit.json @@ -1,6 +1,6 @@ { "version": 1, - "source_hash": "sha256:f71480e58eda8f2be041e07d7faf6fb20f5c21f861995d3c4dacc311b46c3e81", + "source_hash": "sha256:1fed2a8bc5328212fdf646c3e68d465a2a440b2d303c1ad120981e06c97da487", "surface_hash": "sha256:4cf8460240b19f896c3a330c69982cbb5f57aa8576cdf30373172082eea893ed", "source_files": 160, "hint_files": 54, diff --git a/internal/cli/schema_catalog/catalog.json b/internal/cli/schema_catalog/catalog.json index c41e0c8a..d281439a 100644 --- a/internal/cli/schema_catalog/catalog.json +++ b/internal/cli/schema_catalog/catalog.json @@ -1,12 +1,12 @@ { "version": 1, "surface_hash": "sha256:4cf8460240b19f896c3a330c69982cbb5f57aa8576cdf30373172082eea893ed", - "source_hash": "sha256:8fd0bba48b8e50ecacf247ef4ff259b92287831cf0dc8a29dc812f7d55fdf7f2", + "source_hash": "sha256:a933860d7efdc55d1868cb1efe8ff56c493a48cbffae246e2013f4394e70259a", "catalog": { "agent_metadata": { "products_with_metadata": 26, "source": "embedded-skill-metadata", - "source_hash": "sha256:f71480e58eda8f2be041e07d7faf6fb20f5c21f861995d3c4dacc311b46c3e81", + "source_hash": "sha256:1fed2a8bc5328212fdf646c3e68d465a2a440b2d303c1ad120981e06c97da487", "surface_hash": "sha256:4cf8460240b19f896c3a330c69982cbb5f57aa8576cdf30373172082eea893ed", "surface_products": 26, "surface_tools": 813, @@ -9823,7 +9823,7 @@ "cli_name": "+category-create", "cli_path": "chat +category-create", "confirmation": "user_required", - "description": "当你想新建一个会话分组来归类会话时使用;会实际创建分组并返回其 ID,需传分组名称 --title。", + "description": "当你想新建一个会话分组来归类会话时使用;会实际创建分组并返回其 ID,需传最多 15 个字符的分组名称 --title。\n\n参数约束:\n - --title 去除首尾空白后必须非空,且最多 15 个字符", "effect": "write", "idempotency": "unknown", "interface_mode": "composite", @@ -9834,7 +9834,7 @@ "risk": "medium", "title": "创建用户自定义会话分组", "use_when": [ - "当你想新建一个会话分组来归类会话时使用;会实际创建分组并返回其 ID,需传分组名称 --title。" + "当你想新建一个会话分组来归类会话时使用;会实际创建分组并返回其 ID,需传最多 15 个字符的分组名称 --title。" ] }, { @@ -9901,7 +9901,7 @@ "cli_name": "+category-rename", "cli_path": "chat +category-rename", "confirmation": "user_required", - "description": "当你想重命名已有的自定义会话分组时使用;会实际更新分组名称,需传 categoryId 和新名称 --title。", + "description": "当你想重命名已有的自定义会话分组时使用;会实际更新分组名称,需传 categoryId 和最多 15 个字符的新名称 --title。\n\n参数约束:\n - --title 去除首尾空白后必须非空,且最多 15 个字符", "effect": "write", "idempotency": "unknown", "interface_mode": "composite", @@ -9912,7 +9912,7 @@ "risk": "medium", "title": "更新用户自定义会话分组的名称", "use_when": [ - "当你想重命名已有的自定义会话分组时使用;会实际更新分组名称,需传 categoryId 和新名称 --title。" + "当你想重命名已有的自定义会话分组时使用;会实际更新分组名称,需传 categoryId 和最多 15 个字符的新名称 --title。" ] }, { @@ -10437,17 +10437,17 @@ }, { "agent_metadata_source": "embedded-skill-metadata", - "agent_summary": "拉取置顶会话列表", + "agent_summary": "拉取置顶会话列表,可只看群聊或单聊", "agent_summary_source": "dws-agent-selection/chat", "availability": "available", "avoid_when": [ - "需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令" + "用户要查看群内被 Pin 的消息而不是侧边栏置顶会话时,改用 +messages-list-pin" ], "canonical_path": "chat.shortcut_conversation_list_top", "cli_name": "+conversation-list-top", "cli_path": "chat +conversation-list-top", "confirmation": "not_required", - "description": "当你只想查看被置顶的那些会话时使用;只读分页返回置顶会话列表,可用 --exclude-muted 排除已免打扰会话。", + "description": "当你只想查看被置顶的那些会话时使用;只读分页返回置顶会话列表,并把下层 singleChat 规范化为 conversationType=group|direct。可用 --type group 只看群聊、--type direct 只看单聊,或用 --exclude-muted 排除已免打扰会话。", "effect": "read", "idempotency": "idempotent", "interface_mode": "composite", @@ -10456,9 +10456,9 @@ "primary_cli_path": "chat +conversation-list-top", "reviewed": true, "risk": "low", - "title": "拉取置顶会话列表", + "title": "拉取置顶会话列表,可只看群聊或单聊", "use_when": [ - "当你只想查看被置顶的那些会话时使用;只读分页返回置顶会话列表,可用 --exclude-muted 排除已免打扰会话。" + "当你只想查看被置顶的那些会话时使用;只读分页返回置顶会话列表,并把下层 singleChat 规范化为 conversationType=group|direct。可用 --type group 只看群聊、--type direct 只看单聊,或用 --exclude-muted 排除已免打扰会话。" ] }, { @@ -10603,7 +10603,7 @@ "cli_name": "+messages-mget", "cli_path": "chat +messages-mget", "confirmation": "not_required", - "description": "当你已有一批消息 openMsgId、需要批量取回它们的详细内容时使用;只读,一次最多查询 50 条。", + "description": "当你已有一批消息 openMsgId、需要批量取回完整详情、reaction 和可执行资源引用时使用;一次最多 50 条。--download-resources 可把所有可识别 mediaId 安全下载到工作目录内,并逐资源返回成功/失败 ledger。\n\n参数约束:\n - --msg-ids 去重后必须包含 1-50 条消息 ID\n - --output-dir 必须是工作目录内的相对路径,不允许绝对路径或 .. 逃逸", "effect": "read", "idempotency": "idempotent", "interface_mode": "composite", @@ -10614,7 +10614,7 @@ "risk": "low", "title": "根据消息 ID 批量查询消息(最多 50 条)", "use_when": [ - "当你已有一批消息 openMsgId、需要批量取回它们的详细内容时使用;只读,一次最多查询 50 条。" + "当你已有一批消息 openMsgId、需要批量取回完整详情、reaction 和可执行资源引用时使用;一次最多 50 条。--download-resources 可把所有可识别 mediaId 安全下载到工作目录内,并逐资源返回成功/失败 ledger。" ] }, { diff --git a/internal/cli/schema_catalog/tools/chat.json b/internal/cli/schema_catalog/tools/chat.json index 4acab1bd..b7c65806 100644 --- a/internal/cli/schema_catalog/tools/chat.json +++ b/internal/cli/schema_catalog/tools/chat.json @@ -38094,7 +38094,7 @@ "is_alias": false, "metadata_source": "embedded-mcp-metadata", "name": "search_messages", - "parameter_count": 11, + "parameter_count": 14, "parameters": { "at-ids": { "description": "@指定人的 openDingTalkId 列表,逗号分隔(可选)", @@ -38429,6 +38429,103 @@ "required": false, "type": "string" }, + "conversation-type": { + "description": "下层会话类型过滤值(可选,以当前 IM Schema 支持值为准)", + "field_provenance": { + "description": { + "candidates": [ + { + "precedence": "cobra_contract", + "selected": true, + "source": "cobra_usage", + "value": "下层会话类型过滤值(可选,以当前 IM Schema 支持值为准)" + }, + { + "precedence": "mcp_metadata", + "selected": false, + "source": "mcp_metadata", + "value": "searchConvType" + }, + { + "precedence": "default", + "selected": false, + "source": "default", + "value": "" + } + ], + "precedence": "cobra_contract", + "resolution": "highest_precedence", + "source": "cobra_usage", + "value": "下层会话类型过滤值(可选,以当前 IM Schema 支持值为准)" + }, + "property": { + "candidates": [ + { + "precedence": "versioned_binding", + "selected": true, + "source": "versioned_parameter_binding", + "value": "searchConvType" + }, + { + "precedence": "inference", + "selected": false, + "source": "flag_name_inference", + "value": "conversationType" + } + ], + "precedence": "versioned_binding", + "resolution": "highest_precedence", + "source": "versioned_parameter_binding", + "value": "searchConvType" + }, + "required": { + "candidates": [ + { + "precedence": "default", + "selected": true, + "source": "default", + "value": false + } + ], + "precedence": "default", + "resolution": "fallback", + "source": "default", + "value": false + }, + "required_when": { + "candidates": [ + { + "precedence": "default", + "selected": true, + "source": "default", + "value": "" + } + ], + "precedence": "default", + "resolution": "highest_precedence", + "source": "default", + "value": "" + }, + "type": { + "candidates": [ + { + "precedence": "cobra_contract", + "selected": true, + "source": "cobra_flag_type", + "value": "string" + } + ], + "precedence": "cobra_contract", + "resolution": "highest_precedence", + "source": "cobra_flag_type", + "value": "string" + } + }, + "interface_description": "searchConvType", + "property": "searchConvType", + "required": false, + "type": "string" + }, "cursor": { "default": "0", "description": "分页游标(默认 \"0\")", @@ -38785,6 +38882,200 @@ "required": false, "type": "integer" }, + "message-type": { + "description": "下层消息类型过滤值(可选,以当前 IM Schema 支持值为准)", + "field_provenance": { + "description": { + "candidates": [ + { + "precedence": "cobra_contract", + "selected": true, + "source": "cobra_usage", + "value": "下层消息类型过滤值(可选,以当前 IM Schema 支持值为准)" + }, + { + "precedence": "mcp_metadata", + "selected": false, + "source": "mcp_metadata", + "value": "messageType" + }, + { + "precedence": "default", + "selected": false, + "source": "default", + "value": "" + } + ], + "precedence": "cobra_contract", + "resolution": "highest_precedence", + "source": "cobra_usage", + "value": "下层消息类型过滤值(可选,以当前 IM Schema 支持值为准)" + }, + "property": { + "candidates": [ + { + "precedence": "versioned_binding", + "selected": true, + "source": "versioned_parameter_binding", + "value": "messageType" + }, + { + "precedence": "inference", + "selected": false, + "source": "flag_name_inference", + "value": "messageType" + } + ], + "precedence": "versioned_binding", + "resolution": "highest_precedence", + "source": "versioned_parameter_binding", + "value": "messageType" + }, + "required": { + "candidates": [ + { + "precedence": "default", + "selected": true, + "source": "default", + "value": false + } + ], + "precedence": "default", + "resolution": "fallback", + "source": "default", + "value": false + }, + "required_when": { + "candidates": [ + { + "precedence": "default", + "selected": true, + "source": "default", + "value": "" + } + ], + "precedence": "default", + "resolution": "highest_precedence", + "source": "default", + "value": "" + }, + "type": { + "candidates": [ + { + "precedence": "cobra_contract", + "selected": true, + "source": "cobra_flag_type", + "value": "string" + } + ], + "precedence": "cobra_contract", + "resolution": "highest_precedence", + "source": "cobra_flag_type", + "value": "string" + } + }, + "interface_description": "messageType", + "property": "messageType", + "required": false, + "type": "string" + }, + "only-robot": { + "description": "只搜索机器人消息(可选;显式传 false 时也会传给下层)", + "field_provenance": { + "description": { + "candidates": [ + { + "precedence": "cobra_contract", + "selected": true, + "source": "cobra_usage", + "value": "只搜索机器人消息(可选;显式传 false 时也会传给下层)" + }, + { + "precedence": "mcp_metadata", + "selected": false, + "source": "mcp_metadata", + "value": "onlyRobotMessages" + }, + { + "precedence": "default", + "selected": false, + "source": "default", + "value": "" + } + ], + "precedence": "cobra_contract", + "resolution": "highest_precedence", + "source": "cobra_usage", + "value": "只搜索机器人消息(可选;显式传 false 时也会传给下层)" + }, + "property": { + "candidates": [ + { + "precedence": "versioned_binding", + "selected": true, + "source": "versioned_parameter_binding", + "value": "onlyRobotMessages" + }, + { + "precedence": "inference", + "selected": false, + "source": "flag_name_inference", + "value": "onlyRobot" + } + ], + "precedence": "versioned_binding", + "resolution": "highest_precedence", + "source": "versioned_parameter_binding", + "value": "onlyRobotMessages" + }, + "required": { + "candidates": [ + { + "precedence": "default", + "selected": true, + "source": "default", + "value": false + } + ], + "precedence": "default", + "resolution": "fallback", + "source": "default", + "value": false + }, + "required_when": { + "candidates": [ + { + "precedence": "default", + "selected": true, + "source": "default", + "value": "" + } + ], + "precedence": "default", + "resolution": "highest_precedence", + "source": "default", + "value": "" + }, + "type": { + "candidates": [ + { + "precedence": "cobra_contract", + "selected": true, + "source": "cobra_flag_type", + "value": "boolean" + } + ], + "precedence": "cobra_contract", + "resolution": "highest_precedence", + "source": "cobra_flag_type", + "value": "boolean" + } + }, + "interface_description": "onlyRobotMessages", + "property": "onlyRobotMessages", + "required": false, + "type": "boolean" + }, "query": { "description": "搜索关键词(可选)", "field_provenance": { @@ -50912,8 +51203,99 @@ "interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.", "is_alias": false, "name": "shortcut_at_me", - "parameter_count": 1, + "parameter_count": 4, "parameters": { + "cursor": { + "default": "0", + "description": "分页游标,翻页传上次的 nextCursor", + "field_provenance": { + "description": { + "candidates": [ + { + "precedence": "cobra_contract", + "selected": true, + "source": "cobra_usage", + "value": "分页游标,翻页传上次的 nextCursor" + }, + { + "precedence": "default", + "selected": false, + "source": "default", + "value": "" + } + ], + "precedence": "cobra_contract", + "resolution": "highest_precedence", + "source": "cobra_usage", + "value": "分页游标,翻页传上次的 nextCursor" + }, + "property": { + "candidates": [ + { + "precedence": "inference", + "selected": true, + "source": "flag_name_inference", + "value": "cursor" + } + ], + "precedence": "inference", + "resolution": "highest_precedence", + "source": "flag_name_inference", + "value": "cursor" + }, + "required": { + "candidates": [ + { + "precedence": "cobra_contract", + "selected": true, + "source": "cobra_nonzero_default", + "value": false + }, + { + "precedence": "default", + "selected": false, + "source": "default", + "value": false + } + ], + "precedence": "cobra_contract", + "resolution": "highest_precedence", + "source": "cobra_nonzero_default", + "value": false + }, + "required_when": { + "candidates": [ + { + "precedence": "default", + "selected": true, + "source": "default", + "value": "" + } + ], + "precedence": "default", + "resolution": "highest_precedence", + "source": "default", + "value": "" + }, + "type": { + "candidates": [ + { + "precedence": "cobra_contract", + "selected": true, + "source": "cobra_flag_type", + "value": "string" + } + ], + "precedence": "cobra_contract", + "resolution": "highest_precedence", + "source": "cobra_flag_type", + "value": "string" + } + }, + "property": "cursor", + "required": false, + "type": "string" + }, "days": { "default": "7", "description": "回溯天数(可选,默认 7)", @@ -51004,6 +51386,187 @@ "property": "days", "required": false, "type": "integer" + }, + "limit": { + "default": "50", + "description": "每页返回数量(默认 50)", + "field_provenance": { + "description": { + "candidates": [ + { + "precedence": "cobra_contract", + "selected": true, + "source": "cobra_usage", + "value": "每页返回数量(默认 50)" + }, + { + "precedence": "default", + "selected": false, + "source": "default", + "value": "" + } + ], + "precedence": "cobra_contract", + "resolution": "highest_precedence", + "source": "cobra_usage", + "value": "每页返回数量(默认 50)" + }, + "property": { + "candidates": [ + { + "precedence": "inference", + "selected": true, + "source": "flag_name_inference", + "value": "limit" + } + ], + "precedence": "inference", + "resolution": "highest_precedence", + "source": "flag_name_inference", + "value": "limit" + }, + "required": { + "candidates": [ + { + "precedence": "cobra_contract", + "selected": true, + "source": "cobra_nonzero_default", + "value": false + }, + { + "precedence": "default", + "selected": false, + "source": "default", + "value": false + } + ], + "precedence": "cobra_contract", + "resolution": "highest_precedence", + "source": "cobra_nonzero_default", + "value": false + }, + "required_when": { + "candidates": [ + { + "precedence": "default", + "selected": true, + "source": "default", + "value": "" + } + ], + "precedence": "default", + "resolution": "highest_precedence", + "source": "default", + "value": "" + }, + "type": { + "candidates": [ + { + "precedence": "cobra_contract", + "selected": true, + "source": "cobra_flag_type", + "value": "integer" + } + ], + "precedence": "cobra_contract", + "resolution": "highest_precedence", + "source": "cobra_flag_type", + "value": "integer" + } + }, + "property": "limit", + "required": false, + "type": "integer" + }, + "no-reactions": { + "description": "不输出消息 reaction(默认输出)", + "field_provenance": { + "description": { + "candidates": [ + { + "precedence": "cobra_contract", + "selected": true, + "source": "cobra_usage", + "value": "不输出消息 reaction(默认输出)" + }, + { + "precedence": "default", + "selected": false, + "source": "default", + "value": "" + } + ], + "precedence": "cobra_contract", + "resolution": "highest_precedence", + "source": "cobra_usage", + "value": "不输出消息 reaction(默认输出)" + }, + "property": { + "candidates": [ + { + "precedence": "inference", + "selected": true, + "source": "flag_name_inference", + "value": "noReactions" + } + ], + "precedence": "inference", + "resolution": "highest_precedence", + "source": "flag_name_inference", + "value": "noReactions" + }, + "required": { + "candidates": [ + { + "precedence": "cobra_contract", + "selected": true, + "source": "cobra_nonzero_default", + "value": false + }, + { + "precedence": "default", + "selected": false, + "source": "default", + "value": false + } + ], + "precedence": "cobra_contract", + "resolution": "highest_precedence", + "source": "cobra_nonzero_default", + "value": false + }, + "required_when": { + "candidates": [ + { + "precedence": "default", + "selected": true, + "source": "default", + "value": "" + } + ], + "precedence": "default", + "resolution": "highest_precedence", + "source": "default", + "value": "" + }, + "type": { + "candidates": [ + { + "precedence": "cobra_contract", + "selected": true, + "source": "cobra_flag_type", + "value": "boolean" + } + ], + "precedence": "cobra_contract", + "resolution": "highest_precedence", + "source": "cobra_flag_type", + "value": "boolean" + } + }, + "property": "noReactions", + "required": false, + "type": "boolean" } }, "path": "chat.shortcut_at_me", @@ -52800,7 +53363,7 @@ "cli_name": "+category-create", "cli_path": "chat +category-create", "confirmation": "user_required", - "description": "当你想新建一个会话分组来归类会话时使用;会实际创建分组并返回其 ID,需传分组名称 --title。", + "description": "当你想新建一个会话分组来归类会话时使用;会实际创建分组并返回其 ID,需传最多 15 个字符的分组名称 --title。\n\n参数约束:\n - --title 去除首尾空白后必须非空,且最多 15 个字符", "display": "群聊 / 消息 / 机器人", "effect": "write", "effect_source": "agent-hint", @@ -52898,13 +53461,13 @@ "precedence": "cobra_help", "selected": true, "source": "cobra_help", - "value": "当你想新建一个会话分组来归类会话时使用;会实际创建分组并返回其 ID,需传分组名称 --title。" + "value": "当你想新建一个会话分组来归类会话时使用;会实际创建分组并返回其 ID,需传最多 15 个字符的分组名称 --title。\n\n参数约束:\n - --title 去除首尾空白后必须非空,且最多 15 个字符" } ], "precedence": "cobra_help", "resolution": "highest_precedence", "source": "cobra_help", - "value": "当你想新建一个会话分组来归类会话时使用;会实际创建分组并返回其 ID,需传分组名称 --title。" + "value": "当你想新建一个会话分组来归类会话时使用;会实际创建分组并返回其 ID,需传最多 15 个字符的分组名称 --title。\n\n参数约束:\n - --title 去除首尾空白后必须非空,且最多 15 个字符" }, "effect": { "candidates": [ @@ -53067,7 +53630,7 @@ "selected": true, "source": "internal/cli/schema_hints/selection/chat.json", "value": [ - "当你想新建一个会话分组来归类会话时使用;会实际创建分组并返回其 ID,需传分组名称 --title。" + "当你想新建一个会话分组来归类会话时使用;会实际创建分组并返回其 ID,需传最多 15 个字符的分组名称 --title。" ] } ], @@ -53076,7 +53639,7 @@ "review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.", "source": "internal/cli/schema_hints/selection/chat.json", "value": [ - "当你想新建一个会话分组来归类会话时使用;会实际创建分组并返回其 ID,需传分组名称 --title。" + "当你想新建一个会话分组来归类会话时使用;会实际创建分组并返回其 ID,需传最多 15 个字符的分组名称 --title。" ] } }, @@ -53089,7 +53652,7 @@ "parameter_count": 1, "parameters": { "title": { - "description": "分组名称(必填)", + "description": "分组名称;去除首尾空白后必须非空,且最多 15 个字符(必填)", "field_provenance": { "description": { "candidates": [ @@ -53097,7 +53660,7 @@ "precedence": "cobra_contract", "selected": true, "source": "cobra_usage", - "value": "分组名称(必填)" + "value": "分组名称;去除首尾空白后必须非空,且最多 15 个字符(必填)" }, { "precedence": "default", @@ -53109,7 +53672,7 @@ "precedence": "cobra_contract", "resolution": "highest_precedence", "source": "cobra_usage", - "value": "分组名称(必填)" + "value": "分组名称;去除首尾空白后必须非空,且最多 15 个字符(必填)" }, "property": { "candidates": [ @@ -53193,7 +53756,7 @@ "source": "reviewed_command_registry", "title": "创建用户自定义会话分组", "use_when": [ - "当你想新建一个会话分组来归类会话时使用;会实际创建分组并返回其 ID,需传分组名称 --title。" + "当你想新建一个会话分组来归类会话时使用;会实际创建分组并返回其 ID,需传最多 15 个字符的分组名称 --title。" ] }, "chat.shortcut_category_delete": { @@ -53948,7 +54511,7 @@ "cli_name": "+category-rename", "cli_path": "chat +category-rename", "confirmation": "user_required", - "description": "当你想重命名已有的自定义会话分组时使用;会实际更新分组名称,需传 categoryId 和新名称 --title。", + "description": "当你想重命名已有的自定义会话分组时使用;会实际更新分组名称,需传 categoryId 和最多 15 个字符的新名称 --title。\n\n参数约束:\n - --title 去除首尾空白后必须非空,且最多 15 个字符", "display": "群聊 / 消息 / 机器人", "effect": "write", "effect_source": "agent-hint", @@ -54046,13 +54609,13 @@ "precedence": "cobra_help", "selected": true, "source": "cobra_help", - "value": "当你想重命名已有的自定义会话分组时使用;会实际更新分组名称,需传 categoryId 和新名称 --title。" + "value": "当你想重命名已有的自定义会话分组时使用;会实际更新分组名称,需传 categoryId 和最多 15 个字符的新名称 --title。\n\n参数约束:\n - --title 去除首尾空白后必须非空,且最多 15 个字符" } ], "precedence": "cobra_help", "resolution": "highest_precedence", "source": "cobra_help", - "value": "当你想重命名已有的自定义会话分组时使用;会实际更新分组名称,需传 categoryId 和新名称 --title。" + "value": "当你想重命名已有的自定义会话分组时使用;会实际更新分组名称,需传 categoryId 和最多 15 个字符的新名称 --title。\n\n参数约束:\n - --title 去除首尾空白后必须非空,且最多 15 个字符" }, "effect": { "candidates": [ @@ -54215,7 +54778,7 @@ "selected": true, "source": "internal/cli/schema_hints/selection/chat.json", "value": [ - "当你想重命名已有的自定义会话分组时使用;会实际更新分组名称,需传 categoryId 和新名称 --title。" + "当你想重命名已有的自定义会话分组时使用;会实际更新分组名称,需传 categoryId 和最多 15 个字符的新名称 --title。" ] } ], @@ -54224,7 +54787,7 @@ "review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.", "source": "internal/cli/schema_hints/selection/chat.json", "value": [ - "当你想重命名已有的自定义会话分组时使用;会实际更新分组名称,需传 categoryId 和新名称 --title。" + "当你想重命名已有的自定义会话分组时使用;会实际更新分组名称,需传 categoryId 和最多 15 个字符的新名称 --title。" ] } }, @@ -54333,7 +54896,7 @@ "type": "integer" }, "title": { - "description": "新的分组名称(必填)", + "description": "新的分组名称;去除首尾空白后必须非空,且最多 15 个字符(必填)", "field_provenance": { "description": { "candidates": [ @@ -54341,7 +54904,7 @@ "precedence": "cobra_contract", "selected": true, "source": "cobra_usage", - "value": "新的分组名称(必填)" + "value": "新的分组名称;去除首尾空白后必须非空,且最多 15 个字符(必填)" }, { "precedence": "default", @@ -54353,7 +54916,7 @@ "precedence": "cobra_contract", "resolution": "highest_precedence", "source": "cobra_usage", - "value": "新的分组名称(必填)" + "value": "新的分组名称;去除首尾空白后必须非空,且最多 15 个字符(必填)" }, "property": { "candidates": [ @@ -54437,7 +55000,7 @@ "source": "reviewed_command_registry", "title": "更新用户自定义会话分组的名称", "use_when": [ - "当你想重命名已有的自定义会话分组时使用;会实际更新分组名称,需传 categoryId 和新名称 --title。" + "当你想重命名已有的自定义会话分组时使用;会实际更新分组名称,需传 categoryId 和最多 15 个字符的新名称 --title。" ] }, "chat.shortcut_chat_bots": { @@ -64730,45 +65293,46 @@ "internal/cli/schema_hints/metadata/chat.json", "internal/cli/schema_hints/selection/chat.json" ], - "agent_summary": "拉取置顶会话列表", + "agent_summary": "拉取置顶会话列表,可只看群聊或单聊", "agent_summary_source": "dws-agent-selection/chat", "availability": "available", "avoid_when": [ - "需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令" + "用户要查看群内被 Pin 的消息而不是侧边栏置顶会话时,改用 +messages-list-pin" ], "canonical_path": "chat.shortcut_conversation_list_top", "cli_name": "+conversation-list-top", "cli_path": "chat +conversation-list-top", "confirmation": "not_required", - "description": "当你只想查看被置顶的那些会话时使用;只读分页返回置顶会话列表,可用 --exclude-muted 排除已免打扰会话。", + "description": "当你只想查看被置顶的那些会话时使用;只读分页返回置顶会话列表,并把下层 singleChat 规范化为 conversationType=group|direct。可用 --type group 只看群聊、--type direct 只看单聊,或用 --exclude-muted 排除已免打扰会话。", "display": "群聊 / 消息 / 机器人", "effect": "read", "effect_source": "agent-hint", "examples": [ - "dws chat +conversation-list-top --limit 1000" + "dws chat +conversation-list-top --type group --limit 1000", + "dws chat +conversation-list-top --type direct --limit 1000" ], "field_provenance": { "agent_summary": { "candidates": [ { "precedence": "reviewed_explicit", - "review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.", + "review_reason": "Reviewed after live testing list_top_conversations: singleChat is present on every tested row and is normalized locally to group/direct without adding a lower-service parameter.", "selected": true, "source": "internal/cli/schema_hints/selection/chat.json", - "value": "拉取置顶会话列表" + "value": "拉取置顶会话列表,可只看群聊或单聊" } ], "precedence": "reviewed_explicit", "resolution": "highest_precedence", - "review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.", + "review_reason": "Reviewed after live testing list_top_conversations: singleChat is present on every tested row and is normalized locally to group/direct without adding a lower-service parameter.", "source": "internal/cli/schema_hints/selection/chat.json", - "value": "拉取置顶会话列表" + "value": "拉取置顶会话列表,可只看群聊或单聊" }, "availability": { "candidates": [ { "precedence": "reviewed_explicit", - "review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.", + "review_reason": "Reviewed against the mounted Shortcut: --type all|group|direct is a local semantic filter over list_top_conversations.singleChat, so the composite interface owns validation and normalized output without changing lower-service arguments.", "selected": true, "source": "internal/cli/schema_hints/metadata/chat.json", "value": "available" @@ -64776,7 +65340,7 @@ ], "precedence": "reviewed_explicit", "resolution": "highest_precedence", - "review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.", + "review_reason": "Reviewed against the mounted Shortcut: --type all|group|direct is a local semantic filter over list_top_conversations.singleChat, so the composite interface owns validation and normalized output without changing lower-service arguments.", "source": "internal/cli/schema_hints/metadata/chat.json", "value": "available" }, @@ -64784,20 +65348,20 @@ "candidates": [ { "precedence": "reviewed_explicit", - "review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.", + "review_reason": "Reviewed after live testing list_top_conversations: singleChat is present on every tested row and is normalized locally to group/direct without adding a lower-service parameter.", "selected": true, "source": "internal/cli/schema_hints/selection/chat.json", "value": [ - "需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令" + "用户要查看群内被 Pin 的消息而不是侧边栏置顶会话时,改用 +messages-list-pin" ] } ], "precedence": "reviewed_explicit", "resolution": "highest_precedence", - "review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.", + "review_reason": "Reviewed after live testing list_top_conversations: singleChat is present on every tested row and is normalized locally to group/direct without adding a lower-service parameter.", "source": "internal/cli/schema_hints/selection/chat.json", "value": [ - "需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令" + "用户要查看群内被 Pin 的消息而不是侧边栏置顶会话时,改用 +messages-list-pin" ] }, "canonical_path": { @@ -64820,7 +65384,7 @@ "candidates": [ { "precedence": "reviewed_explicit", - "review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.", + "review_reason": "Reviewed against the mounted Shortcut: --type all|group|direct is a local semantic filter over list_top_conversations.singleChat, so the composite interface owns validation and normalized output without changing lower-service arguments.", "selected": true, "source": "internal/cli/schema_hints/metadata/chat.json", "value": "not_required" @@ -64828,7 +65392,7 @@ ], "precedence": "reviewed_explicit", "resolution": "highest_precedence", - "review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.", + "review_reason": "Reviewed against the mounted Shortcut: --type all|group|direct is a local semantic filter over list_top_conversations.singleChat, so the composite interface owns validation and normalized output without changing lower-service arguments.", "source": "internal/cli/schema_hints/metadata/chat.json", "value": "not_required" }, @@ -64838,19 +65402,19 @@ "precedence": "cobra_help", "selected": true, "source": "cobra_help", - "value": "当你只想查看被置顶的那些会话时使用;只读分页返回置顶会话列表,可用 --exclude-muted 排除已免打扰会话。" + "value": "当你只想查看被置顶的那些会话时使用;只读分页返回置顶会话列表,并把下层 singleChat 规范化为 conversationType=group|direct。可用 --type group 只看群聊、--type direct 只看单聊,或用 --exclude-muted 排除已免打扰会话。" } ], "precedence": "cobra_help", "resolution": "highest_precedence", "source": "cobra_help", - "value": "当你只想查看被置顶的那些会话时使用;只读分页返回置顶会话列表,可用 --exclude-muted 排除已免打扰会话。" + "value": "当你只想查看被置顶的那些会话时使用;只读分页返回置顶会话列表,并把下层 singleChat 规范化为 conversationType=group|direct。可用 --type group 只看群聊、--type direct 只看单聊,或用 --exclude-muted 排除已免打扰会话。" }, "effect": { "candidates": [ { "precedence": "reviewed_explicit", - "review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.", + "review_reason": "Reviewed against the mounted Shortcut: --type all|group|direct is a local semantic filter over list_top_conversations.singleChat, so the composite interface owns validation and normalized output without changing lower-service arguments.", "selected": true, "source": "internal/cli/schema_hints/metadata/chat.json", "value": "read" @@ -64858,7 +65422,7 @@ ], "precedence": "reviewed_explicit", "resolution": "highest_precedence", - "review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.", + "review_reason": "Reviewed against the mounted Shortcut: --type all|group|direct is a local semantic filter over list_top_conversations.singleChat, so the composite interface owns validation and normalized output without changing lower-service arguments.", "source": "internal/cli/schema_hints/metadata/chat.json", "value": "read" }, @@ -64866,27 +65430,29 @@ "candidates": [ { "precedence": "reviewed_explicit", - "review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.", + "review_reason": "Reviewed after live testing list_top_conversations: singleChat is present on every tested row and is normalized locally to group/direct without adding a lower-service parameter.", "selected": true, "source": "internal/cli/schema_hints/selection/chat.json", "value": [ - "dws chat +conversation-list-top --limit 1000" + "dws chat +conversation-list-top --type group --limit 1000", + "dws chat +conversation-list-top --type direct --limit 1000" ] } ], "precedence": "reviewed_explicit", "resolution": "highest_precedence", - "review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.", + "review_reason": "Reviewed after live testing list_top_conversations: singleChat is present on every tested row and is normalized locally to group/direct without adding a lower-service parameter.", "source": "internal/cli/schema_hints/selection/chat.json", "value": [ - "dws chat +conversation-list-top --limit 1000" + "dws chat +conversation-list-top --type group --limit 1000", + "dws chat +conversation-list-top --type direct --limit 1000" ] }, "idempotency": { "candidates": [ { "precedence": "reviewed_explicit", - "review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.", + "review_reason": "Reviewed against the mounted Shortcut: --type all|group|direct is a local semantic filter over list_top_conversations.singleChat, so the composite interface owns validation and normalized output without changing lower-service arguments.", "selected": true, "source": "internal/cli/schema_hints/metadata/chat.json", "value": "idempotent" @@ -64894,7 +65460,7 @@ ], "precedence": "reviewed_explicit", "resolution": "highest_precedence", - "review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.", + "review_reason": "Reviewed against the mounted Shortcut: --type all|group|direct is a local semantic filter over list_top_conversations.singleChat, so the composite interface owns validation and normalized output without changing lower-service arguments.", "source": "internal/cli/schema_hints/metadata/chat.json", "value": "idempotent" }, @@ -64902,7 +65468,7 @@ "candidates": [ { "precedence": "reviewed_explicit", - "review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.", + "review_reason": "Reviewed against the mounted Shortcut: --type all|group|direct is a local semantic filter over list_top_conversations.singleChat, so the composite interface owns validation and normalized output without changing lower-service arguments.", "selected": true, "source": "internal/cli/schema_hints/metadata/chat.json", "value": "composite" @@ -64910,7 +65476,7 @@ ], "precedence": "reviewed_explicit", "resolution": "highest_precedence", - "review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.", + "review_reason": "Reviewed against the mounted Shortcut: --type all|group|direct is a local semantic filter over list_top_conversations.singleChat, so the composite interface owns validation and normalized output without changing lower-service arguments.", "source": "internal/cli/schema_hints/metadata/chat.json", "value": "composite" }, @@ -64918,7 +65484,7 @@ "candidates": [ { "precedence": "reviewed_explicit", - "review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.", + "review_reason": "Reviewed against the mounted Shortcut: --type all|group|direct is a local semantic filter over list_top_conversations.singleChat, so the composite interface owns validation and normalized output without changing lower-service arguments.", "selected": true, "source": "internal/cli/schema_hints/metadata/chat.json", "value": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref." @@ -64926,7 +65492,7 @@ ], "precedence": "reviewed_explicit", "resolution": "highest_precedence", - "review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.", + "review_reason": "Reviewed against the mounted Shortcut: --type all|group|direct is a local semantic filter over list_top_conversations.singleChat, so the composite interface owns validation and normalized output without changing lower-service arguments.", "source": "internal/cli/schema_hints/metadata/chat.json", "value": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref." }, @@ -64950,14 +65516,14 @@ "candidates": [ { "precedence": "reviewed_explicit", - "review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.", + "review_reason": "Reviewed against the mounted Shortcut: --type all|group|direct is a local semantic filter over list_top_conversations.singleChat, so the composite interface owns validation and normalized output without changing lower-service arguments.", "selected": true, "source": "internal/cli/schema_hints/metadata/chat.json", "value": true }, { "precedence": "reviewed_explicit", - "review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.", + "review_reason": "Reviewed after live testing list_top_conversations: singleChat is present on every tested row and is normalized locally to group/direct without adding a lower-service parameter.", "selected": false, "source": "internal/cli/schema_hints/selection/chat.json", "value": true @@ -64965,7 +65531,7 @@ ], "precedence": "reviewed_explicit", "resolution": "highest_precedence", - "review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.", + "review_reason": "Reviewed against the mounted Shortcut: --type all|group|direct is a local semantic filter over list_top_conversations.singleChat, so the composite interface owns validation and normalized output without changing lower-service arguments.", "source": "internal/cli/schema_hints/metadata/chat.json", "value": true }, @@ -64973,7 +65539,7 @@ "candidates": [ { "precedence": "reviewed_explicit", - "review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.", + "review_reason": "Reviewed against the mounted Shortcut: --type all|group|direct is a local semantic filter over list_top_conversations.singleChat, so the composite interface owns validation and normalized output without changing lower-service arguments.", "selected": true, "source": "internal/cli/schema_hints/metadata/chat.json", "value": "low" @@ -64981,7 +65547,7 @@ ], "precedence": "reviewed_explicit", "resolution": "highest_precedence", - "review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.", + "review_reason": "Reviewed against the mounted Shortcut: --type all|group|direct is a local semantic filter over list_top_conversations.singleChat, so the composite interface owns validation and normalized output without changing lower-service arguments.", "source": "internal/cli/schema_hints/metadata/chat.json", "value": "low" }, @@ -64991,32 +65557,32 @@ "precedence": "cobra_help", "selected": true, "source": "cobra_help", - "value": "拉取置顶会话列表" + "value": "拉取置顶会话列表,可只看群聊或单聊" } ], "precedence": "cobra_help", "resolution": "highest_precedence", "source": "cobra_help", - "value": "拉取置顶会话列表" + "value": "拉取置顶会话列表,可只看群聊或单聊" }, "use_when": { "candidates": [ { "precedence": "reviewed_explicit", - "review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.", + "review_reason": "Reviewed after live testing list_top_conversations: singleChat is present on every tested row and is normalized locally to group/direct without adding a lower-service parameter.", "selected": true, "source": "internal/cli/schema_hints/selection/chat.json", "value": [ - "当你只想查看被置顶的那些会话时使用;只读分页返回置顶会话列表,可用 --exclude-muted 排除已免打扰会话。" + "当你只想查看被置顶的那些会话时使用;只读分页返回置顶会话列表,并把下层 singleChat 规范化为 conversationType=group|direct。可用 --type group 只看群聊、--type direct 只看单聊,或用 --exclude-muted 排除已免打扰会话。" ] } ], "precedence": "reviewed_explicit", "resolution": "highest_precedence", - "review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.", + "review_reason": "Reviewed after live testing list_top_conversations: singleChat is present on every tested row and is normalized locally to group/direct without adding a lower-service parameter.", "source": "internal/cli/schema_hints/selection/chat.json", "value": [ - "当你只想查看被置顶的那些会话时使用;只读分页返回置顶会话列表,可用 --exclude-muted 排除已免打扰会话。" + "当你只想查看被置顶的那些会话时使用;只读分页返回置顶会话列表,并把下层 singleChat 规范化为 conversationType=group|direct。可用 --type group 只看群聊、--type direct 只看单聊,或用 --exclude-muted 排除已免打扰会话。" ] } }, @@ -65026,7 +65592,7 @@ "interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.", "is_alias": false, "name": "shortcut_conversation_list_top", - "parameter_count": 3, + "parameter_count": 4, "parameters": { "cursor": { "description": "分页游标(首次不传或 0)", @@ -65279,6 +65845,130 @@ "property": "limit", "required": false, "type": "integer" + }, + "type": { + "default": "all", + "description": "会话类型:all 全部 / group 群聊 / direct 单聊(当前页本地过滤)(可选值: all, group, direct)", + "enum": [ + "all", + "group", + "direct" + ], + "field_provenance": { + "description": { + "candidates": [ + { + "precedence": "cobra_contract", + "selected": true, + "source": "cobra_usage", + "value": "会话类型:all 全部 / group 群聊 / direct 单聊(当前页本地过滤)(可选值: all, group, direct)" + }, + { + "precedence": "default", + "selected": false, + "source": "default", + "value": "" + } + ], + "precedence": "cobra_contract", + "resolution": "highest_precedence", + "source": "cobra_usage", + "value": "会话类型:all 全部 / group 群聊 / direct 单聊(当前页本地过滤)(可选值: all, group, direct)" + }, + "enum": { + "candidates": [ + { + "precedence": "native_annotation", + "selected": true, + "source": "native_annotation", + "value": [ + "all", + "group", + "direct" + ] + }, + { + "precedence": "default", + "selected": false, + "source": "default", + "value": [] + } + ], + "precedence": "native_annotation", + "resolution": "highest_precedence", + "source": "native_annotation", + "value": [ + "all", + "group", + "direct" + ] + }, + "property": { + "candidates": [ + { + "precedence": "inference", + "selected": true, + "source": "flag_name_inference", + "value": "type" + } + ], + "precedence": "inference", + "resolution": "highest_precedence", + "source": "flag_name_inference", + "value": "type" + }, + "required": { + "candidates": [ + { + "precedence": "cobra_contract", + "selected": true, + "source": "cobra_nonzero_default", + "value": false + }, + { + "precedence": "default", + "selected": false, + "source": "default", + "value": false + } + ], + "precedence": "cobra_contract", + "resolution": "highest_precedence", + "source": "cobra_nonzero_default", + "value": false + }, + "required_when": { + "candidates": [ + { + "precedence": "default", + "selected": true, + "source": "default", + "value": "" + } + ], + "precedence": "default", + "resolution": "highest_precedence", + "source": "default", + "value": "" + }, + "type": { + "candidates": [ + { + "precedence": "cobra_contract", + "selected": true, + "source": "cobra_flag_type", + "value": "string" + } + ], + "precedence": "cobra_contract", + "resolution": "highest_precedence", + "source": "cobra_flag_type", + "value": "string" + } + }, + "property": "type", + "required": false, + "type": "string" } }, "path": "chat.shortcut_conversation_list_top", @@ -65287,9 +65977,9 @@ "reviewed": true, "risk": "low", "source": "reviewed_command_registry", - "title": "拉取置顶会话列表", + "title": "拉取置顶会话列表,可只看群聊或单聊", "use_when": [ - "当你只想查看被置顶的那些会话时使用;只读分页返回置顶会话列表,可用 --exclude-muted 排除已免打扰会话。" + "当你只想查看被置顶的那些会话时使用;只读分页返回置顶会话列表,并把下层 singleChat 规范化为 conversationType=group|direct。可用 --type group 只看群聊、--type direct 只看单聊,或用 --exclude-muted 排除已免打扰会话。" ] }, "chat.shortcut_dm": { @@ -66614,7 +67304,7 @@ "interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.", "is_alias": false, "name": "shortcut_messages_list_direct", - "parameter_count": 5, + "parameter_count": 6, "parameters": { "forward": { "default": "true", @@ -66791,6 +67481,96 @@ "required": false, "type": "integer" }, + "no-reactions": { + "description": "不输出消息 reaction(默认输出)", + "field_provenance": { + "description": { + "candidates": [ + { + "precedence": "cobra_contract", + "selected": true, + "source": "cobra_usage", + "value": "不输出消息 reaction(默认输出)" + }, + { + "precedence": "default", + "selected": false, + "source": "default", + "value": "" + } + ], + "precedence": "cobra_contract", + "resolution": "highest_precedence", + "source": "cobra_usage", + "value": "不输出消息 reaction(默认输出)" + }, + "property": { + "candidates": [ + { + "precedence": "inference", + "selected": true, + "source": "flag_name_inference", + "value": "noReactions" + } + ], + "precedence": "inference", + "resolution": "highest_precedence", + "source": "flag_name_inference", + "value": "noReactions" + }, + "required": { + "candidates": [ + { + "precedence": "cobra_contract", + "selected": true, + "source": "cobra_nonzero_default", + "value": false + }, + { + "precedence": "default", + "selected": false, + "source": "default", + "value": false + } + ], + "precedence": "cobra_contract", + "resolution": "highest_precedence", + "source": "cobra_nonzero_default", + "value": false + }, + "required_when": { + "candidates": [ + { + "precedence": "default", + "selected": true, + "source": "default", + "value": "" + } + ], + "precedence": "default", + "resolution": "highest_precedence", + "source": "default", + "value": "" + }, + "type": { + "candidates": [ + { + "precedence": "cobra_contract", + "selected": true, + "source": "cobra_flag_type", + "value": "boolean" + } + ], + "precedence": "cobra_contract", + "resolution": "highest_precedence", + "source": "cobra_flag_type", + "value": "boolean" + } + }, + "property": "noReactions", + "required": false, + "type": "boolean" + }, "open-dingtalk-id": { "description": "对方 openDingTalkId(与 --user 二选一)", "field_provenance": { @@ -68162,7 +68942,7 @@ "cli_name": "+messages-mget", "cli_path": "chat +messages-mget", "confirmation": "not_required", - "description": "当你已有一批消息 openMsgId、需要批量取回它们的详细内容时使用;只读,一次最多查询 50 条。", + "description": "当你已有一批消息 openMsgId、需要批量取回完整详情、reaction 和可执行资源引用时使用;一次最多 50 条。--download-resources 可把所有可识别 mediaId 安全下载到工作目录内,并逐资源返回成功/失败 ledger。\n\n参数约束:\n - --msg-ids 去重后必须包含 1-50 条消息 ID\n - --output-dir 必须是工作目录内的相对路径,不允许绝对路径或 .. 逃逸", "display": "群聊 / 消息 / 机器人", "effect": "read", "effect_source": "agent-hint", @@ -68174,7 +68954,7 @@ "candidates": [ { "precedence": "reviewed_explicit", - "review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.", + "review_reason": "Reviewed against the built-in Shortcut, executable Cobra flags, safe relative-path download contract, and per-resource failure ledger; this selection update advertises the new optional resource workflow without changing its runtime safety facts.", "selected": true, "source": "internal/cli/schema_hints/selection/chat.json", "value": "根据消息 ID 批量查询消息(最多 50 条)" @@ -68182,7 +68962,7 @@ ], "precedence": "reviewed_explicit", "resolution": "highest_precedence", - "review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.", + "review_reason": "Reviewed against the built-in Shortcut, executable Cobra flags, safe relative-path download contract, and per-resource failure ledger; this selection update advertises the new optional resource workflow without changing its runtime safety facts.", "source": "internal/cli/schema_hints/selection/chat.json", "value": "根据消息 ID 批量查询消息(最多 50 条)" }, @@ -68206,7 +68986,7 @@ "candidates": [ { "precedence": "reviewed_explicit", - "review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.", + "review_reason": "Reviewed against the built-in Shortcut, executable Cobra flags, safe relative-path download contract, and per-resource failure ledger; this selection update advertises the new optional resource workflow without changing its runtime safety facts.", "selected": true, "source": "internal/cli/schema_hints/selection/chat.json", "value": [ @@ -68216,7 +68996,7 @@ ], "precedence": "reviewed_explicit", "resolution": "highest_precedence", - "review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.", + "review_reason": "Reviewed against the built-in Shortcut, executable Cobra flags, safe relative-path download contract, and per-resource failure ledger; this selection update advertises the new optional resource workflow without changing its runtime safety facts.", "source": "internal/cli/schema_hints/selection/chat.json", "value": [ "需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令" @@ -68260,13 +69040,13 @@ "precedence": "cobra_help", "selected": true, "source": "cobra_help", - "value": "当你已有一批消息 openMsgId、需要批量取回它们的详细内容时使用;只读,一次最多查询 50 条。" + "value": "当你已有一批消息 openMsgId、需要批量取回完整详情、reaction 和可执行资源引用时使用;一次最多 50 条。--download-resources 可把所有可识别 mediaId 安全下载到工作目录内,并逐资源返回成功/失败 ledger。\n\n参数约束:\n - --msg-ids 去重后必须包含 1-50 条消息 ID\n - --output-dir 必须是工作目录内的相对路径,不允许绝对路径或 .. 逃逸" } ], "precedence": "cobra_help", "resolution": "highest_precedence", "source": "cobra_help", - "value": "当你已有一批消息 openMsgId、需要批量取回它们的详细内容时使用;只读,一次最多查询 50 条。" + "value": "当你已有一批消息 openMsgId、需要批量取回完整详情、reaction 和可执行资源引用时使用;一次最多 50 条。--download-resources 可把所有可识别 mediaId 安全下载到工作目录内,并逐资源返回成功/失败 ledger。\n\n参数约束:\n - --msg-ids 去重后必须包含 1-50 条消息 ID\n - --output-dir 必须是工作目录内的相对路径,不允许绝对路径或 .. 逃逸" }, "effect": { "candidates": [ @@ -68288,7 +69068,7 @@ "candidates": [ { "precedence": "reviewed_explicit", - "review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.", + "review_reason": "Reviewed against the built-in Shortcut, executable Cobra flags, safe relative-path download contract, and per-resource failure ledger; this selection update advertises the new optional resource workflow without changing its runtime safety facts.", "selected": true, "source": "internal/cli/schema_hints/selection/chat.json", "value": [ @@ -68298,7 +69078,7 @@ ], "precedence": "reviewed_explicit", "resolution": "highest_precedence", - "review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.", + "review_reason": "Reviewed against the built-in Shortcut, executable Cobra flags, safe relative-path download contract, and per-resource failure ledger; this selection update advertises the new optional resource workflow without changing its runtime safety facts.", "source": "internal/cli/schema_hints/selection/chat.json", "value": [ "dws chat +messages-mget --msg-ids msgId1,msgId2" @@ -68379,7 +69159,7 @@ }, { "precedence": "reviewed_explicit", - "review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.", + "review_reason": "Reviewed against the built-in Shortcut, executable Cobra flags, safe relative-path download contract, and per-resource failure ledger; this selection update advertises the new optional resource workflow without changing its runtime safety facts.", "selected": false, "source": "internal/cli/schema_hints/selection/chat.json", "value": true @@ -68425,20 +69205,20 @@ "candidates": [ { "precedence": "reviewed_explicit", - "review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.", + "review_reason": "Reviewed against the built-in Shortcut, executable Cobra flags, safe relative-path download contract, and per-resource failure ledger; this selection update advertises the new optional resource workflow without changing its runtime safety facts.", "selected": true, "source": "internal/cli/schema_hints/selection/chat.json", "value": [ - "当你已有一批消息 openMsgId、需要批量取回它们的详细内容时使用;只读,一次最多查询 50 条。" + "当你已有一批消息 openMsgId、需要批量取回完整详情、reaction 和可执行资源引用时使用;一次最多 50 条。--download-resources 可把所有可识别 mediaId 安全下载到工作目录内,并逐资源返回成功/失败 ledger。" ] } ], "precedence": "reviewed_explicit", "resolution": "highest_precedence", - "review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.", + "review_reason": "Reviewed against the built-in Shortcut, executable Cobra flags, safe relative-path download contract, and per-resource failure ledger; this selection update advertises the new optional resource workflow without changing its runtime safety facts.", "source": "internal/cli/schema_hints/selection/chat.json", "value": [ - "当你已有一批消息 openMsgId、需要批量取回它们的详细内容时使用;只读,一次最多查询 50 条。" + "当你已有一批消息 openMsgId、需要批量取回完整详情、reaction 和可执行资源引用时使用;一次最多 50 条。--download-resources 可把所有可识别 mediaId 安全下载到工作目录内,并逐资源返回成功/失败 ledger。" ] } }, @@ -68448,10 +69228,10 @@ "interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.", "is_alias": false, "name": "shortcut_messages_mget", - "parameter_count": 1, + "parameter_count": 5, "parameters": { - "msg-ids": { - "description": "消息 openMsgId 列表(必填)", + "download-resources": { + "description": "自动下载消息中的全部可识别 mediaId 资源", "field_provenance": { "description": { "candidates": [ @@ -68459,7 +69239,7 @@ "precedence": "cobra_contract", "selected": true, "source": "cobra_usage", - "value": "消息 openMsgId 列表(必填)" + "value": "自动下载消息中的全部可识别 mediaId 资源" }, { "precedence": "default", @@ -68471,7 +69251,91 @@ "precedence": "cobra_contract", "resolution": "highest_precedence", "source": "cobra_usage", - "value": "消息 openMsgId 列表(必填)" + "value": "自动下载消息中的全部可识别 mediaId 资源" + }, + "property": { + "candidates": [ + { + "precedence": "inference", + "selected": true, + "source": "flag_name_inference", + "value": "downloadResources" + } + ], + "precedence": "inference", + "resolution": "highest_precedence", + "source": "flag_name_inference", + "value": "downloadResources" + }, + "required": { + "candidates": [ + { + "precedence": "default", + "selected": true, + "source": "default", + "value": false + } + ], + "precedence": "default", + "resolution": "fallback", + "source": "default", + "value": false + }, + "required_when": { + "candidates": [ + { + "precedence": "default", + "selected": true, + "source": "default", + "value": "" + } + ], + "precedence": "default", + "resolution": "highest_precedence", + "source": "default", + "value": "" + }, + "type": { + "candidates": [ + { + "precedence": "cobra_contract", + "selected": true, + "source": "cobra_flag_type", + "value": "boolean" + } + ], + "precedence": "cobra_contract", + "resolution": "highest_precedence", + "source": "cobra_flag_type", + "value": "boolean" + } + }, + "property": "downloadResources", + "required": false, + "type": "boolean" + }, + "msg-ids": { + "description": "消息 openMsgId 列表;--msg-ids 去重后必须包含 1-50 条消息 ID(必填)", + "field_provenance": { + "description": { + "candidates": [ + { + "precedence": "cobra_contract", + "selected": true, + "source": "cobra_usage", + "value": "消息 openMsgId 列表;--msg-ids 去重后必须包含 1-50 条消息 ID(必填)" + }, + { + "precedence": "default", + "selected": false, + "source": "default", + "value": "" + } + ], + "precedence": "cobra_contract", + "resolution": "highest_precedence", + "source": "cobra_usage", + "value": "消息 openMsgId 列表;--msg-ids 去重后必须包含 1-50 条消息 ID(必填)" }, "property": { "candidates": [ @@ -68545,6 +69409,277 @@ "property": "msgIds", "required": true, "type": "array" + }, + "no-reactions": { + "description": "不输出消息 reaction(默认输出)", + "field_provenance": { + "description": { + "candidates": [ + { + "precedence": "cobra_contract", + "selected": true, + "source": "cobra_usage", + "value": "不输出消息 reaction(默认输出)" + }, + { + "precedence": "default", + "selected": false, + "source": "default", + "value": "" + } + ], + "precedence": "cobra_contract", + "resolution": "highest_precedence", + "source": "cobra_usage", + "value": "不输出消息 reaction(默认输出)" + }, + "property": { + "candidates": [ + { + "precedence": "inference", + "selected": true, + "source": "flag_name_inference", + "value": "noReactions" + } + ], + "precedence": "inference", + "resolution": "highest_precedence", + "source": "flag_name_inference", + "value": "noReactions" + }, + "required": { + "candidates": [ + { + "precedence": "cobra_contract", + "selected": true, + "source": "cobra_nonzero_default", + "value": false + }, + { + "precedence": "default", + "selected": false, + "source": "default", + "value": false + } + ], + "precedence": "cobra_contract", + "resolution": "highest_precedence", + "source": "cobra_nonzero_default", + "value": false + }, + "required_when": { + "candidates": [ + { + "precedence": "default", + "selected": true, + "source": "default", + "value": "" + } + ], + "precedence": "default", + "resolution": "highest_precedence", + "source": "default", + "value": "" + }, + "type": { + "candidates": [ + { + "precedence": "cobra_contract", + "selected": true, + "source": "cobra_flag_type", + "value": "boolean" + } + ], + "precedence": "cobra_contract", + "resolution": "highest_precedence", + "source": "cobra_flag_type", + "value": "boolean" + } + }, + "property": "noReactions", + "required": false, + "type": "boolean" + }, + "output-dir": { + "default": "./downloads", + "description": "资源输出目录;--output-dir 必须是工作目录内的相对路径,不允许绝对路径或 .. 逃逸", + "field_provenance": { + "description": { + "candidates": [ + { + "precedence": "cobra_contract", + "selected": true, + "source": "cobra_usage", + "value": "资源输出目录;--output-dir 必须是工作目录内的相对路径,不允许绝对路径或 .. 逃逸" + }, + { + "precedence": "default", + "selected": false, + "source": "default", + "value": "" + } + ], + "precedence": "cobra_contract", + "resolution": "highest_precedence", + "source": "cobra_usage", + "value": "资源输出目录;--output-dir 必须是工作目录内的相对路径,不允许绝对路径或 .. 逃逸" + }, + "property": { + "candidates": [ + { + "precedence": "inference", + "selected": true, + "source": "flag_name_inference", + "value": "outputDir" + } + ], + "precedence": "inference", + "resolution": "highest_precedence", + "source": "flag_name_inference", + "value": "outputDir" + }, + "required": { + "candidates": [ + { + "precedence": "cobra_contract", + "selected": true, + "source": "cobra_nonzero_default", + "value": false + }, + { + "precedence": "default", + "selected": false, + "source": "default", + "value": false + } + ], + "precedence": "cobra_contract", + "resolution": "highest_precedence", + "source": "cobra_nonzero_default", + "value": false + }, + "required_when": { + "candidates": [ + { + "precedence": "default", + "selected": true, + "source": "default", + "value": "" + } + ], + "precedence": "default", + "resolution": "highest_precedence", + "source": "default", + "value": "" + }, + "type": { + "candidates": [ + { + "precedence": "cobra_contract", + "selected": true, + "source": "cobra_flag_type", + "value": "string" + } + ], + "precedence": "cobra_contract", + "resolution": "highest_precedence", + "source": "cobra_flag_type", + "value": "string" + } + }, + "property": "outputDir", + "required": false, + "type": "string" + }, + "overwrite": { + "description": "允许覆盖同名资源文件(默认拒绝)", + "field_provenance": { + "description": { + "candidates": [ + { + "precedence": "cobra_contract", + "selected": true, + "source": "cobra_usage", + "value": "允许覆盖同名资源文件(默认拒绝)" + }, + { + "precedence": "default", + "selected": false, + "source": "default", + "value": "" + } + ], + "precedence": "cobra_contract", + "resolution": "highest_precedence", + "source": "cobra_usage", + "value": "允许覆盖同名资源文件(默认拒绝)" + }, + "property": { + "candidates": [ + { + "precedence": "inference", + "selected": true, + "source": "flag_name_inference", + "value": "overwrite" + } + ], + "precedence": "inference", + "resolution": "highest_precedence", + "source": "flag_name_inference", + "value": "overwrite" + }, + "required": { + "candidates": [ + { + "precedence": "cobra_contract", + "selected": true, + "source": "cobra_nonzero_default", + "value": false + }, + { + "precedence": "default", + "selected": false, + "source": "default", + "value": false + } + ], + "precedence": "cobra_contract", + "resolution": "highest_precedence", + "source": "cobra_nonzero_default", + "value": false + }, + "required_when": { + "candidates": [ + { + "precedence": "default", + "selected": true, + "source": "default", + "value": "" + } + ], + "precedence": "default", + "resolution": "highest_precedence", + "source": "default", + "value": "" + }, + "type": { + "candidates": [ + { + "precedence": "cobra_contract", + "selected": true, + "source": "cobra_flag_type", + "value": "boolean" + } + ], + "precedence": "cobra_contract", + "resolution": "highest_precedence", + "source": "cobra_flag_type", + "value": "boolean" + } + }, + "property": "overwrite", + "required": false, + "type": "boolean" } }, "path": "chat.shortcut_messages_mget", @@ -68555,7 +69690,7 @@ "source": "reviewed_command_registry", "title": "根据消息 ID 批量查询消息(最多 50 条)", "use_when": [ - "当你已有一批消息 openMsgId、需要批量取回它们的详细内容时使用;只读,一次最多查询 50 条。" + "当你已有一批消息 openMsgId、需要批量取回完整详情、reaction 和可执行资源引用时使用;一次最多 50 条。--download-resources 可把所有可识别 mediaId 安全下载到工作目录内,并逐资源返回成功/失败 ledger。" ] }, "chat.shortcut_messages_query_send_status": { @@ -71336,8 +72471,183 @@ "interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.", "is_alias": false, "name": "shortcut_my_groups", - "parameter_count": 1, + "parameter_count": 3, "parameters": { + "cursor": { + "description": "分页游标,翻页传上次的 nextCursor", + "field_provenance": { + "description": { + "candidates": [ + { + "precedence": "cobra_contract", + "selected": true, + "source": "cobra_usage", + "value": "分页游标,翻页传上次的 nextCursor" + }, + { + "precedence": "default", + "selected": false, + "source": "default", + "value": "" + } + ], + "precedence": "cobra_contract", + "resolution": "highest_precedence", + "source": "cobra_usage", + "value": "分页游标,翻页传上次的 nextCursor" + }, + "property": { + "candidates": [ + { + "precedence": "inference", + "selected": true, + "source": "flag_name_inference", + "value": "cursor" + } + ], + "precedence": "inference", + "resolution": "highest_precedence", + "source": "flag_name_inference", + "value": "cursor" + }, + "required": { + "candidates": [ + { + "precedence": "default", + "selected": true, + "source": "default", + "value": false + } + ], + "precedence": "default", + "resolution": "fallback", + "source": "default", + "value": false + }, + "required_when": { + "candidates": [ + { + "precedence": "default", + "selected": true, + "source": "default", + "value": "" + } + ], + "precedence": "default", + "resolution": "highest_precedence", + "source": "default", + "value": "" + }, + "type": { + "candidates": [ + { + "precedence": "cobra_contract", + "selected": true, + "source": "cobra_flag_type", + "value": "string" + } + ], + "precedence": "cobra_contract", + "resolution": "highest_precedence", + "source": "cobra_flag_type", + "value": "string" + } + }, + "property": "cursor", + "required": false, + "type": "string" + }, + "limit": { + "default": "200", + "description": "每页返回数量(默认 200)", + "field_provenance": { + "description": { + "candidates": [ + { + "precedence": "cobra_contract", + "selected": true, + "source": "cobra_usage", + "value": "每页返回数量(默认 200)" + }, + { + "precedence": "default", + "selected": false, + "source": "default", + "value": "" + } + ], + "precedence": "cobra_contract", + "resolution": "highest_precedence", + "source": "cobra_usage", + "value": "每页返回数量(默认 200)" + }, + "property": { + "candidates": [ + { + "precedence": "inference", + "selected": true, + "source": "flag_name_inference", + "value": "limit" + } + ], + "precedence": "inference", + "resolution": "highest_precedence", + "source": "flag_name_inference", + "value": "limit" + }, + "required": { + "candidates": [ + { + "precedence": "cobra_contract", + "selected": true, + "source": "cobra_nonzero_default", + "value": false + }, + { + "precedence": "default", + "selected": false, + "source": "default", + "value": false + } + ], + "precedence": "cobra_contract", + "resolution": "highest_precedence", + "source": "cobra_nonzero_default", + "value": false + }, + "required_when": { + "candidates": [ + { + "precedence": "default", + "selected": true, + "source": "default", + "value": "" + } + ], + "precedence": "default", + "resolution": "highest_precedence", + "source": "default", + "value": "" + }, + "type": { + "candidates": [ + { + "precedence": "cobra_contract", + "selected": true, + "source": "cobra_flag_type", + "value": "integer" + } + ], + "precedence": "cobra_contract", + "resolution": "highest_precedence", + "source": "cobra_flag_type", + "value": "integer" + } + }, + "property": "limit", + "required": false, + "type": "integer" + }, "type": { "description": "按群类型过滤(可选,如返回中的 groupType/conversationType,大小写不敏感)", "field_provenance": { diff --git a/internal/cli/schema_command_exclusions.json b/internal/cli/schema_command_exclusions.json index 9ec1eddf..f3f349c1 100644 --- a/internal/cli/schema_command_exclusions.json +++ b/internal/cli/schema_command_exclusions.json @@ -153,6 +153,68 @@ "todo task list-sub", "todo task remove-attachment" ] + }, + { + "id": "chat-shortcuts-pending-schema-curation", + "reason": "These reviewed public Chat shortcuts remain executable and discoverable through the Shortcut catalog while their typed Schema selection and metadata records are curated; exact paths keep reverse completeness strict without hiding future shortcuts.", + "reviewed": true, + "commands": [ + "chat +category-add-conversation", + "chat +category-list-conversations", + "chat +category-remove-conversation", + "chat +chat-add-bot", + "chat +chat-audit-join", + "chat +chat-create", + "chat +chat-get-by-id", + "chat +chat-members-get", + "chat +chat-members-list", + "chat +chat-messages", + "chat +chat-mute-member", + "chat +chat-quit", + "chat +chat-remove-bot", + "chat +chat-role-remove", + "chat +chat-role-remove-user", + "chat +chat-transfer-owner", + "chat +chat-update", + "chat +chat-update-icon", + "chat +chat-update-settings", + "chat +conversation-clear-messages", + "chat +conversation-clear-red-point", + "chat +conversation-hide", + "chat +conversation-mark-read", + "chat +conversation-mark-unread", + "chat +conversation-mute", + "chat +conversation-set-top", + "chat +feed-group-query-item", + "chat +flag-cancel", + "chat +flag-create", + "chat +flag-list", + "chat +messages-add-emoji", + "chat +messages-add-text-emotion", + "chat +messages-batch-recall-by-bot", + "chat +messages-batch-send-by-bot", + "chat +messages-combine-forward", + "chat +messages-create-text-emotion", + "chat +messages-forward", + "chat +messages-forward-topic", + "chat +messages-list", + "chat +messages-recall", + "chat +messages-recall-by-bot", + "chat +messages-remove-emoji", + "chat +messages-remove-text-emotion", + "chat +messages-reply", + "chat +messages-resource-download", + "chat +messages-resource-url", + "chat +messages-send", + "chat +messages-send-by-bot", + "chat +messages-send-card", + "chat +messages-set-pin", + "chat +messages-set-top", + "chat +messages-unset-pin", + "chat +messages-unset-top", + "chat +search-msg", + "chat +thread-replies" + ] } ] } diff --git a/internal/cli/schema_hints/metadata/chat.json b/internal/cli/schema_hints/metadata/chat.json index 5a48ac43..cd937849 100644 --- a/internal/cli/schema_hints/metadata/chat.json +++ b/internal/cli/schema_hints/metadata/chat.json @@ -875,7 +875,7 @@ "availability": "available", "interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.", "reviewed": true, - "review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.", + "review_reason": "Reviewed against the mounted Shortcut: --type all|group|direct is a local semantic filter over list_top_conversations.singleChat, so the composite interface owns validation and normalized output without changing lower-service arguments.", "cli_path": "chat +conversation-list-top", "runtime_gate": "none" }, diff --git a/internal/cli/schema_hints/selection/chat.json b/internal/cli/schema_hints/selection/chat.json index 33ac09f5..b54c5735 100644 --- a/internal/cli/schema_hints/selection/chat.json +++ b/internal/cli/schema_hints/selection/chat.json @@ -1692,18 +1692,19 @@ ] }, "chat.shortcut_conversation_list_top": { - "agent_summary": "拉取置顶会话列表", + "agent_summary": "拉取置顶会话列表,可只看群聊或单聊", "use_when": [ - "当你只想查看被置顶的那些会话时使用;只读分页返回置顶会话列表,可用 --exclude-muted 排除已免打扰会话。" + "当你只想查看被置顶的那些会话时使用;只读分页返回置顶会话列表,并把下层 singleChat 规范化为 conversationType=group|direct。可用 --type group 只看群聊、--type direct 只看单聊,或用 --exclude-muted 排除已免打扰会话。" ], "avoid_when": [ - "需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令" + "用户要查看群内被 Pin 的消息而不是侧边栏置顶会话时,改用 +messages-list-pin" ], "examples": [ - "dws chat +conversation-list-top --limit 1000" + "dws chat +conversation-list-top --type group --limit 1000", + "dws chat +conversation-list-top --type direct --limit 1000" ], "reviewed": true, - "review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.", + "review_reason": "Reviewed after live testing list_top_conversations: singleChat is present on every tested row and is normalized locally to group/direct without adding a lower-service parameter.", "source_refs": [ "internal/cli/schema_command_registry.json#chat.shortcut_conversation_list_top", "cobra-help:dws chat +conversation-list-top", @@ -1732,7 +1733,7 @@ "chat.shortcut_category_create": { "agent_summary": "创建用户自定义会话分组", "use_when": [ - "当你想新建一个会话分组来归类会话时使用;会实际创建分组并返回其 ID,需传分组名称 --title。" + "当你想新建一个会话分组来归类会话时使用;会实际创建分组并返回其 ID,需传最多 15 个字符的分组名称 --title。" ], "avoid_when": [ "需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令" @@ -1770,7 +1771,7 @@ "chat.shortcut_category_rename": { "agent_summary": "更新用户自定义会话分组的名称", "use_when": [ - "当你想重命名已有的自定义会话分组时使用;会实际更新分组名称,需传 categoryId 和新名称 --title。" + "当你想重命名已有的自定义会话分组时使用;会实际更新分组名称,需传 categoryId 和最多 15 个字符的新名称 --title。" ], "avoid_when": [ "需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令" @@ -2169,7 +2170,7 @@ "chat.shortcut_messages_mget": { "agent_summary": "根据消息 ID 批量查询消息(最多 50 条)", "use_when": [ - "当你已有一批消息 openMsgId、需要批量取回它们的详细内容时使用;只读,一次最多查询 50 条。" + "当你已有一批消息 openMsgId、需要批量取回完整详情、reaction 和可执行资源引用时使用;一次最多 50 条。--download-resources 可把所有可识别 mediaId 安全下载到工作目录内,并逐资源返回成功/失败 ledger。" ], "avoid_when": [ "需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令" @@ -2178,7 +2179,7 @@ "dws chat +messages-mget --msg-ids msgId1,msgId2" ], "reviewed": true, - "review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.", + "review_reason": "Reviewed against the built-in Shortcut, executable Cobra flags, safe relative-path download contract, and per-resource failure ledger; this selection update advertises the new optional resource workflow without changing its runtime safety facts.", "source_refs": [ "internal/cli/schema_command_registry.json#chat.shortcut_messages_mget", "cobra-help:dws chat +messages-mget", diff --git a/internal/cli/schema_parameter_bindings.json b/internal/cli/schema_parameter_bindings.json index 76670fb2..da4c740a 100644 --- a/internal/cli/schema_parameter_bindings.json +++ b/internal/cli/schema_parameter_bindings.json @@ -2,8 +2,8 @@ "version": 3, "baseline": { "manifest": "schema-parameter-bindings-v3", - "sha256": "sha256:9008afd72243232d4bcb37b46f1e8fc10e2bc283c8af26c59fbdb4901ebeef2f", - "reason": "Reviewed v3 baseline for the complete active canonical/flag/property manifest after splitting the fixed-scope minutes list commands into distinct registry identities.", + "sha256": "sha256:f48d7b55b6ca9719c466475ee32d678eab0deaee1d79ac4e4790c7ee441ebe7b", + "reason": "Reviewed v3 baseline after exposing the pinned chat.search_messages messageType, onlyRobotMessages, and searchConvType properties through exact Cobra flags.", "reviewed": true }, "removals": { @@ -408,8 +408,11 @@ }, "chat.search_messages": { "at-ids": "atOpenDingTakIds", + "conversation-type": "searchConvType", "conversation-ids": "openConversationIds", "end": "endTime", + "message-type": "messageType", + "only-robot": "onlyRobotMessages", "query": "keyword", "sender-ids": "senderOpenDingTakIds", "start": "startTime" diff --git a/internal/helpers/chat.go b/internal/helpers/chat.go index 3f1684f1..5a17af9a 100644 --- a/internal/helpers/chat.go +++ b/internal/helpers/chat.go @@ -13,6 +13,7 @@ import ( "strconv" "strings" "time" + "unicode/utf8" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" @@ -49,6 +50,20 @@ func resolveMessageForward(cmd *cobra.Command, defaultForward bool) (bool, error } } +const maxConversationCategoryTitleRunes = 15 + +func validatedConversationCategoryTitle(raw string) (string, error) { + title := strings.TrimSpace(raw) + if title == "" { + return "", apperrors.NewValidation("--title 不能为空") + } + if utf8.RuneCountInString(title) > maxConversationCategoryTitleRunes { + return "", apperrors.NewValidation(fmt.Sprintf( + "--title 最多 %d 个字符", maxConversationCategoryTitleRunes)) + } + return title, nil +} + func chatIntFlagOrFallback(cmd *cobra.Command, primary string, aliases ...string) int { for _, alias := range aliases { if f := cmd.Flags().Lookup(alias); f != nil && f.Changed { @@ -2164,6 +2179,16 @@ func newChatCommand() *cobra.Command { } } + if v, _ := cmd.Flags().GetString("message-type"); v != "" { + toolArgs["messageType"] = v + } + if cmd.Flags().Changed("only-robot") { + toolArgs["onlyRobotMessages"], _ = cmd.Flags().GetBool("only-robot") + } + if v, _ := cmd.Flags().GetString("conversation-type"); v != "" { + toolArgs["searchConvType"] = v + } + // start -> startTime (ISO-8601 to milliseconds) if v, _ := cmd.Flags().GetString("start"); v != "" { ms, err := parseISOTimeToMillis("start", v) @@ -2659,6 +2684,9 @@ func newChatCommand() *cobra.Command { _ = chatMessageSearchAdvancedCmd.Flags().MarkHidden("groups") chatMessageSearchAdvancedCmd.Flags().String("group", "", "") _ = chatMessageSearchAdvancedCmd.Flags().MarkHidden("group") + chatMessageSearchAdvancedCmd.Flags().String("message-type", "", "下层消息类型过滤值(可选,以当前 IM Schema 支持值为准)") + chatMessageSearchAdvancedCmd.Flags().Bool("only-robot", false, "只搜索机器人消息(可选;显式传 false 时也会传给下层)") + chatMessageSearchAdvancedCmd.Flags().String("conversation-type", "", "下层会话类型过滤值(可选,以当前 IM Schema 支持值为准)") chatMessageSearchAdvancedCmd.Flags().String("start", "", "开始时间,ISO-8601 格式(可选)") chatMessageSearchAdvancedCmd.Flags().String("end", "", "结束时间,ISO-8601 格式(可选)") chatMessageSearchAdvancedCmd.Flags().String("cursor", "0", "分页游标(默认 \"0\")") @@ -2867,8 +2895,12 @@ func newChatCommand() *cobra.Command { if err := validateRequiredFlags(cmd, "title"); err != nil { return err } + title, err := validatedConversationCategoryTitle(mustGetFlag(cmd, "title")) + if err != nil { + return err + } return callMCPToolOnServer("im", "create_conv_category", map[string]any{ - "title": mustGetFlag(cmd, "title"), + "title": title, }) }, } @@ -2902,9 +2934,13 @@ func newChatCommand() *cobra.Command { if err := validateRequiredFlags(cmd, "title"); err != nil { return err } + title, err := validatedConversationCategoryTitle(mustGetFlag(cmd, "title")) + if err != nil { + return err + } return callMCPToolOnServer("im", "rename_conv_category", map[string]any{ "categoryId": categoryId, - "title": mustGetFlag(cmd, "title"), + "title": title, }) }, } @@ -3851,7 +3887,7 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 _ = chatCategoryConvsCmd.MarkFlagRequired("category-id") // category create flags - chatCategoryCreateCmd.Flags().String("title", "", "分组名称 (必填)") + chatCategoryCreateCmd.Flags().String("title", "", "分组名称,最多 15 个字符 (必填)") _ = chatCategoryCreateCmd.MarkFlagRequired("title") // category delete flags @@ -3859,7 +3895,7 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 // category rename flags chatCategoryRenameCmd.Flags().Int64("category-id", 0, "会话分组 ID (必填)") - chatCategoryRenameCmd.Flags().String("title", "", "新的分组名称 (必填)") + chatCategoryRenameCmd.Flags().String("title", "", "新的分组名称,最多 15 个字符 (必填)") _ = chatCategoryRenameCmd.MarkFlagRequired("title") // category add-conv flags diff --git a/internal/helpers/chat_category_title_test.go b/internal/helpers/chat_category_title_test.go new file mode 100644 index 00000000..3c85ac66 --- /dev/null +++ b/internal/helpers/chat_category_title_test.go @@ -0,0 +1,85 @@ +// 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 helpers + +import ( + "context" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" +) + +func TestValidatedConversationCategoryTitle(t *testing.T) { + tests := []struct { + name string + raw string + want string + wantErr string + }{ + {name: "trim", raw: " 工作群 ", want: "工作群"}, + {name: "fifteen characters", raw: "123456789012345", want: "123456789012345"}, + {name: "blank", raw: " ", wantErr: "不能为空"}, + {name: "too long", raw: "1234567890123456", wantErr: "最多 15 个字符"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := validatedConversationCategoryTitle(tc.raw) + if tc.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("error = %v, want containing %q", err, tc.wantErr) + } + return + } + if err != nil { + t.Fatal(err) + } + if got != tc.want { + t.Fatalf("title = %q, want %q", got, tc.want) + } + }) + } +} + +func TestCrossPlatformCoverageCategoryCommandsRejectInvalidTitles(t *testing.T) { + caller := &categoryTitleCaller{} + old := deps + t.Cleanup(func() { deps = old }) + InitDeps(caller) + + for _, argv := range [][]string{ + {"category", "create", "--title", "1234567890123456"}, + {"category", "rename", "--category-id", "42", "--title", " "}, + } { + cmd := newChatCommand() + cmd.SetArgs(argv) + if err := cmd.Execute(); err == nil { + t.Fatalf("newChatCommand(%v) unexpectedly accepted an invalid title", argv) + } + } + if caller.calls != 0 { + t.Fatalf("invalid category titles reached MCP %d times", caller.calls) + } +} + +type categoryTitleCaller struct{ calls int } + +func (c *categoryTitleCaller) CallTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) { + c.calls++ + return &edition.ToolResult{}, nil +} +func (*categoryTitleCaller) Format() string { return "json" } +func (*categoryTitleCaller) DryRun() bool { return false } +func (*categoryTitleCaller) Fields() string { return "" } +func (*categoryTitleCaller) JQ() string { return "" } diff --git a/internal/helpers/chat_message_search_test.go b/internal/helpers/chat_message_search_test.go index c0051581..3dda8334 100644 --- a/internal/helpers/chat_message_search_test.go +++ b/internal/helpers/chat_message_search_test.go @@ -87,12 +87,15 @@ func TestCrossPlatformCoverageChatMessageSearchUsesMCPContracts(t *testing.T) { }, { name: "advanced search", - args: []string{"message", "search-advanced", "--query", "categoryName", "--conversation-ids", "cid-1,cid-2", "--start", start, "--end", end, "--limit", "100", "--cursor", "0"}, + args: []string{"message", "search-advanced", "--query", "categoryName", "--conversation-ids", "cid-1,cid-2", "--message-type", "text", "--only-robot", "--conversation-type", "group", "--start", start, "--end", end, "--limit", "100", "--cursor", "0"}, productID: "im", toolName: "search_messages", wantToolArg: map[string]any{ "keyword": "categoryName", "openConversationIds": []string{"cid-1", "cid-2"}, + "messageType": "text", + "onlyRobotMessages": true, + "searchConvType": "group", "startTime": startTime.UnixMilli(), "endTime": endTime.UnixMilli(), "limit": 100, diff --git a/internal/helpers/helpers.go b/internal/helpers/helpers.go index c873dbc0..acde24fb 100644 --- a/internal/helpers/helpers.go +++ b/internal/helpers/helpers.go @@ -2,6 +2,7 @@ package helpers import ( "bufio" + "bytes" "context" "encoding/json" "fmt" @@ -145,6 +146,37 @@ func callMCPToolReturnText(ctx context.Context, toolName string, args map[string func callMCPToolReturnTextOnServer(ctx context.Context, serverID, toolName string, args map[string]any) (string, error) { result, err := deps.Caller.CallTool(ctx, serverID, toolName, args) + return parseMCPToolTextResult(serverID, toolName, result, err) +} + +// CallMCPReadToolTextOnServer performs a read-only lookup needed to construct a +// semantic Shortcut dry-run plan. Under ordinary execution it is identical to +// CallMCPToolTextOnServer. Under --dry-run it requires the host's optional +// ReadToolCaller capability; if unavailable, it fails closed instead of +// returning a synthetic dry-run envelope that looks like business data. +func CallMCPReadToolTextOnServer(serverID, toolName string, args map[string]any) (string, error) { + ctx := context.Background() + if deps == nil || deps.Caller == nil { + return "", &CLIError{ + Code: CodeMCPToolError, + Message: "MCP caller is not initialized", + } + } + if !deps.Caller.DryRun() { + return callMCPToolReturnTextOnServer(ctx, serverID, toolName, args) + } + readCaller, ok := deps.Caller.(edition.ReadToolCaller) + if !ok { + return "", &CLIError{ + Code: CodeMCPToolError, + Message: "当前运行时不支持在 --dry-run 下执行只读解析", + } + } + result, err := readCaller.CallReadTool(ctx, serverID, toolName, args) + return parseMCPToolTextResult(serverID, toolName, result, err) +} + +func parseMCPToolTextResult(serverID, toolName string, result *edition.ToolResult, err error) (string, error) { if err != nil { if patErr := reclassifyPATFromError(err); patErr != nil { return "", patErr @@ -153,6 +185,7 @@ func callMCPToolReturnTextOnServer(ctx context.Context, serverID, toolName strin } for _, c := range result.Content { if c.Type == "text" && c.Text != "" { + dumpRawToolResponse(serverID, toolName, c.Text) var errBody map[string]any if json.Unmarshal([]byte(c.Text), &errBody) == nil { if _, ok := getDWSGatewayErrorCode(errBody); ok { @@ -295,6 +328,7 @@ func callMCPToolInternalOpts(explicitServerID, toolName string, args map[string] flagFormat := deps.Caller.Format() for _, c := range result.Content { if c.Type == "text" { + dumpRawToolResponse(serverID, toolName, c.Text) // 尝试将返回文本解析为 JSON,进行错误分类 var errBody map[string]any if json.Unmarshal([]byte(c.Text), &errBody) == nil { @@ -347,6 +381,29 @@ func callMCPToolInternalOpts(explicitServerID, toolName string, args map[string] return printJSON(result) } +// dumpRawToolResponse emits one opt-in lower-layer record for live projection +// audits. Responses may contain business data, so normal CLI runs never emit +// this line; an auditor must explicitly set DWS_DUMP_RAW. +func dumpRawToolResponse(serverID, toolName, text string) { + if strings.TrimSpace(os.Getenv("DWS_DUMP_RAW")) == "" { + return + } + fmt.Fprintln(os.Stderr, formatRawDumpLine(serverID, toolName, text)) +} + +func formatRawDumpLine(serverID, toolName, text string) string { + raw := json.RawMessage(strings.TrimSpace(text)) + if json.Valid(raw) { + var compact bytes.Buffer + _ = json.Compact(&compact, raw) + raw = compact.Bytes() + } else { + encoded, _ := json.Marshal(text) + raw = encoded + } + return "DWSRAW\t" + serverID + "\t" + toolName + "\t" + string(raw) +} + // formatDevdocSearchTable formats devdoc search JSON results as a table. // Returns true on success, false if the JSON cannot be parsed. func formatDevdocSearchTable(raw string) bool { @@ -490,7 +547,7 @@ func isBusinessError(body map[string]any) bool { if v, ok := body["status"].(string); ok && strings.EqualFold(strings.TrimSpace(v), "error") { return true } - for _, key := range []string{"errorCode", "error_code", "code"} { + for _, key := range []string{"errorCode", "error_code", "errcode", "err_code", "code"} { if isErrorCodeValue(body[key]) { return true } diff --git a/internal/helpers/helpers_core_test.go b/internal/helpers/helpers_core_test.go index f96b3dbd..6896fea3 100644 --- a/internal/helpers/helpers_core_test.go +++ b/internal/helpers/helpers_core_test.go @@ -32,11 +32,39 @@ func (c *helpersCoreCaller) DryRun() bool { return c.dry } func (*helpersCoreCaller) Fields() string { return "" } func (*helpersCoreCaller) JQ() string { return "" } +type helpersReadCaller struct { + *helpersCoreCaller + readResult *edition.ToolResult + readErr error + readCalls int +} + +func (c *helpersReadCaller) CallReadTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) { + c.readCalls++ + return c.readResult, c.readErr +} + func textToolResult(text string) *edition.ToolResult { return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}} } -func installHelpersCoreDeps(t *testing.T, caller *helpersCoreCaller) (*bytes.Buffer, *bytes.Buffer) { +func TestCrossPlatformCoverageRawToolAuditLineIsSingleLineJSON(t *testing.T) { + if got, want := formatRawDumpLine("chat", "list_messages", "{\n \"items\": []\n}"), + `DWSRAW chat list_messages {"items":[]}`; got != want { + t.Fatalf("JSON raw dump = %q, want %q", got, want) + } + if got, want := formatRawDumpLine("chat", "plain", "line one\nline two"), + `DWSRAW chat plain "line one\nline two"`; got != want { + t.Fatalf("text raw dump = %q, want %q", got, want) + } +} + +func TestCrossPlatformCoverageRawToolAuditEnabled(t *testing.T) { + t.Setenv("DWS_DUMP_RAW", "1") + dumpRawToolResponse("im", "search_groups", `{"result":[]}`) +} + +func installHelpersCoreDeps(t *testing.T, caller edition.ToolCaller) (*bytes.Buffer, *bytes.Buffer) { t.Helper() old := deps t.Cleanup(func() { deps = old }) @@ -48,6 +76,58 @@ func installHelpersCoreDeps(t *testing.T, caller *helpersCoreCaller) (*bytes.Buf return out, errOut } +func TestCrossPlatformCoverageDryRunReadLookupUsesExplicitCapability(t *testing.T) { + caller := &helpersReadCaller{ + helpersCoreCaller: &helpersCoreCaller{ + format: "json", + dry: true, + result: textToolResult( + `{"dry_run":true,"request":{"name":"search_groups"}}`, + ), + }, + readResult: textToolResult(`{"success":true,"result":{"groups":[{"id":"g1"}]}}`), + } + installHelpersCoreDeps(t, caller) + got, err := CallMCPReadToolTextOnServer("im", "search_groups", map[string]any{"keyword": "x"}) + if err != nil { + t.Fatalf("CallMCPReadToolTextOnServer() error = %v", err) + } + if !strings.Contains(got, `"groups"`) || caller.readCalls != 1 || caller.calls != 0 { + t.Fatalf("read result/calls = %q, read=%d regular=%d", got, caller.readCalls, caller.calls) + } + + failClosed := &helpersCoreCaller{format: "json", dry: true} + installHelpersCoreDeps(t, failClosed) + if _, err := CallMCPReadToolTextOnServer("im", "search_groups", nil); err == nil { + t.Fatal("dry-run read lookup without explicit capability was accepted") + } + if failClosed.calls != 0 { + t.Fatalf("fail-closed lookup made %d regular calls", failClosed.calls) + } +} + +func TestCrossPlatformCoverageReadLookupInitializationAndRegularExecution(t *testing.T) { + old := deps + t.Cleanup(func() { deps = old }) + deps = nil + if _, err := CallMCPReadToolTextOnServer("im", "search_groups", nil); err == nil { + t.Fatal("uninitialized read lookup unexpectedly succeeded") + } + + caller := &helpersCoreCaller{ + format: "json", + result: textToolResult(`{"success":true,"result":{"groups":[]}}`), + } + installHelpersCoreDeps(t, caller) + got, err := CallMCPReadToolTextOnServer("im", "search_groups", nil) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(got, `"groups"`) || caller.calls != 1 { + t.Fatalf("regular read result/calls = %q, %d", got, caller.calls) + } +} + func TestCrossPlatformCoverageSharedDependenciesRoutingAndWrappers(t *testing.T) { oldDeps := deps deps = nil @@ -106,6 +186,7 @@ func TestCrossPlatformCoverageMCPReturnTextClassification(t *testing.T) { {"business-bool", `{"success":false,"message":"failed"}`}, {"business-string", `{"success":"false","errorMsg":"failed"}`}, {"business-error", `{"error":"failed"}`}, + {"business-webhook-errcode", `{"errcode":300005,"errmsg":"token is not exist"}`}, } for _, tc := range cases { caller.result = textToolResult(tc.text) diff --git a/internal/helpers/helpers_error_test.go b/internal/helpers/helpers_error_test.go index c313726f..22ac1b1a 100644 --- a/internal/helpers/helpers_error_test.go +++ b/internal/helpers/helpers_error_test.go @@ -10,6 +10,8 @@ func TestCrossPlatformCoverageIsBusinessErrorRecognizesRealErrorEnvelopes(t *tes {"status": "error", "success": true, "error": map[string]any{"code": "INVALID_BASE_ID"}}, {"status": " error ", "success": true}, {"success": true, "errorCode": "1001"}, + {"errcode": 300005, "errmsg": "token is not exist"}, + {"err_code": "300005", "errmsg": "token is not exist"}, {"success": true, "error": []any{"failed"}}, {"success": true, "error": true}, {"success": false}, @@ -45,13 +47,24 @@ func TestCrossPlatformCoverageIsBusinessErrorAllowsSuccessEnvelope(t *testing.T) } func TestCrossPlatformCoverageIsBusinessErrorAllowsCodeZeroSuccessEnvelope(t *testing.T) { - body := map[string]any{ - "success": true, - "code": "0", - "message": "success", - "result": []any{}, - } - if isBusinessError(body) { - t.Fatalf("isBusinessError(%v) = true, want false", body) + for _, body := range []map[string]any{ + { + "success": true, + "code": "0", + "message": "success", + "result": []any{}, + }, + { + "errcode": 0, + "errmsg": "ok", + }, + { + "err_code": "0", + "errmsg": "ok", + }, + } { + if isBusinessError(body) { + t.Fatalf("isBusinessError(%v) = true, want false", body) + } } } diff --git a/internal/shortcut/builtin/chat_semantic_catalog_test.go b/internal/shortcut/builtin/chat_semantic_catalog_test.go new file mode 100644 index 00000000..dfdeaaf9 --- /dev/null +++ b/internal/shortcut/builtin/chat_semantic_catalog_test.go @@ -0,0 +1,124 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package builtin_test + +import ( + "encoding/json" + "os" + "sort" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +type chatSemanticCatalogFixture struct { + Service string `json:"service"` + Availability shortcut.Availability `json:"default_availability"` + Shortcuts map[string]struct { + Disposition shortcut.SemanticDisposition `json:"disposition"` + SemanticDelta string `json:"semantic_delta"` + Risk shortcut.Risk `json:"risk"` + Availability shortcut.Availability `json:"availability"` + Primary string `json:"primary"` + Public bool `json:"public"` + Reviewed bool `json:"reviewed"` + } `json:"shortcuts"` +} + +func TestChatSemanticCatalogExactlyCoversRegisteredShortcuts(t *testing.T) { + raw, err := os.ReadFile("../semantic_catalog.json") + if err != nil { + t.Fatal(err) + } + var source chatSemanticCatalogFixture + if err := json.Unmarshal(raw, &source); err != nil { + t.Fatal(err) + } + if source.Service != "chat" { + t.Fatalf("semantic catalog service = %q, want chat", source.Service) + } + + registered := make(map[string]shortcut.Shortcut) + for _, item := range shortcut.All() { + if item.Service != "chat" { + continue + } + if _, duplicate := registered[item.Command]; duplicate { + t.Fatalf("duplicate registered Chat Shortcut %s", item.Command) + } + registered[item.Command] = item + } + if got, want := len(registered), 99; got != want { + t.Fatalf("registered Chat Shortcuts = %d, want %d", got, want) + } + if got, want := len(source.Shortcuts), 99; got != want { + t.Fatalf("reviewed Chat Shortcut records = %d, want %d", got, want) + } + + var missing, stale []string + for command := range registered { + if _, ok := source.Shortcuts[command]; !ok { + missing = append(missing, command) + } + } + for command := range source.Shortcuts { + if _, ok := registered[command]; !ok { + stale = append(stale, command) + } + } + sort.Strings(missing) + sort.Strings(stale) + if len(missing) > 0 || len(stale) > 0 { + t.Fatalf("semantic catalog mismatch: missing=%v stale=%v", missing, stale) + } + + for command, item := range registered { + record := source.Shortcuts[command] + if !record.Reviewed || !item.SemanticReviewed { + t.Errorf("%s: semantic decision is not reviewed", command) + } + if strings.TrimSpace(record.SemanticDelta) == "" || + item.SemanticDelta != record.SemanticDelta { + t.Errorf("%s: semantic delta was not delivered exactly", command) + } + if item.Disposition != record.Disposition { + t.Errorf("%s: disposition = %q, want %q", command, item.Disposition, record.Disposition) + } + deliveredRisk := item.Risk + if deliveredRisk == "" { + deliveredRisk = shortcut.RiskRead + } + if deliveredRisk != record.Risk { + t.Errorf("%s: runtime risk = %q, reviewed risk = %q", command, deliveredRisk, record.Risk) + } + reviewedAvailability := record.Availability + if reviewedAvailability == "" { + reviewedAvailability = source.Availability + } + if item.Availability != reviewedAvailability { + t.Errorf("%s: availability = %q, want %q", command, item.Availability, reviewedAvailability) + } + if got := shortcut.InPublicCatalog("chat", command); got != record.Public { + t.Errorf("%s: InPublicCatalog = %v, want %v", command, got, record.Public) + } + if record.Public != !item.Hidden { + t.Errorf("%s: delivered public = %v, catalog public = %v", command, !item.Hidden, record.Public) + } + if reviewedAvailability == shortcut.AvailabilityAvailable && (!record.Public || item.Hidden) { + t.Errorf("%s: available reviewed Chat Shortcut must be public", command) + } + if reviewedAvailability != shortcut.AvailabilityAvailable && (record.Public || !item.Hidden) { + t.Errorf("%s: %s reviewed Chat Shortcut must be hidden", command, reviewedAvailability) + } + if record.Disposition == shortcut.DispositionAliasInternal { + primary, ok := registered[record.Primary] + if !ok { + t.Errorf("%s: primary %q is not registered", command, record.Primary) + } else if primary.Hidden { + t.Errorf("%s: primary %q is not public", command, record.Primary) + } + } + } +} diff --git a/internal/shortcut/chat/batch_write.go b/internal/shortcut/chat/batch_write.go new file mode 100644 index 00000000..71fa6e3b --- /dev/null +++ b/internal/shortcut/chat/batch_write.go @@ -0,0 +1,68 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. + +package chat + +import ( + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +type shortcutBatchWrite struct { + target string + arguments map[string]any +} + +// executeShortcutBatchWrite gives batch-style IM mutations one shared result +// contract. Dry-run publishes executable per-item actions without calling a +// write tool. Real execution preserves input order and records every failure +// instead of aborting the remaining items. +func executeShortcutBatchWrite(rt *shortcut.RuntimeContext, product, tool string, items []shortcutBatchWrite) error { + if rt.DryRun() { + actions := make([]map[string]any, 0, len(items)) + for _, item := range items { + actions = append(actions, map[string]any{ + "target": item.target, + "tool": tool, + "arguments": item.arguments, + }) + } + return rt.Output(map[string]any{ + "dry_run": true, + "executed": false, + "preview_kind": "plan", + "tool": tool, + "actionCount": len(actions), + "failedCount": 0, + "actions": actions, + "requestedCount": len(items), + }) + } + + succeeded := make([]map[string]any, 0, len(items)) + failures := make([]map[string]any, 0) + for _, item := range items { + data, err := rt.CallMCPWriteData(product, tool, item.arguments) + if err != nil { + failures = append(failures, map[string]any{ + "target": item.target, + "error": err.Error(), + }) + continue + } + entry := map[string]any{"target": item.target} + if len(data) > 0 { + entry["result"] = data + } + succeeded = append(succeeded, entry) + } + return rt.Output(map[string]any{ + "ok": len(failures) == 0, + "partial": len(succeeded) > 0 && len(failures) > 0, + "requestedCount": len(items), + "succeededCount": len(succeeded), + "failedCount": len(failures), + "succeeded": succeeded, + "failures": failures, + }) +} diff --git a/internal/shortcut/chat/chat_conversation.go b/internal/shortcut/chat/chat_conversation.go index 618e1c41..7343dbf9 100644 --- a/internal/shortcut/chat/chat_conversation.go +++ b/internal/shortcut/chat/chat_conversation.go @@ -15,8 +15,12 @@ package chat import ( "fmt" + "strings" + "unicode/utf8" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg" ) // ConversationInfo gets conversation info (get_conversation_info, chat server). @@ -52,21 +56,56 @@ var ConversationSetTop = shortcut.Shortcut{ Service: "chat", Command: "+conversation-set-top", Product: "im", - Description: "会话置顶 / 取消置顶(支持单聊/群聊)", - Intent: "当你想把某个单聊或群聊置顶到会话列表顶部、或取消其置顶时使用;会实际修改该会话的置顶状态,需传 openConversationId,加 --off 取消置顶。", + Description: "批量会话置顶 / 取消置顶(最多 10 个)", + Intent: "当你想把一个或多个单聊/群聊置顶到会话列表顶部、或取消置顶时使用;支持 1-10 个 openConversationId,逐项执行并返回成功/失败 ledger,某一项失败不阻断其余项。", Risk: shortcut.RiskWrite, Flags: []shortcut.Flag{ - {Name: "conversation-id", Type: shortcut.FlagString, Desc: "会话 openConversationId", Required: true}, + {Name: "conversation-id", Type: shortcut.FlagString, Desc: "单个会话 openConversationId"}, + {Name: "conversation-ids", Type: shortcut.FlagStringSlice, Desc: "多个会话 openConversationId(最多 10 个)"}, {Name: "off", Type: shortcut.FlagBool, Desc: "取消置顶(不传则设置置顶)"}, }, - Tips: []string{`dws chat +conversation-set-top --conversation-id `}, - Execute: func(rt *shortcut.RuntimeContext) error { - return rt.CallMCP("set_top_conversation", map[string]any{ - "openConversationId": rt.Str("conversation-id"), - "cid": rt.Str("conversation-id"), - "top": !rt.Bool("off"), - }) + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintAtLeastOne, Flags: []string{"conversation-id", "conversation-ids"}}, + { + Kind: shortcut.ConstraintCustom, + Flags: []string{"conversation-id", "conversation-ids"}, + Description: "会话 ID 去重后必须为 1-10 个", + }, }, + Tips: []string{ + `dws chat +conversation-set-top --conversation-id `, + `dws chat +conversation-set-top --conversation-ids , --off`, + }, + Validate: func(rt *shortcut.RuntimeContext) error { + ids := conversationSetTopIDs(rt) + if len(ids) < 1 || len(ids) > 10 { + return apperrors.NewValidation(fmt.Sprintf("会话 ID 去重后必须为 1-10 个,当前 %d 个", len(ids))) + } + return nil + }, + Execute: func(rt *shortcut.RuntimeContext) error { + ids := conversationSetTopIDs(rt) + items := make([]shortcutBatchWrite, 0, len(ids)) + for _, id := range ids { + items = append(items, shortcutBatchWrite{ + target: id, + arguments: map[string]any{ + "openConversationId": id, + "cid": id, + "top": !rt.Bool("off"), + }, + }) + } + return executeShortcutBatchWrite(rt, "im", "set_top_conversation", items) + }, +} + +func conversationSetTopIDs(rt *shortcut.RuntimeContext) []string { + values := append([]string{}, rt.StrSlice("conversation-ids")...) + if value := rt.Str("conversation-id"); value != "" { + values = append(values, value) + } + return uniqueShortcutStrings(values) } // ConversationMute mutes/unmutes a conversation (update_notification_off, im). @@ -107,7 +146,6 @@ var ConversationMuteAtAll = shortcut.Shortcut{ Execute: func(rt *shortcut.RuntimeContext) error { return rt.CallMCP("update_at_all_notification_off", map[string]any{ "openConversationId": rt.Str("conversation-id"), - "cid": rt.Str("conversation-id"), "mute": !rt.Bool("off"), }) }, @@ -129,7 +167,6 @@ var ConversationMuteRedEnvelope = shortcut.Shortcut{ Execute: func(rt *shortcut.RuntimeContext) error { return rt.CallMCP("update_red_env_notification_off", map[string]any{ "openConversationId": rt.Str("conversation-id"), - "cid": rt.Str("conversation-id"), "mute": !rt.Bool("off"), }) }, @@ -220,13 +257,7 @@ var ConversationList = shortcut.Shortcut{ } convs := conversationListProject(data) payload := map[string]any{"count": len(convs), "conversations": convs} - // carry pagination hints when present so翻页仍可继续(字段防御式探测)。 - if v, ok := conversationListFirst(data, "nextCursor", "cursor"); ok { - payload["nextCursor"] = v - } - if v, ok := conversationListFirst(data, "hasMore", "has_more"); ok { - payload["hasMore"] = v - } + chatmsg.ApplyPagination(payload, data) return rt.Output(payload) }, } @@ -297,15 +328,19 @@ func conversationListFirst(m map[string]any, keys ...string) (any, bool) { var ConversationListTop = shortcut.Shortcut{ Service: "chat", Command: "+conversation-list-top", - Description: "拉取置顶会话列表", - Intent: "当你只想查看被置顶的那些会话时使用;只读分页返回置顶会话列表,可用 --exclude-muted 排除已免打扰会话。", + Description: "拉取置顶会话列表,可只看群聊或单聊", + Intent: "当你只想查看被置顶的那些会话时使用;只读分页返回置顶会话列表,并把下层 singleChat 规范化为 conversationType=group|direct。可用 --type group 只看群聊、--type direct 只看单聊,或用 --exclude-muted 排除已免打扰会话。", Risk: shortcut.RiskRead, Flags: []shortcut.Flag{ {Name: "limit", Type: shortcut.FlagInt, Desc: "每页数量"}, {Name: "cursor", Type: shortcut.FlagInt, Desc: "分页游标(首次不传或 0)"}, {Name: "exclude-muted", Type: shortcut.FlagBool, Desc: "排除已免打扰会话"}, + {Name: "type", Type: shortcut.FlagString, Default: "all", Desc: "会话类型:all 全部 / group 群聊 / direct 单聊(当前页本地过滤)", Enum: []string{"all", "group", "direct"}}, + }, + Tips: []string{ + `dws chat +conversation-list-top --limit 1000`, + `dws chat +conversation-list-top --type group --limit 1000`, }, - Tips: []string{`dws chat +conversation-list-top --limit 1000`}, Execute: func(rt *shortcut.RuntimeContext) error { params := map[string]any{} if rt.Int("limit") > 0 { @@ -322,13 +357,14 @@ var ConversationListTop = shortcut.Shortcut{ return err } convs := conversationListTopProject(data) - payload := map[string]any{"count": len(convs), "conversations": convs} - if v, ok := conversationListTopFirst(data, "nextCursor", "cursor"); ok { - payload["nextCursor"] = v - } - if v, ok := conversationListTopFirst(data, "hasMore", "has_more"); ok { - payload["hasMore"] = v + typeFilter := rt.Str("type") + convs = conversationListTopFilter(convs, typeFilter) + payload := map[string]any{ + "count": len(convs), + "requestedType": typeFilter, + "conversations": convs, } + chatmsg.ApplyPagination(payload, data) return rt.Output(payload) }, } @@ -353,8 +389,8 @@ func conversationListTopProject(data map[string]any) []map[string]any { if v, ok := conversationListTopFirst(m, "conversationName", "name", "title"); ok { row["conversationName"] = v } - if v, ok := conversationListTopFirst(m, "conversationType", "type"); ok { - row["conversationType"] = v + if conversationType, ok := conversationListTopType(m); ok { + row["conversationType"] = conversationType } if len(row) > 0 { out = append(out, row) @@ -363,6 +399,67 @@ func conversationListTopProject(data map[string]any) []map[string]any { return out } +// conversationListTopType converts the lower service's singleChat flag into a +// stable, Agent-facing type. The fallback accepts known type spellings for +// compatibility with older/newer response projections. +func conversationListTopType(m map[string]any) (string, bool) { + if value, ok := conversationListTopFirst(m, "singleChat", "single_chat", "isSingleChat"); ok { + switch typed := value.(type) { + case bool: + if typed { + return "direct", true + } + return "group", true + case string: + switch strings.ToLower(strings.TrimSpace(typed)) { + case "true", "1": + return "direct", true + case "false", "0": + return "group", true + } + case float64: + if typed == 1 { + return "direct", true + } + if typed == 0 { + return "group", true + } + case int: + if typed == 1 { + return "direct", true + } + if typed == 0 { + return "group", true + } + } + } + + if value, ok := conversationListTopFirst(m, "conversationType", "type"); ok { + if text, ok := value.(string); ok { + switch strings.ToLower(strings.TrimSpace(text)) { + case "group", "groupchat", "group_chat": + return "group", true + case "direct", "single", "singlechat", "single_chat", "p2p": + return "direct", true + } + } + } + return "", false +} + +func conversationListTopFilter(conversations []map[string]any, typeFilter string) []map[string]any { + if typeFilter == "" || typeFilter == "all" { + return conversations + } + filtered := make([]map[string]any, 0, len(conversations)) + for _, conversation := range conversations { + if conversation["conversationType"] == typeFilter { + filtered = append(filtered, conversation) + } + } + return filtered +} + // conversationListTopResolveList locates the conversation array inside the // response, tolerating a bare top-level list or nesting one level under a // common envelope. @@ -561,12 +658,7 @@ var CategoryListConversations = shortcut.Shortcut{ } convs := categoryConversationsProject(data) payload := map[string]any{"count": len(convs), "conversations": convs} - if v, ok := categoryConversationsFirst(data, "nextCursor", "cursor"); ok { - payload["nextCursor"] = v - } - if v, ok := categoryConversationsFirst(data, "hasMore", "has_more"); ok { - payload["hasMore"] = v - } + chatmsg.ApplyPagination(payload, data) return rt.Output(payload) }, } @@ -634,20 +726,44 @@ func categoryConversationsFirst(m map[string]any, keys ...string) (any, bool) { return nil, false } +const maxConversationCategoryTitleRunes = 15 + +func validateConversationCategoryTitle(rt *shortcut.RuntimeContext) error { + title := strings.TrimSpace(rt.Str("title")) + if title == "" { + return apperrors.NewValidation("--title 不能为空") + } + if utf8.RuneCountInString(title) > maxConversationCategoryTitleRunes { + return apperrors.NewValidation(fmt.Sprintf( + "--title 最多 %d 个字符", maxConversationCategoryTitleRunes)) + } + return nil +} + // CategoryCreate creates a conversation category (create_conv_category, im). var CategoryCreate = shortcut.Shortcut{ Service: "chat", Command: "+category-create", Product: "im", Description: "创建用户自定义会话分组", - Intent: "当你想新建一个会话分组来归类会话时使用;会实际创建分组并返回其 ID,需传分组名称 --title。", + Intent: "当你想新建一个会话分组来归类会话时使用;会实际创建分组并返回其 ID,需传最多 15 个字符的分组名称 --title。", Risk: shortcut.RiskWrite, Flags: []shortcut.Flag{ - {Name: "title", Type: shortcut.FlagString, Desc: "分组名称", Required: true}, + {Name: "title", Type: shortcut.FlagString, Desc: "分组名称;去除首尾空白后必须非空,且最多 15 个字符", Required: true}, }, - Tips: []string{`dws chat +category-create --title "工作群"`}, + Constraints: []shortcut.Constraint{ + { + Kind: shortcut.ConstraintCustom, + Flags: []string{"title"}, + Description: "--title 去除首尾空白后必须非空,且最多 15 个字符", + }, + }, + Tips: []string{`dws chat +category-create --title "工作群"`}, + Validate: validateConversationCategoryTitle, Execute: func(rt *shortcut.RuntimeContext) error { - return rt.CallMCP("create_conv_category", map[string]any{"title": rt.Str("title")}) + return rt.CallMCP("create_conv_category", map[string]any{ + "title": strings.TrimSpace(rt.Str("title")), + }) }, } @@ -674,17 +790,25 @@ var CategoryRename = shortcut.Shortcut{ Command: "+category-rename", Product: "im", Description: "更新用户自定义会话分组的名称", - Intent: "当你想重命名已有的自定义会话分组时使用;会实际更新分组名称,需传 categoryId 和新名称 --title。", + Intent: "当你想重命名已有的自定义会话分组时使用;会实际更新分组名称,需传 categoryId 和最多 15 个字符的新名称 --title。", Risk: shortcut.RiskWrite, Flags: []shortcut.Flag{ {Name: "category-id", Type: shortcut.FlagInt, Desc: "会话分组 ID", Required: true}, - {Name: "title", Type: shortcut.FlagString, Desc: "新的分组名称", Required: true}, + {Name: "title", Type: shortcut.FlagString, Desc: "新的分组名称;去除首尾空白后必须非空,且最多 15 个字符", Required: true}, }, - Tips: []string{`dws chat +category-rename --category-id <分组ID> --title "新名称"`}, + Constraints: []shortcut.Constraint{ + { + Kind: shortcut.ConstraintCustom, + Flags: []string{"title"}, + Description: "--title 去除首尾空白后必须非空,且最多 15 个字符", + }, + }, + Tips: []string{`dws chat +category-rename --category-id <分组ID> --title "新名称"`}, + Validate: validateConversationCategoryTitle, Execute: func(rt *shortcut.RuntimeContext) error { return rt.CallMCP("rename_conv_category", map[string]any{ "categoryId": rt.Int("category-id"), - "title": rt.Str("title"), + "title": strings.TrimSpace(rt.Str("title")), }) }, } diff --git a/internal/shortcut/chat/chat_conversation_projection_test.go b/internal/shortcut/chat/chat_conversation_projection_test.go new file mode 100644 index 00000000..26c25dfc --- /dev/null +++ b/internal/shortcut/chat/chat_conversation_projection_test.go @@ -0,0 +1,89 @@ +// 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 chat + +import ( + "reflect" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" +) + +func TestConversationListTopProjectNormalizesAndFiltersType(t *testing.T) { + data := map[string]any{ + "result": map[string]any{ + "items": []any{ + map[string]any{ + "openConversationId": "cid-direct", + "title": "张三", + "singleChat": true, + }, + map[string]any{ + "openConversationId": "cid-group", + "title": "项目群", + "singleChat": false, + }, + map[string]any{ + "openConversationId": "cid-legacy", + "title": "旧版单聊", + "conversationType": "P2P", + }, + map[string]any{ + "openConversationId": "cid-unknown", + "title": "未知类型", + }, + }, + }, + } + + all := conversationListTopProject(data) + if len(all) != 4 { + t.Fatalf("all conversations = %#v", all) + } + if got := all[0]["conversationType"]; got != "direct" { + t.Errorf("singleChat=true type = %#v, want direct", got) + } + if got := all[1]["conversationType"]; got != "group" { + t.Errorf("singleChat=false type = %#v, want group", got) + } + if got := all[2]["conversationType"]; got != "direct" { + t.Errorf("legacy P2P type = %#v, want direct", got) + } + if _, ok := all[3]["conversationType"]; ok { + t.Errorf("unknown type was fabricated: %#v", all[3]) + } + + if got, want := conversationListTopFilter(all, "group"), []map[string]any{all[1]}; !reflect.DeepEqual(got, want) { + t.Errorf("group filter = %#v, want %#v", got, want) + } + if got, want := conversationListTopFilter(all, "direct"), []map[string]any{all[0], all[2]}; !reflect.DeepEqual(got, want) { + t.Errorf("direct filter = %#v, want %#v", got, want) + } + if got := conversationListTopFilter(all, "all"); !reflect.DeepEqual(got, all) { + t.Errorf("all filter changed rows: %#v", got) + } +} + +func TestConversationListTopRejectsInvalidType(t *testing.T) { + fake := &platformCoverageCaller{} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + root.SetArgs([]string{"chat", "+conversation-list-top", "--type", "bot"}) + if err := root.Execute(); err == nil { + t.Fatal("invalid --type unexpectedly succeeded") + } + if fake.tool != "" { + t.Fatalf("invalid --type reached lower tool %s/%s", fake.product, fake.tool) + } +} diff --git a/internal/shortcut/chat/chat_group.go b/internal/shortcut/chat/chat_group.go index 25527904..eab450f9 100644 --- a/internal/shortcut/chat/chat_group.go +++ b/internal/shortcut/chat/chat_group.go @@ -15,8 +15,11 @@ package chat import ( "fmt" + "strconv" + "strings" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg" ) // ChatSearch searches groups by keyword (search_groups on the im server). @@ -306,7 +309,9 @@ var ChatListMine = shortcut.Shortcut{ return err } groups := chatListMineProject(data) - return rt.Output(map[string]any{"count": len(groups), "groups": groups}) + payload := map[string]any{"count": len(groups), "groups": groups} + chatmsg.ApplyPagination(payload, data) + return rt.Output(payload) }, } @@ -370,12 +375,7 @@ var ChatListAll = shortcut.Shortcut{ } groups := chatListAllProject(data) payload := map[string]any{"count": len(groups), "groups": groups} - if v, ok := chatGroupFirst(data, "nextCursor", "next_cursor", "cursor"); ok { - payload["nextCursor"] = v - } - if v, ok := chatGroupFirst(data, "hasMore", "has_more"); ok { - payload["hasMore"] = v - } + chatmsg.ApplyPagination(payload, data) return rt.Output(payload) }, } @@ -681,6 +681,19 @@ var ChatMuteMember = shortcut.Shortcut{ Execute: func(rt *shortcut.RuntimeContext) error { userIDs, openIDs := splitIDs(rt.StrSlice("users")) off := rt.Bool("off") + if len(userIDs) > 0 { + resolved, err := resolveMuteMemberOpenIDs(rt, rt.Str("group"), userIDs) + if err != nil { + return err + } + openIDs = append(openIDs, resolved...) + userIDs = nil + } + deduplicatedOpenIDs := make([]string, 0, len(openIDs)) + for _, openID := range openIDs { + deduplicatedOpenIDs = appendUniqueShortcutString(deduplicatedOpenIDs, openID) + } + openIDs = deduplicatedOpenIDs params := map[string]any{ "openConversationId": rt.Str("group"), "cid": rt.Str("group"), @@ -702,6 +715,124 @@ var ChatMuteMember = shortcut.Shortcut{ }, } +// resolveMuteMemberOpenIDs mirrors the native group-mute-member compatibility +// path. The service currently rejects its documented uids input with +// "uids is required", while openDingTalkIds succeeds. Resolve userIds through +// the directory and the target group's member list so the Shortcut's advertised +// mixed-ID contract remains executable instead of forwarding a known-broken +// parameter shape. +func resolveMuteMemberOpenIDs(rt *shortcut.RuntimeContext, groupID string, userIDs []string) ([]string, error) { + contacts, err := rt.CallMCPData("contact", "get_user_info_by_user_ids", map[string]any{ + "user_id_list": userIDs, + }) + if err != nil { + return nil, fmt.Errorf("解析禁言成员 userId 失败: %w", err) + } + + requested := make(map[string]struct{}, len(userIDs)) + for _, userID := range userIDs { + requested[userID] = struct{}{} + } + nameByUserID := make(map[string]string, len(userIDs)) + for _, item := range shortcutMapSlice(contacts["result"]) { + employee, _ := item["orgEmployeeModel"].(map[string]any) + userID := shortcutString(employee, "orgUserId", "userId") + if _, ok := requested[userID]; !ok { + continue + } + nameByUserID[userID] = shortcutString(employee, "orgUserName", "name") + } + for _, userID := range userIDs { + if strings.TrimSpace(nameByUserID[userID]) == "" { + return nil, fmt.Errorf("无法从通讯录解析成员 userId %q 的姓名;请改传 openDingTalkId", userID) + } + } + + openIDsByName := map[string][]string{} + cursor := "0" + for page := 0; page < 50; page++ { + members, callErr := rt.CallMCPData("chat", "get_group_members", map[string]any{ + "openconversation_id": groupID, + "cursor": cursor, + }) + if callErr != nil { + return nil, fmt.Errorf("读取目标群成员以解析 userId 失败: %w", callErr) + } + result, _ := members["result"].(map[string]any) + for _, member := range shortcutMapSlice(result["list"]) { + name := shortcutString(member, "memberEmpName", "empName", "name") + openID := shortcutString(member, "openDingtalkId", "openDingTalkId", "memberDingtalkId") + if name == "" || openID == "" { + continue + } + openIDsByName[name] = appendUniqueShortcutString(openIDsByName[name], openID) + } + hasMore, _ := result["hasMore"].(bool) + if !hasMore { + break + } + next := shortcutString(result, "nextCursor", "cursor") + if next == "" || next == cursor { + return nil, fmt.Errorf("群成员分页返回 hasMore=true 但缺少可继续的 cursor") + } + cursor = next + if page == 49 { + return nil, fmt.Errorf("群成员分页超过 50 页,无法安全解析禁言目标") + } + } + + resolved := make([]string, 0, len(userIDs)) + for _, userID := range userIDs { + name := nameByUserID[userID] + matches := openIDsByName[name] + if len(matches) != 1 { + return nil, fmt.Errorf( + "群内姓名 %q 对应 %d 个成员,无法把 userId %q 唯一解析为 openDingTalkId;请直接传 openDingTalkId", + name, len(matches), userID) + } + resolved = append(resolved, matches[0]) + } + return resolved, nil +} + +func shortcutMapSlice(value any) []map[string]any { + items, _ := value.([]any) + out := make([]map[string]any, 0, len(items)) + for _, item := range items { + if mapped, ok := item.(map[string]any); ok { + out = append(out, mapped) + } + } + return out +} + +func shortcutString(value map[string]any, keys ...string) string { + for _, key := range keys { + switch typed := value[key].(type) { + case string: + if text := strings.TrimSpace(typed); text != "" { + return text + } + case float64: + return strconv.FormatFloat(typed, 'f', -1, 64) + case int: + return strconv.Itoa(typed) + case int64: + return strconv.FormatInt(typed, 10) + } + } + return "" +} + +func appendUniqueShortcutString(values []string, candidate string) []string { + for _, value := range values { + if value == candidate { + return values + } + } + return append(values, candidate) +} + // ── group-role: 群身份管理 (im) ────────────────────────────── // ChatRoleList lists custom group roles (list_custom_group_roles, im). diff --git a/internal/shortcut/chat/chat_message.go b/internal/shortcut/chat/chat_message.go index af9199dc..12a385b1 100644 --- a/internal/shortcut/chat/chat_message.go +++ b/internal/shortcut/chat/chat_message.go @@ -15,6 +15,9 @@ package chat import ( "fmt" + "os" + "path/filepath" + "strings" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg" @@ -214,6 +217,7 @@ var MessagesList = shortcut.Shortcut{ {Name: "forward", Type: shortcut.FlagBool, Default: "true", Desc: "true=从给定时间往现在拉,false=往以前拉"}, {Name: "limit", Type: shortcut.FlagInt, Desc: "每页返回数量"}, {Name: "size", Type: shortcut.FlagInt, Desc: "--limit 的旧版别名", Hidden: true}, + {Name: "no-reactions", Type: shortcut.FlagBool, Desc: "不输出消息 reaction(默认输出)"}, }, Constraints: []shortcut.Constraint{ {Kind: shortcut.ConstraintExactlyOne, Flags: []string{"group", "conversation-id", "id"}}, @@ -233,8 +237,14 @@ var MessagesList = shortcut.Shortcut{ if err != nil { return err } - messages := listMessagesProject(data) - return rt.Output(map[string]any{"count": len(messages), "messages": messages}) + messages := listMessagesProjectWithReactions(data, !rt.Bool("no-reactions")) + payload := map[string]any{"count": len(messages), "messages": messages} + direction := "older" + if rt.Bool("forward") { + direction = "newer" + } + chatmsg.ApplyMessagePagination(payload, data, listMessagesResolveMaps(data), direction) + return rt.Output(payload) }, } @@ -249,6 +259,10 @@ var MessagesList = shortcut.Shortcut{ // records ("聊天记录") expand their nested messages under "forwarded" instead of // collapsing to a "[卡片]" summary. func listMessagesProject(data map[string]any) []map[string]any { + return listMessagesProjectWithReactions(data, true) +} + +func listMessagesProjectWithReactions(data map[string]any, includeReactions bool) []map[string]any { raw := listMessagesResolveList(data) out := make([]map[string]any, 0, len(raw)) for _, item := range raw { @@ -256,7 +270,7 @@ func listMessagesProject(data map[string]any) []map[string]any { if !ok { continue } - if row := listMessageProjectOne(m); len(row) > 0 { + if row := listMessageProjectOneWithReactions(m, includeReactions); len(row) > 0 { out = append(out, row) } } @@ -267,6 +281,10 @@ func listMessagesProject(data map[string]any) []map[string]any { // {messageId, senderId, msgType, createTime, text(, forwarded)} shape, reused // recursively for forwarded chat records. func listMessageProjectOne(m map[string]any) map[string]any { + return listMessageProjectOneWithReactions(m, true) +} + +func listMessageProjectOneWithReactions(m map[string]any, includeReactions bool) map[string]any { row := map[string]any{} if v, ok := listMessagesFirst(m, "openMessageId", "openMsgId", "messageId", "msgId"); ok { row["messageId"] = v @@ -283,7 +301,30 @@ func listMessageProjectOne(m map[string]any) map[string]any { if text := chatmsg.Text(m); text != nil { row["text"] = text } - if forwarded := chatmsg.Forwarded(m, listMessageProjectOne); len(forwarded) > 0 { + if conversationID := chatmsg.ConversationID(m); conversationID != nil { + row["conversationId"] = conversationID + } + if threadID := chatmsg.ThreadID(m); threadID != nil { + row["threadId"] = threadID + } + if updateTime := chatmsg.UpdateTime(m); updateTime != nil { + row["updateTime"] = updateTime + } + if includeReactions { + if reactions := chatmsg.Reactions(m); len(reactions) > 0 { + row["reactions"] = reactions + } + } + if quoted := chatmsg.QuotedMessage(m); len(quoted) > 0 { + row["quotedMessage"] = quoted + } + if resources := chatmsg.Resources(m); len(resources) > 0 { + row["resourceRefs"] = resources + } + projectForwarded := func(item map[string]any) map[string]any { + return listMessageProjectOneWithReactions(item, includeReactions) + } + if forwarded := chatmsg.Forwarded(m, projectForwarded); len(forwarded) > 0 { row["forwarded"] = forwarded } return row @@ -311,6 +352,17 @@ func listMessagesResolveList(data map[string]any) []any { return []any{} } +func listMessagesResolveMaps(data map[string]any) []map[string]any { + raw := listMessagesResolveList(data) + out := make([]map[string]any, 0, len(raw)) + for _, item := range raw { + if message, ok := item.(map[string]any); ok { + out = append(out, message) + } + } + return out +} + // listMessagesFirst returns the first present candidate key's value. func listMessagesFirst(m map[string]any, keys ...string) (any, bool) { for _, k := range keys { @@ -335,6 +387,7 @@ var MessagesListDirect = shortcut.Shortcut{ {Name: "forward", Type: shortcut.FlagBool, Default: "true", Desc: "true=往现在拉,false=往以前拉"}, {Name: "limit", Type: shortcut.FlagInt, Desc: "每页返回数量"}, {Name: "size", Type: shortcut.FlagInt, Desc: "--limit 的旧版别名", Hidden: true}, + {Name: "no-reactions", Type: shortcut.FlagBool, Desc: "不输出消息 reaction(默认输出)"}, }, Tips: []string{`dws chat +messages-list-direct --user --time "2025-03-01 00:00:00"`}, Execute: func(rt *shortcut.RuntimeContext) error { @@ -357,8 +410,14 @@ var MessagesListDirect = shortcut.Shortcut{ if err != nil { return err } - messages := listMessagesProject(data) - return rt.Output(map[string]any{"count": len(messages), "messages": messages}) + messages := listMessagesProjectWithReactions(data, !rt.Bool("no-reactions")) + payload := map[string]any{"count": len(messages), "messages": messages} + direction := "older" + if rt.Bool("forward") { + direction = "newer" + } + chatmsg.ApplyMessagePagination(payload, data, listMessagesResolveMaps(data), direction) + return rt.Output(payload) }, } @@ -466,15 +525,183 @@ var MessagesMget = shortcut.Shortcut{ Command: "+messages-mget", Product: "im", Description: "根据消息 ID 批量查询消息(最多 50 条)", - Intent: "当你已有一批消息 openMsgId、需要批量取回它们的详细内容时使用;只读,一次最多查询 50 条。", + Intent: "当你已有一批消息 openMsgId、需要批量取回完整详情、reaction 和可执行资源引用时使用;一次最多 50 条。--download-resources 可把所有可识别 mediaId 安全下载到工作目录内,并逐资源返回成功/失败 ledger。", Risk: shortcut.RiskRead, Flags: []shortcut.Flag{ - {Name: "msg-ids", Type: shortcut.FlagStringSlice, Desc: "消息 openMsgId 列表", Required: true}, + {Name: "msg-ids", Type: shortcut.FlagStringSlice, Desc: "消息 openMsgId 列表;--msg-ids 去重后必须包含 1-50 条消息 ID", Required: true}, + {Name: "no-reactions", Type: shortcut.FlagBool, Desc: "不输出消息 reaction(默认输出)"}, + {Name: "download-resources", Type: shortcut.FlagBool, Desc: "自动下载消息中的全部可识别 mediaId 资源"}, + {Name: "output-dir", Type: shortcut.FlagString, Default: "./downloads", Desc: "资源输出目录;--output-dir 必须是工作目录内的相对路径,不允许绝对路径或 .. 逃逸"}, + {Name: "overwrite", Type: shortcut.FlagBool, Desc: "允许覆盖同名资源文件(默认拒绝)"}, + }, + Constraints: []shortcut.Constraint{ + { + Kind: shortcut.ConstraintCustom, + Flags: []string{"msg-ids"}, + Description: "--msg-ids 去重后必须包含 1-50 条消息 ID", + }, + { + Kind: shortcut.ConstraintCustom, + Flags: []string{"output-dir"}, + Description: "--output-dir 必须是工作目录内的相对路径,不允许绝对路径或 .. 逃逸", + }, }, Tips: []string{`dws chat +messages-mget --msg-ids msgId1,msgId2`}, - Execute: func(rt *shortcut.RuntimeContext) error { - return rt.CallMCP("list_messages_by_ids", map[string]any{"openMsgIds": rt.StrSlice("msg-ids")}) + Validate: func(rt *shortcut.RuntimeContext) error { + ids := uniqueShortcutStrings(rt.StrSlice("msg-ids")) + if len(ids) < 1 || len(ids) > 50 { + return fmt.Errorf("--msg-ids 去重后必须包含 1-50 条消息 ID,当前 %d 条", len(ids)) + } + if rt.Bool("download-resources") { + if err := validateResourceDownloadOutputFlag(rt.Str("output-dir"), "--output-dir"); err != nil { + return err + } + } + return nil }, + Execute: func(rt *shortcut.RuntimeContext) error { + ids := uniqueShortcutStrings(rt.StrSlice("msg-ids")) + data, err := rt.CallMCPData("im", "list_messages_by_ids", map[string]any{"openMsgIds": ids}) + if err != nil { + return err + } + rawMessages := listMessagesResolveMaps(data) + messages := listMessagesProjectWithReactions(data, !rt.Bool("no-reactions")) + found := map[string]bool{} + for _, message := range rawMessages { + if id := strings.TrimSpace(fmt.Sprint(chatmsg.MessageID(message))); id != "" && id != "" { + found[id] = true + } + } + notFound := make([]string, 0) + for _, id := range ids { + if !found[id] { + notFound = append(notFound, id) + } + } + payload := map[string]any{ + "requestedCount": len(ids), + "foundCount": len(ids) - len(notFound), + "notFoundCount": len(notFound), + "notFoundMessageIds": notFound, + "messages": messages, + } + if rt.Bool("download-resources") { + ledger, err := downloadMgetResources(rt, rawMessages) + if err != nil { + return err + } + payload["resourceDownloads"] = ledger + } + return rt.Output(payload) + }, +} + +func downloadMgetResources(rt *shortcut.RuntimeContext, messages []map[string]any) (map[string]any, error) { + resources := make([]map[string]any, 0) + for _, message := range messages { + resources = append(resources, chatmsg.Resources(message)...) + } + if rt.DryRun() { + return map[string]any{ + "dryRun": true, + "requestedCount": len(resources), + "resources": resources, + }, nil + } + + cwd, err := resourceGetwd() + if err != nil { + return nil, fmt.Errorf("读取工作目录失败: %w", err) + } + outputDir := strings.TrimRight(rt.Str("output-dir"), `/\`) + string(os.PathSeparator) + downloads := make([]map[string]any, 0, len(resources)) + failures := make([]map[string]any, 0) + for _, resource := range resources { + resourceID := strings.TrimSpace(fmt.Sprint(resource["resourceId"])) + download, _ := resource["download"].(map[string]any) + arguments, _ := download["arguments"].(map[string]any) + messageID := strings.TrimSpace(fmt.Sprint(arguments["message-id"])) + conversationID := strings.TrimSpace(fmt.Sprint(arguments["open-conversation-id"])) + if download["ready"] != true || resourceID == "" || messageID == "" || conversationID == "" { + failures = append(failures, map[string]any{ + "resourceId": resourceID, + "messageId": messageID, + "error": "资源引用缺少 message-id 或 open-conversation-id", + }) + continue + } + + data, callErr := rt.CallMCPData("im", "get_resource_download_url", map[string]any{ + "resourceType": "mediaId", + "resourceId": resourceID, + "openMessageId": messageID, + "openConversationId": conversationID, + }) + if callErr != nil { + failures = append(failures, map[string]any{ + "resourceId": resourceID, + "messageId": messageID, + "error": callErr.Error(), + }) + continue + } + resourceURL, headers, infoErr := resourceDownloadInfo(data) + if infoErr != nil { + failures = append(failures, map[string]any{ + "resourceId": resourceID, + "messageId": messageID, + "error": infoErr.Error(), + }) + continue + } + destPath, relativePath, pathErr := resolveResourceDownloadPath( + cwd, outputDir, resourceURL, rt.Bool("overwrite")) + if pathErr != nil { + failures = append(failures, map[string]any{ + "resourceId": resourceID, + "messageId": messageID, + "error": pathErr.Error(), + }) + continue + } + size, downloadErr := resourceDownload( + rt.Command().Context(), nil, resourceURL, headers, destPath, rt.Bool("overwrite")) + if downloadErr != nil { + failures = append(failures, map[string]any{ + "resourceId": resourceID, + "messageId": messageID, + "error": downloadErr.Error(), + }) + continue + } + downloads = append(downloads, map[string]any{ + "resourceId": resourceID, + "messageId": messageID, + "localPath": filepath.ToSlash(relativePath), + "sizeBytes": size, + }) + } + return map[string]any{ + "ok": len(failures) == 0, + "partial": len(downloads) > 0 && len(failures) > 0, + "requestedCount": len(resources), + "downloadedCount": len(downloads), + "failedCount": len(failures), + "downloads": downloads, + "failures": failures, + }, nil +} + +func uniqueShortcutStrings(values []string) []string { + out := make([]string, 0, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value != "" { + out = appendUniqueShortcutString(out, value) + } + } + return out } // MessagesQuerySendStatus queries send status of a message (query_message_send_status, im). @@ -908,7 +1135,9 @@ var MessagesListPin = shortcut.Shortcut{ return err } pins := listPinProject(data) - return rt.Output(map[string]any{"count": len(pins), "pins": pins}) + payload := map[string]any{"count": len(pins), "pins": pins} + chatmsg.ApplyPagination(payload, data) + return rt.Output(payload) }, } @@ -938,6 +1167,9 @@ func listPinProject(data map[string]any) []map[string]any { if v, ok := listPinFirst(m, "openConversationId", "conversationId", "openConvId"); ok { row["conversationId"] = v } + if threadID := chatmsg.ThreadID(m); threadID != nil { + row["threadId"] = threadID + } if len(row) > 0 { out = append(out, row) } diff --git a/internal/shortcut/chat/chat_message_coverage_test.go b/internal/shortcut/chat/chat_message_coverage_test.go new file mode 100644 index 00000000..812d7b21 --- /dev/null +++ b/internal/shortcut/chat/chat_message_coverage_test.go @@ -0,0 +1,122 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package chat + +import ( + "context" + "errors" + "net/http" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" +) + +func TestCrossPlatformCoverageListMessageRichProjection(t *testing.T) { + rows := listMessagesProject(map[string]any{"result": map[string]any{"messages": []any{ + map[string]any{ + "openMessageId": "msg", + "openConversationId": "cid", + "threadId": "thread", + "msgType": "text", + "createTime": "1", + "updateTime": "2", + "content": `{"mediaId":"@image"}`, + "quotedMessage": map[string]any{ + "openMessageId": "quoted", + "content": "quoted", + }, + }, + }}}) + if len(rows) != 1 { + t.Fatalf("rows = %#v", rows) + } + for _, key := range []string{"threadId", "updateTime", "quotedMessage", "resourceRefs"} { + if _, ok := rows[0][key]; !ok { + t.Errorf("projection missing %s: %#v", key, rows[0]) + } + } +} + +func TestCrossPlatformCoverageMgetResourceDownloadOutcomes(t *testing.T) { + baseArgs := []string{"chat", "+messages-mget", "--msg-ids", "msg", "--download-resources"} + readyMget := `{"result":[{"openMessageId":"msg","openConversationId":"cid","content":"{\"mediaId\":\"@file\"}"}]}` + missingContextMget := `{"result":[{"content":"{\"mediaId\":\"@file\"}"}]}` + validInfo := `{"result":{"resourceUrl":"https://example.test/resource.bin"}}` + + t.Run("dry run", func(t *testing.T) { + helpers.InitDeps(&larkAlignmentCaller{responses: map[string]string{ + "im/list_messages_by_ids": readyMget, + }}) + root := newPlatformCoverageRoot() + root.SetArgs(append(append([]string{}, baseArgs...), "--dry-run")) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + }) + t.Run("getwd", func(t *testing.T) { + resetResourceDownloadHooks(t) + resourceGetwd = func() (string, error) { return "", errors.New("getwd") } + helpers.InitDeps(&larkAlignmentCaller{responses: map[string]string{ + "im/list_messages_by_ids": readyMget, + }}) + root := newPlatformCoverageRoot() + root.SetArgs(baseArgs) + if err := root.Execute(); err == nil || !strings.Contains(err.Error(), "工作目录") { + t.Fatalf("getwd error = %v", err) + } + }) + + cases := []struct { + name string + mget string + info string + failProductTool string + outputDir string + downloadErr error + pathErr bool + }{ + {name: "missing context", mget: missingContextMget, info: validInfo}, + {name: "resource lookup", mget: readyMget, failProductTool: "im/get_resource_download_url"}, + {name: "invalid info", mget: readyMget, info: `{"result":{}}`}, + {name: "path", mget: readyMget, info: validInfo, outputDir: "go.mod", pathErr: true}, + {name: "download", mget: readyMget, info: validInfo, downloadErr: errors.New("download")}, + {name: "success", mget: readyMget, info: validInfo}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + resetResourceDownloadHooks(t) + if tc.pathErr { + resourceAbs = func(string) (string, error) { return "", errors.New("path") } + } + resourceDownload = func( + _ context.Context, + _ *http.Client, + _ string, + _ map[string]string, + _ string, + _ bool, + ) (int64, error) { + return 4, tc.downloadErr + } + caller := &larkAlignmentCaller{ + failProductTool: tc.failProductTool, + responses: map[string]string{ + "im/list_messages_by_ids": tc.mget, + "im/get_resource_download_url": tc.info, + }, + } + helpers.InitDeps(caller) + root := newPlatformCoverageRoot() + args := append([]string{}, baseArgs...) + if tc.outputDir != "" { + args = append(args, "--output-dir", tc.outputDir) + } + root.SetArgs(args) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + }) + } +} diff --git a/internal/shortcut/chat/chat_message_project_test.go b/internal/shortcut/chat/chat_message_project_test.go index 76437217..41065aea 100644 --- a/internal/shortcut/chat/chat_message_project_test.go +++ b/internal/shortcut/chat/chat_message_project_test.go @@ -28,7 +28,11 @@ func TestListMessageProjectOne(t *testing.T) { "senderOpenDingTalkId": "DXYZ", "msgType": "text", "createTime": "2026-07-19 13:37:03", + "updateTime": "2026-07-19 14:00:00", "content": testCipher, + "emotionReplyList": []any{ + map[string]any{"emoji": "赞", "replyUsers": []any{"D1", "D2"}}, + }, "forwardMessages": []any{ map[string]any{"openMessageId": "c1", "senderOpenDingTalkId": "DA", "content": "子消息", "createTime": "t"}, }, @@ -40,6 +44,12 @@ func TestListMessageProjectOne(t *testing.T) { if row["createTime"] != "2026-07-19 13:37:03" { t.Errorf("createTime = %v", row["createTime"]) } + if row["updateTime"] != "2026-07-19 14:00:00" { + t.Errorf("updateTime = %v", row["updateTime"]) + } + if reactions, ok := row["reactions"].(map[string]any); !ok || len(reactions) == 0 { + t.Errorf("reactions = %#v", row["reactions"]) + } if s, _ := row["text"].(string); !strings.Contains(s, "加密消息") || strings.Contains(s, "||2||1||") { t.Errorf("encrypted text = %v, want marker", row["text"]) } @@ -53,4 +63,34 @@ func TestListMessageProjectOne(t *testing.T) { if len(row) != 0 { t.Errorf("empty message row = %#v, want no keys", row) } + + row = listMessageProjectOneWithReactions(map[string]any{ + "openMessageId": "mid", + "emotionReplyList": []any{ + map[string]any{"emoji": "赞", "replyUsers": []any{"D1"}}, + }, + }, false) + if _, ok := row["reactions"]; ok { + t.Errorf("no-reactions projection leaked reactions: %#v", row) + } +} + +func TestListPinProjectPreservesThreadIdentity(t *testing.T) { + got := listPinProject(map[string]any{ + "result": map[string]any{ + "messages": []any{ + map[string]any{ + "openMessageId": "msg-1", + "openConversationId": "cid-1", + "openConvThreadId": "thread-1", + }, + }, + }, + }) + if len(got) != 1 { + t.Fatalf("pins = %#v", got) + } + if got[0]["messageId"] != "msg-1" || got[0]["threadId"] != "thread-1" { + t.Fatalf("pin identity = %#v", got[0]) + } } diff --git a/internal/shortcut/chat/compatibility_coverage_test.go b/internal/shortcut/chat/compatibility_coverage_test.go index 3e4f903f..e7d1d057 100644 --- a/internal/shortcut/chat/compatibility_coverage_test.go +++ b/internal/shortcut/chat/compatibility_coverage_test.go @@ -17,6 +17,7 @@ import ( "context" "io" "reflect" + "strings" "testing" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" @@ -41,6 +42,31 @@ func (f *platformCoverageCaller) DryRun() bool { return false } func (f *platformCoverageCaller) Fields() string { return "" } func (f *platformCoverageCaller) JQ() string { return "" } +type muteMemberResolutionCaller struct { + calls []platformCoverageCaller +} + +func (f *muteMemberResolutionCaller) CallTool(_ context.Context, product, tool string, args map[string]any) (*edition.ToolResult, error) { + f.calls = append(f.calls, platformCoverageCaller{product: product, tool: tool, args: args}) + var text string + switch product + "/" + tool { + case "contact/get_user_info_by_user_ids": + text = `{"result":[{"orgEmployeeModel":{"orgUserId":"user-2","orgUserName":"测试成员"}}]}` + case "chat/get_group_members": + text = `{"result":{"hasMore":false,"list":[{"memberEmpName":"测试成员","openDingtalkId":"D-open-2"}]}}` + case "im/set_group_member_mute_list": + text = `{"success":true}` + default: + text = `{"result":[]}` + } + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil +} + +func (f *muteMemberResolutionCaller) Format() string { return "json" } +func (f *muteMemberResolutionCaller) DryRun() bool { return false } +func (f *muteMemberResolutionCaller) Fields() string { return "" } +func (f *muteMemberResolutionCaller) JQ() string { return "" } + func newPlatformCoverageRoot() *cobra.Command { root := &cobra.Command{Use: "dws", SilenceUsage: true, SilenceErrors: true} root.SetOut(io.Discard) @@ -103,6 +129,22 @@ func TestCrossPlatformCoverageCompatibilityAliases(t *testing.T) { wantTool: "query_msg_read_status", wantArgs: map[string]any{"openConversationId": "cid-1"}, }, + { + name: "at all mute matches live MCP schema", + argv: []string{"chat", "+conversation-mute-at-all", "--conversation-id", "cid-1", "--yes"}, + wantProduct: "im", + wantTool: "update_at_all_notification_off", + wantArgs: map[string]any{"openConversationId": "cid-1", "mute": true}, + wantAbsent: []string{"cid"}, + }, + { + name: "red envelope mute matches live MCP schema", + argv: []string{"chat", "+conversation-mute-red-envelope", "--conversation-id", "cid-1", "--off", "--yes"}, + wantProduct: "im", + wantTool: "update_red_env_notification_off", + wantArgs: map[string]any{"openConversationId": "cid-1", "mute": false}, + wantAbsent: []string{"cid"}, + }, { name: "message resource url msg id alias", argv: []string{ @@ -205,3 +247,93 @@ func TestCrossPlatformCoverageChatIDHelpers(t *testing.T) { } }) } + +func TestChatMuteMemberResolvesUserIDToOpenDingTalkID(t *testing.T) { + fake := &muteMemberResolutionCaller{} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + root.SetArgs([]string{ + "chat", "+chat-mute-member", + "--group", "cid-1", + "--users", "user-2,D-open-2", + "--mute-time", "300000", + "--yes", + }) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + if len(fake.calls) != 3 { + t.Fatalf("calls = %d, want 3: %#v", len(fake.calls), fake.calls) + } + final := fake.calls[2] + if final.product != "im" || final.tool != "set_group_member_mute_list" { + t.Fatalf("final call = %s/%s, want im/set_group_member_mute_list", final.product, final.tool) + } + if _, ok := final.args["uids"]; ok { + t.Fatalf("known-broken uids argument leaked into final call: %#v", final.args) + } + if got, want := final.args["openDingTalkIds"], []string{"D-open-2"}; !reflect.DeepEqual(got, want) { + t.Fatalf("openDingTalkIds = %#v, want %#v", got, want) + } +} + +func TestConversationCategoryTitleValidation(t *testing.T) { + tests := []struct { + name string + argv []string + wantTool string + wantTitle string + wantError string + }{ + { + name: "create accepts fifteen characters and trims", + argv: []string{"chat", "+category-create", "--title", " 123456789012345 ", "--yes"}, + wantTool: "create_conv_category", + wantTitle: "123456789012345", + }, + { + name: "rename accepts unicode title and trims", + argv: []string{"chat", "+category-rename", "--category-id", "7", "--title", " 工作群 ", "--yes"}, + wantTool: "rename_conv_category", + wantTitle: "工作群", + }, + { + name: "create rejects more than fifteen characters", + argv: []string{"chat", "+category-create", "--title", "1234567890123456", "--yes"}, + wantError: "最多 15 个字符", + }, + { + name: "rename rejects whitespace-only title", + argv: []string{"chat", "+category-rename", "--category-id", "7", "--title", " ", "--yes"}, + wantError: "--title 不能为空", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + fake := &platformCoverageCaller{} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + root.SetArgs(tc.argv) + err := root.Execute() + if tc.wantError != "" { + if err == nil || !strings.Contains(err.Error(), tc.wantError) { + t.Fatalf("error = %v, want containing %q", err, tc.wantError) + } + if fake.tool != "" { + t.Fatalf("validation failure unexpectedly called %q", fake.tool) + } + return + } + if err != nil { + t.Fatal(err) + } + if fake.tool != tc.wantTool { + t.Fatalf("tool = %q, want %q", fake.tool, tc.wantTool) + } + if got := fake.args["title"]; got != tc.wantTitle { + t.Fatalf("title = %#v, want %q", got, tc.wantTitle) + } + }) + } +} diff --git a/internal/shortcut/chat/coverage_completion_test.go b/internal/shortcut/chat/coverage_completion_test.go new file mode 100644 index 00000000..06708738 --- /dev/null +++ b/internal/shortcut/chat/coverage_completion_test.go @@ -0,0 +1,494 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package chat + +import ( + "context" + "errors" + "fmt" + "reflect" + "strconv" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" +) + +func TestCrossPlatformCoverageConversationValidationAndTypeVariants(t *testing.T) { + fake := &larkAlignmentCaller{} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + tooMany := make([]string, 11) + for i := range tooMany { + tooMany[i] = fmt.Sprintf("cid-%d", i) + } + root.SetArgs([]string{ + "chat", "+conversation-set-top", + "--conversation-ids", strings.Join(tooMany, ","), + "--yes", + }) + if err := root.Execute(); err == nil { + t.Fatal("more than ten conversation IDs were accepted") + } + + root = newPlatformCoverageRoot() + root.SetArgs([]string{"chat", "+category-create", "--title", " ", "--yes"}) + if err := root.Execute(); err == nil { + t.Fatal("blank category title was accepted") + } + shortcut.Register(shortcut.Shortcut{ + Service: "chat", + Command: "+coverage-category-title", + Flags: []shortcut.Flag{{Name: "title", Type: shortcut.FlagString}}, + Validate: validateConversationCategoryTitle, + Execute: func(*shortcut.RuntimeContext) error { return nil }, + }) + root = newPlatformCoverageRoot() + root.SetArgs([]string{"chat", "+coverage-category-title", "--title", " "}) + if err := root.Execute(); err == nil || !strings.Contains(err.Error(), "不能为空") { + t.Fatalf("direct blank category validation error = %v", err) + } + + tests := []struct { + value map[string]any + want string + }{ + {map[string]any{"singleChat": "true"}, "direct"}, + {map[string]any{"singleChat": "false"}, "group"}, + {map[string]any{"singleChat": float64(1)}, "direct"}, + {map[string]any{"singleChat": float64(0)}, "group"}, + {map[string]any{"singleChat": 1}, "direct"}, + {map[string]any{"singleChat": 0}, "group"}, + {map[string]any{"conversationType": "group_chat"}, "group"}, + } + for _, tc := range tests { + if got, ok := conversationListTopType(tc.value); !ok || got != tc.want { + t.Errorf("conversationListTopType(%#v) = %q, %v; want %q", tc.value, got, ok, tc.want) + } + } +} + +func TestCrossPlatformCoverageConversationAndGroupListExecution(t *testing.T) { + fake := &larkAlignmentCaller{} + helpers.InitDeps(fake) + for _, args := range [][]string{ + {"chat", "+conversation-set-top", "--conversation-id", "cid", "--yes"}, + {"chat", "+conversation-list", "--limit", "1", "--cursor", "1", "--exclude-muted"}, + {"chat", "+conversation-list-top", "--limit", "1", "--cursor", "1", "--exclude-muted", "--type", "group"}, + {"chat", "+category-list-conversations", "--category-id", "1", "--exclude-muted"}, + {"chat", "+chat-list-mine", "--role", "OWNER", "--limit", "1", "--exclude-muted"}, + {"chat", "+chat-list-all", "--limit", "1", "--cursor", "next"}, + {"chat", "+messages-list-pin", "--open-conversation-id", "cid", "--cursor", "next", "--size", "1"}, + } { + root := newPlatformCoverageRoot() + root.SetArgs(args) + if err := root.Execute(); err != nil { + t.Fatalf("%v: %v", args, err) + } + } +} + +func TestCrossPlatformCoverageChatCreateAndReplyFailures(t *testing.T) { + tests := []struct { + name string + caller *larkAlignmentCaller + args []string + wantError string + }{ + { + name: "current profile call", + caller: &larkAlignmentCaller{failProductTool: "contact/get_current_user_profile"}, + args: []string{"chat", "+chat-create", "--name", "群", "--users", "u1", "--yes"}, + wantError: "读取当前用户", + }, + { + name: "missing current user", + caller: &larkAlignmentCaller{responses: map[string]string{ + "contact/get_current_user_profile": `{"result":[]}`, + }}, + args: []string{"chat", "+chat-create", "--name", "群", "--users", "u1", "--yes"}, + wantError: "缺少 userId", + }, + { + name: "create write", + caller: &larkAlignmentCaller{failProductTool: "im/create_group_conversation"}, + args: []string{"chat", "+chat-create", "--name", "群", "--users", "u1", "--yes"}, + wantError: "fixture lower call failed", + }, + { + name: "explicit sender lookup", + caller: &larkAlignmentCaller{failProductTool: "contact/get_user_info_by_user_ids"}, + args: []string{"chat", "+messages-reply", "--conversation-id", "cid", "--message-id", "msg", "--ref-sender", "user-id", "--text", "收到", "--yes"}, + wantError: "解析为 openDingTalkId", + }, + { + name: "explicit sender unresolved", + caller: &larkAlignmentCaller{responses: map[string]string{ + "contact/get_user_info_by_user_ids": `{"result":[]}`, + }}, + args: []string{"chat", "+messages-reply", "--conversation-id", "cid", "--message-id", "msg", "--ref-sender", "user-id", "--text", "收到", "--yes"}, + wantError: "无法把 --ref-sender", + }, + { + name: "referenced message lookup", + caller: &larkAlignmentCaller{failProductTool: "im/list_messages_by_ids"}, + args: []string{"chat", "+messages-reply", "--conversation-id", "cid", "--message-id", "msg", "--text", "收到", "--yes"}, + wantError: "读取被引用消息", + }, + { + name: "referenced message missing sender", + caller: &larkAlignmentCaller{responses: map[string]string{ + "im/list_messages_by_ids": `{"result":[{"openMessageId":"other","senderOpenDingTalkId":"D-other"},{"openMessageId":"msg"}]}`, + }}, + args: []string{"chat", "+messages-reply", "--conversation-id", "cid", "--message-id", "msg", "--text", "收到", "--yes"}, + wantError: "未返回 senderOpenDingTalkId", + }, + { + name: "feed source", + caller: &larkAlignmentCaller{failProductTool: "im/list_conversations_by_category"}, + args: []string{"chat", "+feed-group-query-item", "--category-id", "1", "--conversation-ids", "cid"}, + wantError: "fixture lower call failed", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + helpers.InitDeps(tc.caller) + root := newPlatformCoverageRoot() + root.SetArgs(tc.args) + err := root.Execute() + if err == nil || !strings.Contains(err.Error(), tc.wantError) { + t.Fatalf("error = %v, want containing %q", err, tc.wantError) + } + }) + } + + dry := &larkAlignmentCaller{} + helpers.InitDeps(dry) + root := newPlatformCoverageRoot() + root.SetArgs([]string{"chat", "+chat-create", "--name", "群", "--users", "u1", "--dry-run", "--yes"}) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + if len(dry.calls) != 2 || + dry.calls[0].tool != "get_current_user_profile" || + dry.calls[1].tool != "create_group_conversation" { + t.Fatalf("chat-create dry-run calls = %#v", dry.calls) + } +} + +func TestCrossPlatformCoverageReplyShapeHelpers(t *testing.T) { + if got := findOpenDingTalkID([]map[string]any{{"openDingTalkId": "D-map"}}); got != "D-map" { + t.Fatalf("findOpenDingTalkID([]map) = %q", got) + } + if got := shortcutMessageMaps(nil); got != nil { + t.Fatalf("nil message maps = %#v", got) + } + if got := shortcutMessageMaps(map[string]any{}); got != nil { + t.Fatalf("empty message maps = %#v", got) + } + maps := shortcutMessageMaps(map[string]any{ + "data": map[string]any{ + "items": []map[string]any{{"openMessageId": "msg"}}, + }, + }) + if len(maps) != 1 || maps[0]["openMessageId"] != "msg" { + t.Fatalf("nested []map message maps = %#v", maps) + } + + if got := currentProfileUserID(map[string]any{"result": map[string]any{"userId": "nested"}}); got != "nested" { + t.Fatalf("nested current profile = %q", got) + } + if got := currentProfileUserID(map[string]any{"userId": "direct"}); got != "direct" { + t.Fatalf("direct current profile = %q", got) + } + data := map[string]any{"result": "not-a-map"} + normalizeCreatedConversation(data) + if data["result"] != "not-a-map" { + t.Fatalf("normalization changed non-map result: %#v", data) + } +} + +func TestCrossPlatformCoverageFlagAndMgetValidation(t *testing.T) { + tooMany := make([]string, 11) + for i := range tooMany { + tooMany[i] = fmt.Sprintf("msg-%d", i) + } + cases := [][]string{ + {"chat", "+flag-create", "--message-ids", strings.Join(tooMany, ","), "--conversation-id", "cid", "--yes"}, + {"chat", "+flag-list", "--cursor", "-1"}, + {"chat", "+flag-list", "--size", "101"}, + {"chat", "+messages-mget", "--msg-ids", strings.Join(makeIDs(51), ",")}, + {"chat", "+messages-mget", "--msg-ids", "msg", "--download-resources", "--output-dir", "../escape"}, + } + for _, args := range cases { + root := newPlatformCoverageRoot() + root.SetArgs(args) + if err := root.Execute(); err == nil { + t.Errorf("invalid args unexpectedly succeeded: %v", args) + } + } + + fake := &larkAlignmentCaller{failProductTool: "im/list_messages_by_ids"} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + root.SetArgs([]string{"chat", "+messages-mget", "--msg-ids", "msg"}) + if err := root.Execute(); err == nil { + t.Fatal("mget lower error was swallowed") + } +} + +func TestCrossPlatformCoverageFeedCompleteAndExcludeMuted(t *testing.T) { + fake := &larkAlignmentCaller{category: `{"result":{"hasMore":false,"list":[{"openConversationId":"cid"}]}}`} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + root.SetArgs([]string{ + "chat", "+feed-group-query-item", + "--category-id", "1", + "--conversation-ids", "cid", + "--exclude-muted", + }) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + if len(fake.calls) != 1 || fake.calls[0].args["excludeMuted"] != true { + t.Fatalf("feed calls = %#v", fake.calls) + } +} + +func TestCrossPlatformCoverageUnifiedSendValidationMatrix(t *testing.T) { + cases := [][]string{ + {"--identity", "user", "--group", "cid", "--users", "u1", "--text", "x"}, + {"--identity", "user", "--text", "x"}, + {"--identity", "user", "--group", "cid", "--open-dingtalk-id", "D1", "--text", "x"}, + {"--identity", "user", "--group", "cid", "--at-user-ids", "u1", "--text", "x"}, + {"--identity", "user", "--open-dingtalk-id", "D1", "--at-all", "--text", "x"}, + {"--identity", "bot", "--group", "cid", "--text", "x"}, + {"--identity", "bot", "--robot-code", "r", "--group", "cid", "--users", "u1", "--text", "x"}, + {"--identity", "bot", "--robot-code", "r", "--group", "cid", "--open-dingtalk-id", "D1", "--text", "x"}, + {"--identity", "bot", "--robot-code", "r", "--group", "cid", "--at-mobiles", "13800000000", "--text", "x"}, + {"--identity", "bot", "--robot-code", "r", "--users", "u1", "--at-user-ids", "u2", "--text", "x"}, + {"--identity", "webhook", "--text", "x"}, + {"--identity", "webhook", "--webhook-token", "token", "--group", "cid", "--text", "x"}, + {"--identity", "webhook", "--webhook-token", "token", "--at-open-dingtalk-ids", "D1", "--text", "x"}, + {"--identity", "webhook", "--webhook-token", "token", "--uuid", "key", "--text", "x"}, + } + for _, tail := range cases { + fake := &larkAlignmentCaller{} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + args := append([]string{"chat", "+messages-send"}, tail...) + args = append(args, "--yes") + root.SetArgs(args) + if err := root.Execute(); err == nil { + t.Errorf("invalid unified send unexpectedly succeeded: %v", tail) + } + if len(fake.calls) != 0 { + t.Errorf("invalid unified send reached lower service: %v => %#v", tail, fake.calls) + } + } +} + +func TestCrossPlatformCoverageUnifiedSendOptionalArgumentsAndErrors(t *testing.T) { + tests := []struct { + name string + args []string + want map[string]any + }{ + { + name: "user group mentions", + args: []string{"--identity", "user", "--group", "cid", "--text", "x", "--at-open-dingtalk-ids", "D1,D2", "--at-all"}, + want: map[string]any{"atOpenDingTalkIds": []string{"D1", "D2"}, "atAll": true}, + }, + { + name: "user direct", + args: []string{"--identity", "user", "--open-dingtalk-id", "D1", "--text", "x"}, + want: map[string]any{"receiverOpenDingTalkId": "D1"}, + }, + { + name: "bot group mentions", + args: []string{"--identity", "bot", "--robot-code", "r", "--group", "cid", "--text", "x", "--at-user-ids", "u1", "--at-open-dingtalk-ids", "D1"}, + want: map[string]any{"atUserIds": []string{"u1"}, "atOpendingtalkIds": []string{"D1"}}, + }, + { + name: "bot direct targets", + args: []string{"--identity", "bot", "--robot-code", "r", "--users", "u1", "--open-dingtalk-ids", "D1", "--text", "x", "--at-all"}, + want: map[string]any{"userIds": []string{"u1"}, "openDingtalkIds": []string{"D1"}, "isAtAll": "true"}, + }, + { + name: "webhook mentions", + args: []string{"--identity", "webhook", "--webhook-token", "token", "--text", "x", "--at-user-ids", "u1", "--at-mobiles", "13800000000"}, + want: map[string]any{"atUserIds": []string{"u1"}, "atMobiles": []string{"13800000000"}}, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + fake := &larkAlignmentCaller{} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + args := append([]string{"chat", "+messages-send"}, tc.args...) + args = append(args, "--yes") + root.SetArgs(args) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + call := fake.calls[len(fake.calls)-1] + for key, want := range tc.want { + if !reflect.DeepEqual(call.args[key], want) { + t.Errorf("%s = %#v, want %#v", key, call.args[key], want) + } + } + }) + } + + fake := &larkAlignmentCaller{} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + root.SetArgs([]string{"chat", "+messages-send", "--identity", "user", "--group", "cid", "--text", "x", "--dry-run", "--yes"}) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + if len(fake.calls) != 0 { + t.Fatalf("unified send dry-run reached lower service: %#v", fake.calls) + } + + fake = &larkAlignmentCaller{failProductTool: "chat/send_personal_message"} + helpers.InitDeps(fake) + root = newPlatformCoverageRoot() + root.SetArgs([]string{"chat", "+messages-send", "--identity", "user", "--group", "cid", "--text", "x", "--yes"}) + if err := root.Execute(); err == nil { + t.Fatal("unified send write error was swallowed") + } + + if got := shortcutMessageTitle(strings.Repeat("界", 45)); len([]rune(got)) != 40 { + t.Fatalf("long generated title has %d runes", len([]rune(got))) + } +} + +func TestCrossPlatformCoverageUnifiedSendUnsupportedIdentityGuard(t *testing.T) { + flags := append([]shortcut.Flag(nil), MessagesSend.Flags...) + for i := range flags { + if flags[i].Name == "identity" || flags[i].Name == "as" { + flags[i].Enum = nil + } + } + shortcut.Register(shortcut.Shortcut{ + Service: "chat", + Command: "+coverage-unified-send", + Flags: flags, + Execute: executeMessagesSend, + }) + root := newPlatformCoverageRoot() + root.SetArgs([]string{"chat", "+coverage-unified-send", "--identity", "unsupported", "--text", "x"}) + err := root.Execute() + if err == nil || !strings.Contains(err.Error(), "unsupported identity") { + t.Fatalf("unsupported identity error = %v", err) + } +} + +func TestCrossPlatformCoverageMuteMemberResolutionFailures(t *testing.T) { + tests := []struct { + name string + caller *muteMemberScenarioCaller + }{ + {"contact error", &muteMemberScenarioCaller{contactErr: errors.New("contact failed")}}, + {"contact missing name", &muteMemberScenarioCaller{contactText: `{"result":[]}`}}, + {"member error", &muteMemberScenarioCaller{memberErr: errors.New("members failed")}}, + {"member missing fields", &muteMemberScenarioCaller{memberMode: "missing-fields"}}, + {"member cursor missing", &muteMemberScenarioCaller{memberMode: "missing-cursor"}}, + {"member ambiguous", &muteMemberScenarioCaller{memberMode: "ambiguous"}}, + {"member page limit", &muteMemberScenarioCaller{memberMode: "page-limit"}}, + {"unrelated directory result", &muteMemberScenarioCaller{ + contactText: `{"result":[{"orgEmployeeModel":{"orgUserId":"other","orgUserName":"其他"}},{"orgEmployeeModel":{"orgUserId":"user-1","orgUserName":"测试成员"}}]}`, + }}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + helpers.InitDeps(tc.caller) + root := newPlatformCoverageRoot() + root.SetArgs([]string{"chat", "+chat-mute-member", "--group", "cid", "--users", "user-1", "--off", "--yes"}) + if err := root.Execute(); err == nil { + t.Fatal("resolution failure scenario unexpectedly succeeded") + } + }) + } + + for _, tc := range []struct { + value any + want string + }{ + {float64(1.5), "1.5"}, + {int(2), "2"}, + {int64(3), "3"}, + } { + if got := shortcutString(map[string]any{"value": tc.value}, "value"); got != tc.want { + t.Errorf("shortcutString(%T) = %q, want %q", tc.value, got, tc.want) + } + } +} + +type muteMemberScenarioCaller struct { + contactErr error + memberErr error + contactText string + memberMode string +} + +func (c *muteMemberScenarioCaller) CallTool( + _ context.Context, + product, tool string, + args map[string]any, +) (*edition.ToolResult, error) { + switch product + "/" + tool { + case "contact/get_user_info_by_user_ids": + if c.contactErr != nil { + return nil, c.contactErr + } + text := c.contactText + if text == "" { + text = `{"result":[{"orgEmployeeModel":{"orgUserId":"user-1","orgUserName":"测试成员"}}]}` + } + return textResult(text), nil + case "chat/get_group_members": + if c.memberErr != nil { + return nil, c.memberErr + } + switch c.memberMode { + case "missing-fields": + return textResult(`{"result":{"hasMore":false,"list":[{"memberEmpName":"","openDingtalkId":""}]}}`), nil + case "missing-cursor": + return textResult(`{"result":{"hasMore":true,"list":[{"memberEmpName":"测试成员","openDingtalkId":"D1"}]}}`), nil + case "ambiguous": + return textResult(`{"result":{"hasMore":false,"list":[{"memberEmpName":"测试成员","openDingtalkId":"D1"},{"memberEmpName":"测试成员","openDingtalkId":"D2"}]}}`), nil + case "page-limit": + cursor, _ := strconv.Atoi(fmt.Sprint(args["cursor"])) + return textResult(fmt.Sprintf( + `{"result":{"hasMore":true,"nextCursor":"%d","list":[{"memberEmpName":"测试成员","openDingtalkId":"D1"}]}}`, + cursor+1, + )), nil + default: + return textResult(`{"result":{"hasMore":false,"list":[]}}`), nil + } + default: + return textResult(`{"success":true}`), nil + } +} + +func (*muteMemberScenarioCaller) Format() string { return "json" } +func (*muteMemberScenarioCaller) DryRun() bool { return false } +func (*muteMemberScenarioCaller) Fields() string { return "" } +func (*muteMemberScenarioCaller) JQ() string { return "" } + +func textResult(text string) *edition.ToolResult { + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}} +} + +func makeIDs(count int) []string { + ids := make([]string, count) + for i := range ids { + ids[i] = fmt.Sprintf("id-%d", i) + } + return ids +} diff --git a/internal/shortcut/chat/lark_alignment.go b/internal/shortcut/chat/lark_alignment.go new file mode 100644 index 00000000..9477a3ba --- /dev/null +++ b/internal/shortcut/chat/lark_alignment.go @@ -0,0 +1,517 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. + +package chat + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg" +) + +// ChatCreate creates a DingTalk group as the current user. It intentionally +// does not advertise Lark-only owner, description, initial-bot, or visibility +// semantics. +var ChatCreate = shortcut.Shortcut{ + Service: "chat", + Command: "+chat-create", + Product: "im", + Description: "以当前用户身份创建钉钉群聊", + Intent: "当你要创建一个基础钉钉群聊时使用;自动把当前用户加入成员列表并作为群主,支持 INTERNAL、EXTERNAL、NORMAL 和话题模式。它不支持指定其他 owner、群 description、初始机器人或 Lark public/private 语义。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "name", Type: shortcut.FlagString, Desc: "群名称", Required: true}, + {Name: "users", Type: shortcut.FlagStringSlice, Desc: "初始成员 userId 或 openDingTalkId 列表", Required: true}, + {Name: "type", Type: shortcut.FlagString, Default: "INTERNAL", Desc: "群类型", Enum: []string{"INTERNAL", "EXTERNAL", "NORMAL"}}, + {Name: "thread", Type: shortcut.FlagBool, Desc: "创建为话题群"}, + }, + Tips: []string{ + `dws chat +chat-create --name "项目冲刺群" --users userId1,userId2`, + `dws chat +chat-create --name "合作群" --users userId1,userId2 --type EXTERNAL`, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + profile, err := rt.CallMCPData("contact", "get_current_user_profile", nil) + if err != nil { + return fmt.Errorf("读取当前用户以设置群主失败: %w", err) + } + currentUserID := currentProfileUserID(profile) + if currentUserID == "" { + return apperrors.NewValidation("当前用户资料缺少 userId,无法保证群主属于初始成员列表") + } + members := []string{currentUserID} + for _, member := range rt.StrSlice("users") { + member = strings.TrimSpace(member) + if member != "" { + members = appendUniqueShortcutString(members, member) + } + } + params := map[string]any{ + "groupName": rt.Str("name"), + "groupMembers": members, + "groupType": rt.Str("type"), + } + if rt.Bool("thread") { + params["convThreadEnabled"] = true + } + if rt.DryRun() { + return rt.CallMCP("create_group_conversation", params) + } + data, err := rt.CallMCPWriteData("im", "create_group_conversation", params) + if err != nil { + return err + } + normalizeCreatedConversation(data) + return rt.Output(data) + }, +} + +func currentProfileUserID(data map[string]any) string { + candidates := []map[string]any{data} + if result, ok := data["result"].(map[string]any); ok { + candidates = append(candidates, result) + } + if result, ok := data["result"].([]any); ok { + for _, item := range result { + if mapped, ok := item.(map[string]any); ok { + candidates = append(candidates, mapped) + } + } + } + for _, candidate := range candidates { + if employee, ok := candidate["orgEmployeeModel"].(map[string]any); ok { + if userID := shortcutString(employee, "userId", "userid", "orgUserId", "staffId"); userID != "" { + return userID + } + } + if userID := shortcutString(candidate, "userId", "userid", "orgUserId", "staffId"); userID != "" { + return userID + } + } + return "" +} + +func normalizeCreatedConversation(data map[string]any) { + result, ok := data["result"].(map[string]any) + if !ok { + return + } + if openCID, exists := result["openCid"]; exists { + result["openConversationId"] = openCID + delete(result, "openCid") + } + delete(result, "cid") +} + +// ChatUpdate intentionally supports only the group-name subset shared by DWS +// and lark-cli. A description flag is omitted because DWS has no such lower +// capability. +var ChatUpdate = shortcut.Shortcut{ + Service: "chat", + Command: "+chat-update", + Product: "chat", + Description: "更新群名称(仅名称,不支持 description)", + 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}, + }, + 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"), + }) + }, +} + +// MessagesReply quote-replies with plain text as the current user. It can infer +// the original sender from the referenced message, so callers do not need to +// manually carry a second identity field from a previous list operation. +var MessagesReply = shortcut.Shortcut{ + Service: "chat", + Command: "+messages-reply", + Product: "chat", + Description: "以当前用户身份引用回复消息(自动补全原发送者)", + Intent: "当你要以当前用户身份对已有消息发送纯文本引用回复时使用;提供会话和被引用消息即可,默认通过 mget 自动读取原发送者,也可显式传 openDingTalkId/userId。它不支持 bot 身份、富媒体、卡片或 thread 内回复。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "conversation-id", Type: shortcut.FlagString, Desc: "会话 openConversationId", Required: true}, + {Name: "ref-msg-id", Type: shortcut.FlagString, Desc: "被引用消息 openMessageId"}, + {Name: "message-id", Type: shortcut.FlagString, Desc: "--ref-msg-id 的 lark-cli 对齐别名"}, + {Name: "ref-sender", Type: shortcut.FlagString, Desc: "原消息发送者 openDingTalkId/userId(不传则自动读取)"}, + {Name: "text", Type: shortcut.FlagString, Desc: "纯文本回复内容", Required: true}, + {Name: "uuid", Type: shortcut.FlagString, Desc: "幂等键(可选)"}, + {Name: "idempotency-key", Type: shortcut.FlagString, Desc: "--uuid 的 lark-cli 对齐别名"}, + shortcut.AIMessageTagFlag(), + }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintExactlyOne, Flags: []string{"ref-msg-id", "message-id"}}, + {Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"uuid", "idempotency-key"}}, + }, + Tips: []string{`dws chat +messages-reply --conversation-id --message-id --text "收到" --idempotency-key `}, + Execute: func(rt *shortcut.RuntimeContext) error { + refSender, err := resolveReplySender(rt) + if err != nil { + return err + } + content, _ := json.Marshal(map[string]string{ + "referenceOpenMessageId": replyMessageID(rt), + "srcMsgSendOpenDingTalkId": refSender, + "replyMsgType": "text", + "content": rt.Str("text"), + }) + params := rt.AddAIMessageTag(map[string]any{ + "openConversationId": rt.Str("conversation-id"), + "msgType": "reply", + "content": string(content), + }) + if value := rt.StrFirst("idempotency-key", "uuid"); value != "" { + params["uuid"] = value + } + return rt.CallMCP("send_personal_message", params) + }, +} + +func resolveReplySender(rt *shortcut.RuntimeContext) (string, error) { + if value := rt.Str("ref-sender"); value != "" { + if isOpenID(value) { + return value, nil + } + data, err := rt.CallMCPData("contact", "get_user_info_by_user_ids", map[string]any{ + "user_id_list": []string{value}, + }) + if err != nil { + return "", fmt.Errorf("把 --ref-sender userId 解析为 openDingTalkId 失败: %w", err) + } + if openID := findOpenDingTalkID(data); openID != "" { + return openID, nil + } + return "", apperrors.NewValidation("无法把 --ref-sender 解析为 openDingTalkId") + } + + messageID := replyMessageID(rt) + data, err := rt.CallMCPData("im", "list_messages_by_ids", map[string]any{ + "openMsgIds": []string{messageID}, + }) + if err != nil { + return "", fmt.Errorf("读取被引用消息以补全原发送者失败: %w", err) + } + for _, message := range shortcutMessageMaps(data) { + if id := strings.TrimSpace(fmt.Sprint(chatmsg.MessageID(message))); id != "" && id != "" && id != messageID { + continue + } + if openID := findMessageSenderOpenDingTalkID(message); openID != "" { + return openID, nil + } + } + return "", apperrors.NewValidation("被引用消息未返回 senderOpenDingTalkId;请显式传 --ref-sender") +} + +func replyMessageID(rt *shortcut.RuntimeContext) string { + return rt.StrFirst("message-id", "ref-msg-id") +} + +func findOpenDingTalkID(value any) string { + switch typed := value.(type) { + case map[string]any: + for _, key := range []string{"senderOpenDingTalkId", "senderOpenDingtalkId", "openDingTalkId", "openDingtalkId"} { + if candidate := strings.TrimSpace(fmt.Sprint(typed[key])); isOpenID(candidate) { + return candidate + } + } + for _, child := range typed { + if candidate := findOpenDingTalkID(child); candidate != "" { + return candidate + } + } + case []any: + for _, child := range typed { + if candidate := findOpenDingTalkID(child); candidate != "" { + return candidate + } + } + case []map[string]any: + for _, child := range typed { + if candidate := findOpenDingTalkID(child); candidate != "" { + return candidate + } + } + } + return "" +} + +// findMessageSenderOpenDingTalkID intentionally inspects only sender identity +// fields. A message may contain unrelated openDingTalkId values in mentions, +// quoted content, cards, or attachments; recursively taking the first identity +// from the whole payload could reply with the wrong source sender. +func findMessageSenderOpenDingTalkID(message map[string]any) string { + for _, key := range []string{ + "senderOpenDingTalkId", + "senderOpenDingtalkId", + "senderOpenId", + } { + if candidate := strings.TrimSpace(fmt.Sprint(message[key])); isOpenID(candidate) { + return candidate + } + } + for _, key := range []string{"sender", "senderInfo", "senderUser"} { + if candidate := findOpenDingTalkID(message[key]); candidate != "" { + return candidate + } + } + return "" +} + +func shortcutMessageMaps(data map[string]any) []map[string]any { + if data == nil { + return nil + } + scopes := []map[string]any{data} + for _, key := range []string{"result", "data"} { + if inner, ok := data[key].(map[string]any); ok { + scopes = append(scopes, inner) + } + } + for _, scope := range scopes { + for _, key := range []string{"result", "data", "messages", "list", "items", "records"} { + switch values := scope[key].(type) { + case []any: + out := make([]map[string]any, 0, len(values)) + for _, value := range values { + if mapped, ok := value.(map[string]any); ok { + out = append(out, mapped) + } + } + return out + case []map[string]any: + return values + } + } + } + return nil +} + +// FlagCreate/FlagCancel/FlagList map Lark's message flag intent to DingTalk's +// message-favorite layer. They are explicitly not Pin or Feed Shortcut state. +var FlagCreate = shortcut.Shortcut{ + Service: "chat", + Command: "+flag-create", + Product: "im", + Description: "收藏一条或多条消息(最多 10 条)", + Intent: "当你要把同一会话中的一条或多条消息加入当前用户的个人收藏时使用;逐项返回成功/失败 ledger。这是消息 favorite,不是消息 Pin、会话置顶或 feed-layer thread flag。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "message-id", Type: shortcut.FlagString, Desc: "单条消息 openMessageId"}, + {Name: "message-ids", Type: shortcut.FlagStringSlice, Desc: "多条消息 openMessageId(最多 10 条)"}, + {Name: "conversation-id", Type: shortcut.FlagString, Desc: "消息所在会话 openConversationId", Required: true}, + }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintAtLeastOne, Flags: []string{"message-id", "message-ids"}}, + { + Kind: shortcut.ConstraintCustom, + Flags: []string{"message-id", "message-ids"}, + Description: "消息 ID 去重后必须为 1-10 条", + }, + }, + Tips: []string{`dws chat +flag-create --message-id --conversation-id `}, + Validate: validateFlagMessageIDs, + Execute: func(rt *shortcut.RuntimeContext) error { + return executeFlagBatch(rt, "add_message_favorite") + }, +} + +var FlagCancel = shortcut.Shortcut{ + Service: "chat", + Command: "+flag-cancel", + Product: "im", + Description: "取消收藏一条或多条消息(最多 10 条)", + Intent: "当你要移除当前用户对同一会话中一条或多条消息的个人收藏标记时使用;逐项返回成功/失败 ledger,只影响 message favorite,不删除原消息,也不会修改 Pin 或会话置顶。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "message-id", Type: shortcut.FlagString, Desc: "单条消息 openMessageId"}, + {Name: "message-ids", Type: shortcut.FlagStringSlice, Desc: "多条消息 openMessageId(最多 10 条)"}, + {Name: "conversation-id", Type: shortcut.FlagString, Desc: "消息所在会话 openConversationId", Required: true}, + }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintAtLeastOne, Flags: []string{"message-id", "message-ids"}}, + { + Kind: shortcut.ConstraintCustom, + Flags: []string{"message-id", "message-ids"}, + Description: "消息 ID 去重后必须为 1-10 条", + }, + }, + Tips: []string{`dws chat +flag-cancel --message-id --conversation-id `}, + Validate: validateFlagMessageIDs, + Execute: func(rt *shortcut.RuntimeContext) error { + return executeFlagBatch(rt, "remove_message_favorite") + }, +} + +func flagMessageIDs(rt *shortcut.RuntimeContext) []string { + values := append([]string{}, rt.StrSlice("message-ids")...) + if value := rt.Str("message-id"); value != "" { + values = append(values, value) + } + return uniqueShortcutStrings(values) +} + +func validateFlagMessageIDs(rt *shortcut.RuntimeContext) error { + ids := flagMessageIDs(rt) + if len(ids) < 1 || len(ids) > 10 { + return apperrors.NewValidation(fmt.Sprintf("消息 ID 去重后必须为 1-10 条,当前 %d 条", len(ids))) + } + return nil +} + +func executeFlagBatch(rt *shortcut.RuntimeContext, tool string) error { + conversationID := rt.Str("conversation-id") + ids := flagMessageIDs(rt) + items := make([]shortcutBatchWrite, 0, len(ids)) + for _, id := range ids { + items = append(items, shortcutBatchWrite{ + target: id, + arguments: map[string]any{ + "openMessageId": id, + "openConversationId": conversationID, + }, + }) + } + return executeShortcutBatchWrite(rt, "im", tool, items) +} + +var FlagList = shortcut.Shortcut{ + Service: "chat", + Command: "+flag-list", + Product: "im", + Description: "分页查询当前用户收藏的消息", + Intent: "当你要查看当前用户的 DingTalk message favorite 列表时使用;返回下层分页结果,不把 message favorite 与 Pin、会话置顶或 Lark feed-layer thread flag 混为一谈。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "cursor", Type: shortcut.FlagInt, Default: "0", Desc: "数字分页游标,首次传 0"}, + {Name: "size", Type: shortcut.FlagInt, Default: "20", Desc: "每页数量,范围 1-100"}, + }, + Constraints: []shortcut.Constraint{{ + Kind: shortcut.ConstraintCustom, + Flags: []string{"cursor", "size"}, + Description: "--cursor 必须大于等于 0,--size 必须在 1-100 之间", + }}, + Tips: []string{`dws chat +flag-list --cursor 0 --size 20`}, + Validate: func(rt *shortcut.RuntimeContext) error { + if rt.Int("cursor") < 0 { + return apperrors.NewValidation("--cursor 必须大于等于 0") + } + if size := rt.Int("size"); size < 1 || size > 100 { + return apperrors.NewValidation("--size 必须在 1-100 之间") + } + return nil + }, + 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")), + }) + }, +} + +// FeedGroupQueryItem is a client-side exact filter over a DingTalk conversation +// category. It does not claim Lark deleted-item or server-side multi-ID query +// semantics. +var FeedGroupQueryItem = shortcut.Shortcut{ + Service: "chat", + Command: "+feed-group-query-item", + Product: "im", + Description: "在会话分组结果中按会话 ID 精确查询多项", + Intent: "当你已知一个钉钉会话分组 ID 和若干 openConversationId、想精确取回这些分组项时使用;先读取该分组,再按 ID 本地过滤并返回未找到清单。若下层表明结果仍有后续页但未提供可执行游标,本命令会把缺失项标为 unresolved 并返回失败 ledger,不会误报 notFound。它不提供 Lark deleted_items 或服务端多 ID 查询语义。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "category-id", Type: shortcut.FlagInt, Desc: "钉钉会话分组 ID", Required: true}, + {Name: "conversation-ids", Type: shortcut.FlagStringSlice, Desc: "要精确查询的 openConversationId 列表", Required: true}, + {Name: "exclude-muted", Type: shortcut.FlagBool, Desc: "读取分组时排除已免打扰会话"}, + }, + Tips: []string{`dws chat +feed-group-query-item --category-id <分组ID> --conversation-ids ,`}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{"categoryId": rt.Int("category-id")} + if rt.Bool("exclude-muted") { + params["excludeMuted"] = true + } + data, err := rt.CallMCPData("im", "list_conversations_by_category", params) + if err != nil { + return err + } + payload := feedGroupQueryProject(categoryConversationsProject(data), rt.StrSlice("conversation-ids")) + chatmsg.ApplyPagination(payload, data) + if page := chatmsg.Pagination(data); page["hasMore"] == true { + unresolved, _ := payload["notFoundConversationIds"].([]string) + payload["ok"] = false + payload["complete"] = false + payload["notFoundCount"] = 0 + payload["notFoundConversationIds"] = []string{} + payload["unresolvedCount"] = len(unresolved) + payload["unresolvedConversationIds"] = unresolved + payload["failedCount"] = 1 + payload["failures"] = []map[string]any{{ + "stage": "source-pagination", + "error": "下层返回 hasMore=true,但 list_conversations_by_category 未公开可继续游标;无法证明缺失会话不在后续页", + }} + } else { + payload["complete"] = true + payload["unresolvedCount"] = 0 + payload["unresolvedConversationIds"] = []string{} + } + return rt.Output(payload) + }, +} + +func feedGroupQueryProject(conversations []map[string]any, requestedIDs []string) map[string]any { + byID := make(map[string]map[string]any, len(conversations)) + for _, conversation := range conversations { + id := strings.TrimSpace(fmt.Sprint(conversation["openConversationId"])) + if id != "" { + byID[id] = conversation + } + } + items := make([]map[string]any, 0, len(requestedIDs)) + notFound := make([]string, 0) + seen := map[string]bool{} + for _, requestedID := range requestedIDs { + requestedID = strings.TrimSpace(requestedID) + if requestedID == "" || seen[requestedID] { + continue + } + seen[requestedID] = true + if item := byID[requestedID]; item != nil { + items = append(items, item) + } else { + notFound = append(notFound, requestedID) + } + } + return map[string]any{ + "ok": len(notFound) == 0, + "requestedCount": len(seen), + "foundCount": len(items), + "notFoundCount": len(notFound), + "failedCount": 0, + "items": items, + "notFoundConversationIds": notFound, + "failures": []map[string]any{}, + } +} + +func init() { + shortcut.Register( + ChatCreate, + ChatUpdate, + MessagesReply, + FlagCreate, + FlagCancel, + FlagList, + FeedGroupQueryItem, + ) +} diff --git a/internal/shortcut/chat/lark_alignment_test.go b/internal/shortcut/chat/lark_alignment_test.go new file mode 100644 index 00000000..5aeaf5f3 --- /dev/null +++ b/internal/shortcut/chat/lark_alignment_test.go @@ -0,0 +1,514 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. + +package chat + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "reflect" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" +) + +type larkAlignmentCall struct { + product string + tool string + args map[string]any +} + +type larkAlignmentCaller struct { + calls []larkAlignmentCall + failTarget string + failProductTool string + category string + responses map[string]string +} + +func (f *larkAlignmentCaller) CallTool(_ context.Context, product, tool string, args map[string]any) (*edition.ToolResult, error) { + f.calls = append(f.calls, larkAlignmentCall{product: product, tool: tool, args: args}) + if f.failTarget != "" && args["openMessageId"] == f.failTarget { + return nil, errors.New("fixture write failed") + } + key := product + "/" + tool + if f.failProductTool == key { + return nil, errors.New("fixture lower call failed") + } + text := `{"success":true}` + switch key { + case "contact/get_current_user_profile": + text = `{"result":[{"orgEmployeeModel":{"userId":"self-user"}}]}` + case "contact/get_user_info_by_user_ids": + text = `{"result":[{"userId":"user-id","openDingTalkId":"D-resolved"}]}` + case "im/create_group_conversation": + text = `{"result":{"cid":"internal-cid","openCid":"open-cid"}}` + case "im/list_messages_by_ids": + text = `{"result":[{"openMessageId":"msg","openConversationId":"cid","senderOpenDingTalkId":"D-inferred","content":"{\"mediaId\":\"@image\"}"}]}` + case "im/list_conversations_by_category": + text = f.category + if text == "" { + text = `{"result":{"hasMore":false,"list":[{"openConversationId":"cid-a","conversationName":"A"},{"openConversationId":"cid-b","conversationName":"B"}]}}` + } + } + if response, ok := f.responses[key]; ok { + text = response + } + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil +} + +func (f *larkAlignmentCaller) CallReadTool(ctx context.Context, product, tool string, args map[string]any) (*edition.ToolResult, error) { + return f.CallTool(ctx, product, tool, args) +} + +func (f *larkAlignmentCaller) Format() string { return "json" } +func (f *larkAlignmentCaller) DryRun() bool { return false } +func (f *larkAlignmentCaller) Fields() string { return "" } +func (f *larkAlignmentCaller) JQ() string { return "" } + +func TestChatCreateAddsCurrentUserAndNormalizesResult(t *testing.T) { + fake := &larkAlignmentCaller{} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + root.SetArgs([]string{ + "chat", "+chat-create", + "--name", "测试群", + "--users", "other-user,self-user", + "--type", "NORMAL", + "--thread", + "--yes", + }) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + if len(fake.calls) != 2 { + t.Fatalf("calls = %#v, want profile + create", fake.calls) + } + create := fake.calls[1] + if create.product != "im" || create.tool != "create_group_conversation" { + t.Fatalf("create call = %s/%s", create.product, create.tool) + } + if got, want := create.args["groupMembers"], []string{"self-user", "other-user"}; !reflect.DeepEqual(got, want) { + t.Fatalf("groupMembers = %#v, want %#v", got, want) + } + if create.args["groupType"] != "NORMAL" || create.args["convThreadEnabled"] != true { + t.Fatalf("create args = %#v", create.args) + } + + payload := map[string]any{"result": map[string]any{"cid": "secret", "openCid": "open"}} + normalizeCreatedConversation(payload) + result := payload["result"].(map[string]any) + if result["openConversationId"] != "open" { + t.Fatalf("normalized result = %#v", result) + } + if _, ok := result["cid"]; ok { + t.Fatalf("internal cid leaked: %#v", result) + } +} + +func TestMessagesSendRoutesIdentitySpecificTransports(t *testing.T) { + tests := []struct { + name string + args []string + product string + tool string + want map[string]any + }{ + { + name: "user", + args: []string{"chat", "+messages-send", "--as", "user", "--chat-id", "cid", "--markdown", "hello", "--idempotency-key", "u1", "--yes"}, + product: "chat", + tool: "send_personal_message", + want: map[string]any{"openConversationId": "cid", "msgType": "markdown", "uuid": "u1"}, + }, + { + name: "bot", + args: []string{"chat", "+messages-send", "--identity", "bot", "--robot-code", "robot", "--group", "cid", "--text", "hello", "--at-all", "--yes"}, + product: "bot", + tool: "send_robot_group_message", + want: map[string]any{"robotCode": "robot", "openConversationId": "cid", "isAtAll": "true"}, + }, + { + name: "webhook", + args: []string{"chat", "+messages-send", "--identity", "webhook", "--webhook-token", "token", "--text", "hello", "--at-all", "--yes"}, + product: "bot", + tool: "send_message_by_custom_robot", + want: map[string]any{"robotToken": "token", "isAtAll": true}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fake := &larkAlignmentCaller{} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + root.SetArgs(tt.args) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + if len(fake.calls) != 1 { + t.Fatalf("calls = %#v", fake.calls) + } + call := fake.calls[0] + if call.product != tt.product || call.tool != tt.tool { + t.Fatalf("call = %#v", call) + } + for key, want := range tt.want { + if !reflect.DeepEqual(call.args[key], want) { + t.Errorf("%s = %#v, want %#v", key, call.args[key], want) + } + } + }) + } +} + +func TestMessagesSendRejectsUnsupportedIdentityCapability(t *testing.T) { + fake := &larkAlignmentCaller{} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + root.SetArgs([]string{ + "chat", "+messages-send", + "--identity", "bot", + "--robot-code", "robot", + "--group", "cid", + "--text", "hello", + "--uuid", "unsupported", + "--yes", + }) + if err := root.Execute(); err == nil { + t.Fatal("bot uuid unexpectedly accepted") + } + if len(fake.calls) != 0 { + t.Fatalf("invalid capability reached lower service: %#v", fake.calls) + } +} + +func TestLarkAlignmentWriteMappings(t *testing.T) { + tests := []struct { + name string + args []string + product string + tool string + wantArgs map[string]any + }{ + { + name: "chat-update-name-only", + args: []string{"chat", "+chat-update", "--group", "cid", "--name", "新群名", "--yes"}, + product: "chat", + tool: "update_group_name", + wantArgs: map[string]any{ + "openconversation_id": "cid", + "group_name": "新群名", + }, + }, + { + name: "flag-create", + args: []string{"chat", "+flag-create", "--message-id", "msg", "--conversation-id", "cid", "--yes"}, + product: "im", + tool: "add_message_favorite", + wantArgs: map[string]any{ + "openMessageId": "msg", + "openConversationId": "cid", + }, + }, + { + name: "flag-cancel", + args: []string{"chat", "+flag-cancel", "--message-id", "msg", "--conversation-id", "cid", "--yes"}, + product: "im", + tool: "remove_message_favorite", + wantArgs: map[string]any{ + "openMessageId": "msg", + "openConversationId": "cid", + }, + }, + { + name: "flag-list", + args: []string{"chat", "+flag-list", "--cursor", "3", "--size", "50"}, + product: "im", + tool: "list_message_favorites", + wantArgs: map[string]any{ + "cursor": 3, + "size": "50", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fake := &larkAlignmentCaller{} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + root.SetArgs(tt.args) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + if len(fake.calls) != 1 { + t.Fatalf("calls = %#v, want 1", fake.calls) + } + call := fake.calls[0] + if call.product != tt.product || call.tool != tt.tool || !reflect.DeepEqual(call.args, tt.wantArgs) { + t.Fatalf("call = %#v, want %s/%s %#v", call, tt.product, tt.tool, tt.wantArgs) + } + }) + } +} + +func TestMessagesReplyPublishesPlainTextBoundary(t *testing.T) { + fake := &larkAlignmentCaller{} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + root.SetArgs([]string{ + "chat", "+messages-reply", + "--conversation-id", "cid", + "--message-id", "msg", + "--ref-sender", "D-sender", + "--text", "收到", + "--idempotency-key", "reply-uuid", + "--yes", + }) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + if len(fake.calls) != 1 { + t.Fatalf("calls = %#v, want 1", fake.calls) + } + call := fake.calls[0] + if call.product != "chat" || call.tool != "send_personal_message" { + t.Fatalf("reply call = %#v", call) + } + if call.args["openConversationId"] != "cid" || call.args["msgType"] != "reply" || call.args["uuid"] != "reply-uuid" { + t.Fatalf("reply args = %#v", call.args) + } + var content map[string]string + if err := json.Unmarshal([]byte(call.args["content"].(string)), &content); err != nil { + t.Fatal(err) + } + if content["referenceOpenMessageId"] != "msg" || + content["srcMsgSendOpenDingTalkId"] != "D-sender" || + content["replyMsgType"] != "text" || + content["content"] != "收到" { + t.Fatalf("reply content = %#v", content) + } +} + +func TestFlagBatchContinuesAndPublishesFailureLedger(t *testing.T) { + fake := &larkAlignmentCaller{failTarget: "m2"} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + var output bytes.Buffer + root.SetOut(&output) + root.SetArgs([]string{ + "chat", "+flag-create", + "--message-ids", "m1,m2", + "--conversation-id", "cid", + "--yes", + }) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + if len(fake.calls) != 2 { + t.Fatalf("calls = %#v", fake.calls) + } + var payload map[string]any + if err := json.Unmarshal(output.Bytes(), &payload); err != nil { + t.Fatal(err) + } + if payload["ok"] != false || payload["partial"] != true || + payload["requestedCount"] != float64(2) || + payload["succeededCount"] != float64(1) || + payload["failedCount"] != float64(1) { + t.Fatalf("payload = %#v", payload) + } +} + +func TestConversationSetTopBatchDryRunPublishesActionsWithoutWrites(t *testing.T) { + fake := &larkAlignmentCaller{} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + var output bytes.Buffer + root.SetOut(&output) + root.SetArgs([]string{ + "chat", "+conversation-set-top", + "--conversation-ids", "cid-1,cid-2", + "--off", + "--dry-run", + "--yes", + }) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + if len(fake.calls) != 0 { + t.Fatalf("dry-run reached lower service: %#v", fake.calls) + } + var payload map[string]any + if err := json.Unmarshal(output.Bytes(), &payload); err != nil { + t.Fatal(err) + } + if payload["dry_run"] != true || payload["executed"] != false || + payload["preview_kind"] != "plan" || + payload["actionCount"] != float64(2) || + payload["failedCount"] != float64(0) { + t.Fatalf("payload = %#v", payload) + } +} + +func TestMessagesMgetDryRunPublishesMultiResourceDownloadPlan(t *testing.T) { + fake := &larkAlignmentCaller{} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + var output bytes.Buffer + root.SetOut(&output) + root.SetArgs([]string{ + "chat", "+messages-mget", + "--msg-ids", "msg", + "--download-resources", + "--dry-run", + }) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + if len(fake.calls) != 1 || + fake.calls[0].product != "im" || + fake.calls[0].tool != "list_messages_by_ids" { + t.Fatalf("calls = %#v", fake.calls) + } + var payload map[string]any + if err := json.Unmarshal(output.Bytes(), &payload); err != nil { + t.Fatal(err) + } + ledger, _ := payload["resourceDownloads"].(map[string]any) + if ledger["dryRun"] != true || ledger["requestedCount"] != float64(1) { + t.Fatalf("ledger = %#v", ledger) + } +} + +func TestMessagesReplyResolvesUserIDBeforeExecution(t *testing.T) { + fake := &larkAlignmentCaller{} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + root.SetArgs([]string{ + "chat", "+messages-reply", + "--conversation-id", "cid", + "--ref-msg-id", "msg", + "--ref-sender", "user-id", + "--text", "收到", + "--yes", + }) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + if len(fake.calls) != 2 || + fake.calls[0].product != "contact" || + fake.calls[0].tool != "get_user_info_by_user_ids" || + fake.calls[1].tool != "send_personal_message" { + t.Fatalf("calls = %#v", fake.calls) + } + var content map[string]string + if err := json.Unmarshal([]byte(fake.calls[1].args["content"].(string)), &content); err != nil { + t.Fatal(err) + } + if content["srcMsgSendOpenDingTalkId"] != "D-resolved" { + t.Fatalf("content = %#v", content) + } +} + +func TestMessagesReplyInfersSenderFromReferencedMessage(t *testing.T) { + fake := &larkAlignmentCaller{} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + root.SetArgs([]string{ + "chat", "+messages-reply", + "--conversation-id", "cid", + "--ref-msg-id", "msg", + "--text", "收到", + "--yes", + }) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + if len(fake.calls) != 2 || + fake.calls[0].product != "im" || + fake.calls[0].tool != "list_messages_by_ids" || + fake.calls[1].tool != "send_personal_message" { + t.Fatalf("calls = %#v", fake.calls) + } + var content map[string]string + if err := json.Unmarshal([]byte(fake.calls[1].args["content"].(string)), &content); err != nil { + t.Fatal(err) + } + if content["srcMsgSendOpenDingTalkId"] != "D-inferred" { + t.Fatalf("content = %#v", content) + } +} + +func TestFindMessageSenderOpenDingTalkIDIgnoresUnrelatedNestedIdentity(t *testing.T) { + message := map[string]any{ + "content": map[string]any{ + "mentions": []any{ + map[string]any{"openDingTalkId": "D-wrong-recipient"}, + }, + }, + "quotedMessage": map[string]any{ + "senderOpenDingTalkId": "D-wrong-quoted-sender", + }, + } + if got := findMessageSenderOpenDingTalkID(message); got != "" { + t.Fatalf("sender = %q, want empty", got) + } + message["senderInfo"] = map[string]any{"openDingTalkId": "D-correct"} + if got := findMessageSenderOpenDingTalkID(message); got != "D-correct" { + t.Fatalf("sender = %q, want D-correct", got) + } +} + +func TestFeedGroupQueryProjectPreservesRequestOrderAndMissingLedger(t *testing.T) { + conversations := []map[string]any{ + {"openConversationId": "cid-a", "conversationName": "A"}, + {"openConversationId": "cid-b", "conversationName": "B"}, + } + got := feedGroupQueryProject(conversations, []string{"cid-b", "cid-missing", "cid-a", "cid-b"}) + if got["requestedCount"] != 3 || got["foundCount"] != 2 { + t.Fatalf("counts = %#v", got) + } + items := got["items"].([]map[string]any) + if items[0]["openConversationId"] != "cid-b" || items[1]["openConversationId"] != "cid-a" { + t.Fatalf("items = %#v", items) + } + if missing := got["notFoundConversationIds"]; !reflect.DeepEqual(missing, []string{"cid-missing"}) { + t.Fatalf("missing = %#v", missing) + } +} + +func TestFeedGroupQueryDoesNotMisreportMissingItemWhenSourceHasMore(t *testing.T) { + fake := &larkAlignmentCaller{ + category: `{"result":{"hasMore":true,"list":[{"openConversationId":"cid-a","conversationName":"A"}]}}`, + } + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + var output bytes.Buffer + root.SetOut(&output) + root.SetArgs([]string{ + "chat", "+feed-group-query-item", + "--category-id", "1", + "--conversation-ids", "cid-a,cid-later", + }) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + var payload map[string]any + if err := json.Unmarshal(output.Bytes(), &payload); err != nil { + t.Fatal(err) + } + if payload["complete"] != false || + payload["notFoundCount"] != float64(0) || + payload["unresolvedCount"] != float64(1) || + payload["failedCount"] != float64(1) { + t.Fatalf("payload = %#v", payload) + } + unresolved, _ := payload["unresolvedConversationIds"].([]any) + if len(unresolved) != 1 || unresolved[0] != "cid-later" { + t.Fatalf("unresolved = %#v", unresolved) + } +} diff --git a/internal/shortcut/chat/resource_download.go b/internal/shortcut/chat/resource_download.go new file mode 100644 index 00000000..65e232cf --- /dev/null +++ b/internal/shortcut/chat/resource_download.go @@ -0,0 +1,425 @@ +// 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 chat + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "time" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +const resourceDownloadTimeout = 10 * time.Minute + +var ( + resourceGetwd = os.Getwd + resourceAbs = filepath.Abs + resourceEvalSymlinks = filepath.EvalSymlinks + resourceStat = os.Stat + resourceLstat = os.Lstat + resourceRel = filepath.Rel + resourceMkdir = os.Mkdir + resourceCreateTemp = os.CreateTemp + resourceCopy = io.Copy + resourceTempSync = (*os.File).Sync + resourceTempClose = (*os.File).Close + resourceRename = os.Rename + resourceLink = os.Link + resourceDownload = downloadResourceAtomically +) + +// MessagesResourceDownload resolves a temporary IM resource URL and saves the +// bytes through a safe, atomic, no-clobber local-file workflow. +var MessagesResourceDownload = shortcut.Shortcut{ + Service: "chat", + Command: "+messages-resource-download", + Product: "im", + Description: "安全下载消息资源(图片/视频/语音)到本地", + Intent: "当你需要拿到消息里的实际图片、视频或语音文件,而不只是临时 URL 时使用;" + + "先用消息和会话身份换取下载地址,再安全写入工作目录内的相对路径。" + + "默认不覆盖已有文件,只有显式传 --overwrite 才覆盖。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "type", Type: shortcut.FlagString, Default: "mediaId", Desc: "资源类型", Enum: []string{"mediaId"}}, + {Name: "resource-id", Type: shortcut.FlagString, Desc: "资源 ID(消息中的 mediaId)", Required: true}, + {Name: "message-id", Type: shortcut.FlagString, Desc: "消息 openMessageId", Required: true}, + {Name: "open-conversation-id", Type: shortcut.FlagString, Desc: "会话 openConversationId", Required: true}, + {Name: "output", Type: shortcut.FlagString, Default: ".", Desc: "工作目录内的相对文件或目录路径"}, + {Name: "overwrite", Type: shortcut.FlagBool, Desc: "允许覆盖已存在的目标文件(默认拒绝)"}, + }, + Constraints: []shortcut.Constraint{ + { + Kind: shortcut.ConstraintCustom, + Flags: []string{"output"}, + Description: "--output 必须是工作目录内的相对路径,不允许绝对路径或 .. 逃逸", + }, + }, + Tips: []string{ + `dws chat +messages-resource-download --resource-id --message-id --open-conversation-id `, + `dws chat +messages-resource-download --resource-id --message-id --open-conversation-id --output ./downloads/`, + }, + Validate: func(rt *shortcut.RuntimeContext) error { + return validateResourceDownloadOutput(rt.Str("output")) + }, + Execute: func(rt *shortcut.RuntimeContext) error { + plan := map[string]any{ + "resourceType": rt.Str("type"), + "resourceId": rt.Str("resource-id"), + "messageId": rt.Str("message-id"), + "openConversationId": rt.Str("open-conversation-id"), + "output": rt.Str("output"), + "overwrite": rt.Bool("overwrite"), + } + if rt.DryRun() { + plan["dryRun"] = true + plan["steps"] = []string{ + "resolve temporary download URL", + "validate output path and no-clobber policy", + "download to a temporary file", + "atomically publish the completed file", + } + return rt.Output(plan) + } + + data, err := rt.CallMCPData("im", "get_resource_download_url", map[string]any{ + "resourceType": rt.Str("type"), + "resourceId": rt.Str("resource-id"), + "openMessageId": rt.Str("message-id"), + "openConversationId": rt.Str("open-conversation-id"), + }) + if err != nil { + return err + } + resourceURL, headers, err := resourceDownloadInfo(data) + if err != nil { + return err + } + cwd, err := resourceGetwd() + if err != nil { + return apperrors.NewInternal(fmt.Sprintf("读取工作目录失败: %v", err)) + } + destPath, relativePath, err := resolveResourceDownloadPath( + cwd, rt.Str("output"), resourceURL, rt.Bool("overwrite")) + if err != nil { + return err + } + size, err := resourceDownload( + rt.Command().Context(), nil, resourceURL, headers, destPath, rt.Bool("overwrite")) + if err != nil { + return err + } + return rt.Output(map[string]any{ + "messageId": rt.Str("message-id"), + "resourceId": rt.Str("resource-id"), + "resourceType": rt.Str("type"), + "localPath": filepath.ToSlash(relativePath), + "sizeBytes": size, + }) + }, +} + +func validateResourceDownloadOutput(output string) error { + return validateResourceDownloadOutputFlag(output, "--output") +} + +func validateResourceDownloadOutputFlag(output, flagName string) error { + output = strings.TrimSpace(output) + if output == "" { + return apperrors.NewValidation(flagName + " 不能为空") + } + if filepath.IsAbs(output) { + return apperrors.NewValidation(flagName + " 只接受工作目录内的相对路径") + } + clean := filepath.Clean(output) + if clean == ".." || strings.HasPrefix(clean, ".."+string(os.PathSeparator)) { + return apperrors.NewValidation(flagName + " 不允许使用 .. 逃逸工作目录") + } + return nil +} + +func resourceDownloadInfo(data map[string]any) (string, map[string]string, error) { + for { + result, ok := data["result"].(map[string]any) + if !ok { + break + } + data = result + } + + resourceURL := "" + switch value := data["resourceUrl"].(type) { + case string: + resourceURL = strings.TrimSpace(value) + case []any: + for _, item := range value { + if candidate, ok := item.(string); ok && strings.TrimSpace(candidate) != "" { + resourceURL = strings.TrimSpace(candidate) + break + } + } + } + if resourceURL == "" { + if value, ok := data["downloadUrl"].(string); ok { + resourceURL = strings.TrimSpace(value) + } + } + parsed, err := url.Parse(resourceURL) + if err != nil || strings.TrimSpace(parsed.Host) == "" { + return "", nil, apperrors.NewAPI( + "资源下载接口未返回合法的 HTTPS 下载地址") + } + if parsed.Scheme == "http" && isAliyunOSSHost(parsed.Hostname()) { + // The IM backend currently returns a signed plaintext OSS URL even + // though the same object endpoint supports TLS. Upgrade only the + // official OSS hostname; never rewrite arbitrary HTTP origins. + parsed.Scheme = "https" + resourceURL = parsed.String() + } + if parsed.Scheme != "https" { + return "", nil, apperrors.NewAPI( + "资源下载接口未返回合法的 HTTPS 下载地址") + } + + headers := map[string]string{} + if values, ok := data["headers"].(map[string]any); ok { + for key, value := range values { + if text, ok := value.(string); ok && strings.TrimSpace(key) != "" { + headers[key] = text + } + } + } + return resourceURL, headers, nil +} + +func isAliyunOSSHost(host string) bool { + host = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(host), ".")) + return host == "aliyuncs.com" || strings.HasSuffix(host, ".aliyuncs.com") +} + +func resolveResourceDownloadPath( + baseDir, output, resourceURL string, + overwrite bool, +) (absolutePath, relativePath string, err error) { + if err := validateResourceDownloadOutput(output); err != nil { + return "", "", err + } + baseDir, err = resourceAbs(baseDir) + if err != nil { + return "", "", apperrors.NewInternal(fmt.Sprintf("解析工作目录失败: %v", err)) + } + realBase, err := resourceEvalSymlinks(baseDir) + if err != nil { + return "", "", apperrors.NewInternal(fmt.Sprintf("解析工作目录失败: %v", err)) + } + + rawOutput := strings.TrimSpace(output) + directoryIntent := strings.HasSuffix(rawOutput, string(os.PathSeparator)) || + strings.HasSuffix(rawOutput, "/") + output = filepath.Clean(rawOutput) + candidate := filepath.Join(realBase, output) + info, statErr := resourceStat(candidate) + isDirectory := (statErr == nil && info.IsDir()) || + directoryIntent || + output == "." + if isDirectory { + candidate = filepath.Join(candidate, resourceDownloadFilename(resourceURL)) + } + + parent := filepath.Dir(candidate) + if err := ensureResourceDownloadParent(realBase, parent); err != nil { + return "", "", err + } + realParent, err := resourceEvalSymlinks(parent) + if err != nil { + return "", "", apperrors.NewInternal(fmt.Sprintf("解析输出目录失败: %v", err)) + } + parentRel, err := resourceRel(realBase, realParent) + if err != nil || parentRel == ".." || + strings.HasPrefix(parentRel, ".."+string(os.PathSeparator)) { + return "", "", apperrors.NewValidation("--output 解析后逃逸工作目录") + } + + absolutePath = filepath.Join(realParent, filepath.Base(candidate)) + if info, statErr := resourceLstat(absolutePath); statErr == nil { + if info.Mode()&os.ModeSymlink != 0 { + return "", "", apperrors.NewValidation("--output 目标不能是符号链接") + } + if info.IsDir() { + return "", "", apperrors.NewValidation("--output 目标是目录,无法写入文件") + } + if !overwrite { + return "", "", apperrors.NewValidation( + "目标文件已存在;如确认覆盖请显式传 --overwrite") + } + } else if !errors.Is(statErr, os.ErrNotExist) { + return "", "", apperrors.NewInternal(fmt.Sprintf("检查输出文件失败: %v", statErr)) + } + relativePath, err = resourceRel(realBase, absolutePath) + if err != nil { + return "", "", apperrors.NewInternal(fmt.Sprintf("解析输出相对路径失败: %v", err)) + } + return absolutePath, relativePath, nil +} + +func ensureResourceDownloadParent(baseDir, parent string) error { + relative, err := resourceRel(baseDir, parent) + if err != nil || relative == ".." || + strings.HasPrefix(relative, ".."+string(os.PathSeparator)) { + return apperrors.NewValidation("--output 解析后逃逸工作目录") + } + if relative == "." { + return nil + } + + current := baseDir + for _, part := range strings.Split(relative, string(os.PathSeparator)) { + current = filepath.Join(current, part) + info, statErr := resourceLstat(current) + if errors.Is(statErr, os.ErrNotExist) { + if mkdirErr := resourceMkdir(current, 0o755); mkdirErr != nil { + // A concurrent creator may have won the race. Re-check the + // resulting entry instead of following it implicitly. + info, statErr = resourceLstat(current) + if statErr != nil { + return apperrors.NewInternal(fmt.Sprintf( + "创建输出目录失败: %v", mkdirErr)) + } + } else { + continue + } + } else if statErr != nil { + return apperrors.NewInternal(fmt.Sprintf( + "检查输出目录失败: %v", statErr)) + } + if info.Mode()&os.ModeSymlink != 0 { + return apperrors.NewValidation("--output 父目录不能是符号链接") + } + if !info.IsDir() { + return apperrors.NewValidation("--output 父路径不是目录") + } + } + return nil +} + +func resourceDownloadFilename(resourceURL string) string { + parsed, err := url.Parse(resourceURL) + if err == nil { + name, unescapeErr := url.PathUnescape(filepath.Base(parsed.Path)) + if unescapeErr == nil { + name = filepath.Base(strings.ReplaceAll(name, "\\", "/")) + if name != "" && name != "." && name != string(os.PathSeparator) { + return name + } + } + } + return "download" +} + +func downloadResourceAtomically( + ctx context.Context, + client *http.Client, + resourceURL string, + headers map[string]string, + destPath string, + overwrite bool, +) (size int64, err error) { + if client == nil { + client = &http.Client{Timeout: resourceDownloadTimeout} + } + clientCopy := *client + client = &clientCopy + originalRedirect := client.CheckRedirect + client.CheckRedirect = func(req *http.Request, via []*http.Request) error { + if req.URL.Scheme != "https" || strings.TrimSpace(req.URL.Host) == "" { + return apperrors.NewValidation("资源下载重定向必须使用 HTTPS") + } + if len(via) >= 10 { + return apperrors.NewAPI("资源下载重定向次数过多") + } + if originalRedirect != nil { + return originalRedirect(req, via) + } + return nil + } + request, err := http.NewRequestWithContext(ctx, http.MethodGet, resourceURL, nil) + if err != nil { + return 0, apperrors.NewValidation(fmt.Sprintf("创建资源下载请求失败: %v", err)) + } + for key, value := range headers { + request.Header.Set(key, value) + } + response, err := client.Do(request) + if err != nil { + return 0, apperrors.NewAPI(fmt.Sprintf("下载消息资源失败: %v", err)) + } + defer response.Body.Close() + if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { + return 0, apperrors.NewAPI(fmt.Sprintf( + "下载消息资源失败: HTTP %d", response.StatusCode)) + } + + parent := filepath.Dir(destPath) + temp, err := resourceCreateTemp(parent, "."+filepath.Base(destPath)+".part-*") + if err != nil { + return 0, apperrors.NewInternal(fmt.Sprintf("创建下载临时文件失败: %v", err)) + } + tempPath := temp.Name() + defer func() { + _ = resourceTempClose(temp) + _ = os.Remove(tempPath) + }() + + size, err = resourceCopy(temp, response.Body) + if err != nil { + return 0, apperrors.NewAPI(fmt.Sprintf("写入消息资源失败: %v", err)) + } + if response.ContentLength >= 0 && size != response.ContentLength { + return 0, apperrors.NewAPI(fmt.Sprintf( + "消息资源大小校验失败: 下载 %d 字节,期望 %d 字节", + size, response.ContentLength)) + } + if err := resourceTempSync(temp); err != nil { + return 0, apperrors.NewInternal(fmt.Sprintf("同步消息资源失败: %v", err)) + } + if err := resourceTempClose(temp); err != nil { + return 0, apperrors.NewInternal(fmt.Sprintf("关闭消息资源失败: %v", err)) + } + if overwrite { + if err := resourceRename(tempPath, destPath); err != nil { + return 0, apperrors.NewInternal(fmt.Sprintf("发布消息资源失败: %v", err)) + } + return size, nil + } + if err := resourceLink(tempPath, destPath); err != nil { + if errors.Is(err, os.ErrExist) { + return 0, apperrors.NewValidation( + "目标文件已存在;如确认覆盖请显式传 --overwrite") + } + return 0, apperrors.NewInternal(fmt.Sprintf("发布消息资源失败: %v", err)) + } + return size, nil +} + +func init() { + shortcut.Register(MessagesResourceDownload) +} diff --git a/internal/shortcut/chat/resource_download_coverage_test.go b/internal/shortcut/chat/resource_download_coverage_test.go new file mode 100644 index 00000000..d6f7c2fb --- /dev/null +++ b/internal/shortcut/chat/resource_download_coverage_test.go @@ -0,0 +1,526 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package chat + +import ( + "bytes" + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" +) + +func resetResourceDownloadHooks(t *testing.T) { + t.Helper() + getwd := resourceGetwd + abs := resourceAbs + eval := resourceEvalSymlinks + stat := resourceStat + lstat := resourceLstat + rel := resourceRel + mkdir := resourceMkdir + createTemp := resourceCreateTemp + copyFn := resourceCopy + syncFn := resourceTempSync + closeFn := resourceTempClose + renameFn := resourceRename + linkFn := resourceLink + downloadFn := resourceDownload + t.Cleanup(func() { + resourceGetwd = getwd + resourceAbs = abs + resourceEvalSymlinks = eval + resourceStat = stat + resourceLstat = lstat + resourceRel = rel + resourceMkdir = mkdir + resourceCreateTemp = createTemp + resourceCopy = copyFn + resourceTempSync = syncFn + resourceTempClose = closeFn + resourceRename = renameFn + resourceLink = linkFn + resourceDownload = downloadFn + }) +} + +func TestCrossPlatformCoverageResourceDownloadCommandOutcomes(t *testing.T) { + baseArgs := []string{ + "chat", "+messages-resource-download", + "--resource-id", "@image", + "--message-id", "msg", + "--open-conversation-id", "cid", + } + t.Run("dry run", func(t *testing.T) { + helpers.InitDeps(&larkAlignmentCaller{}) + root := newPlatformCoverageRoot() + root.SetArgs(append(append([]string{}, baseArgs...), "--dry-run")) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + }) + t.Run("lower error", func(t *testing.T) { + helpers.InitDeps(&larkAlignmentCaller{failProductTool: "im/get_resource_download_url"}) + root := newPlatformCoverageRoot() + root.SetArgs(baseArgs) + if err := root.Execute(); err == nil { + t.Fatal("lower error was swallowed") + } + }) + t.Run("invalid download info", func(t *testing.T) { + helpers.InitDeps(&larkAlignmentCaller{responses: map[string]string{ + "im/get_resource_download_url": `{"result":{}}`, + }}) + root := newPlatformCoverageRoot() + root.SetArgs(baseArgs) + if err := root.Execute(); err == nil { + t.Fatal("missing URL was accepted") + } + }) + t.Run("getwd error", func(t *testing.T) { + resetResourceDownloadHooks(t) + resourceGetwd = func() (string, error) { return "", errors.New("getwd") } + helpers.InitDeps(&larkAlignmentCaller{responses: map[string]string{ + "im/get_resource_download_url": `{"result":{"resourceUrl":"https://example.test/file"}}`, + }}) + root := newPlatformCoverageRoot() + root.SetArgs(baseArgs) + if err := root.Execute(); err == nil { + t.Fatal("getwd error was swallowed") + } + }) + t.Run("path error", func(t *testing.T) { + resetResourceDownloadHooks(t) + resourceAbs = func(string) (string, error) { return "", errors.New("path") } + helpers.InitDeps(&larkAlignmentCaller{responses: map[string]string{ + "im/get_resource_download_url": `{"result":{"resourceUrl":"https://example.test/file"}}`, + }}) + root := newPlatformCoverageRoot() + root.SetArgs(baseArgs) + if err := root.Execute(); err == nil { + t.Fatal("path error was swallowed") + } + }) + for _, tc := range []struct { + name string + download func(context.Context, *http.Client, string, map[string]string, string, bool) (int64, error) + wantError bool + }{ + { + name: "download error", + download: func(context.Context, *http.Client, string, map[string]string, string, bool) (int64, error) { + return 0, errors.New("download") + }, + wantError: true, + }, + { + name: "success", + download: func(context.Context, *http.Client, string, map[string]string, string, bool) (int64, error) { + return 7, nil + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + resetResourceDownloadHooks(t) + resourceDownload = tc.download + helpers.InitDeps(&larkAlignmentCaller{responses: map[string]string{ + "im/get_resource_download_url": `{"result":{"resourceUrl":"https://example.test/file"}}`, + }}) + root := newPlatformCoverageRoot() + root.SetArgs(append(append([]string{}, baseArgs...), "--output", filepath.Join(t.TempDir(), "file"))) + // The absolute path is intentionally rejected by the public command, + // so use a relative path while running from the repository cwd. + root.SetArgs(append(append([]string{}, baseArgs...), "--output", "coverage-resource.bin", "--overwrite")) + err := root.Execute() + if (err != nil) != tc.wantError { + t.Fatalf("error = %v, wantError=%v", err, tc.wantError) + } + }) + } +} + +func TestCrossPlatformCoverageResourceDownloadValidationAndInfo(t *testing.T) { + for _, output := range []string{"", "/absolute", "../escape"} { + if err := validateResourceDownloadOutput(output); err == nil { + t.Errorf("unsafe output %q accepted", output) + } + } + for _, data := range []map[string]any{ + {}, + {"resourceUrl": []any{42, ""}}, + {"resourceUrl": "://bad"}, + {"resourceUrl": "http://example.test/file"}, + } { + if _, _, err := resourceDownloadInfo(data); err == nil { + t.Errorf("invalid resource info accepted: %#v", data) + } + } + resourceURL, headers, err := resourceDownloadInfo(map[string]any{ + "resourceUrl": "https://example.test/file", + "headers": map[string]any{ + "": "ignored", + "X-Count": 3, + "X-Test": "ok", + }, + }) + if err != nil || resourceURL == "" || len(headers) != 1 || headers["X-Test"] != "ok" { + t.Fatalf("resource info = %q %#v %v", resourceURL, headers, err) + } + for host, want := range map[string]bool{ + "ALIYUNCS.COM.": true, + "bucket.aliyuncs.com": true, + "example.com": false, + } { + if got := isAliyunOSSHost(host); got != want { + t.Errorf("isAliyunOSSHost(%q) = %v, want %v", host, got, want) + } + } +} + +func TestCrossPlatformCoverageResourceDownloadPathErrors(t *testing.T) { + t.Run("absolute resolution", func(t *testing.T) { + resetResourceDownloadHooks(t) + resourceAbs = func(string) (string, error) { return "", errors.New("abs") } + if _, _, err := resolveResourceDownloadPath(".", "file", "https://example.test/file", false); err == nil { + t.Fatal("absolute-path error was swallowed") + } + }) + t.Run("base symlink resolution", func(t *testing.T) { + resetResourceDownloadHooks(t) + resourceEvalSymlinks = func(string) (string, error) { return "", errors.New("eval") } + if _, _, err := resolveResourceDownloadPath(t.TempDir(), "file", "https://example.test/file", false); err == nil { + t.Fatal("base eval error was swallowed") + } + }) + t.Run("parent symlink resolution", func(t *testing.T) { + resetResourceDownloadHooks(t) + calls := 0 + resourceEvalSymlinks = func(path string) (string, error) { + calls++ + if calls == 2 { + return "", errors.New("parent eval") + } + return filepath.EvalSymlinks(path) + } + if _, _, err := resolveResourceDownloadPath(t.TempDir(), "file", "https://example.test/file", false); err == nil { + t.Fatal("parent eval error was swallowed") + } + }) + t.Run("parent escape", func(t *testing.T) { + resetResourceDownloadHooks(t) + calls := 0 + resourceRel = func(base, target string) (string, error) { + calls++ + if calls == 2 { + return "../outside", nil + } + return filepath.Rel(base, target) + } + if _, _, err := resolveResourceDownloadPath(t.TempDir(), "file", "https://example.test/file", false); err == nil { + t.Fatal("parent escape was accepted") + } + }) + t.Run("target inspection", func(t *testing.T) { + base := t.TempDir() + existing := filepath.Join(base, "existing") + if err := os.WriteFile(existing, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + if _, _, err := resolveResourceDownloadPath(base, "existing", "https://example.test/file", false); err == nil { + t.Fatal("existing file was accepted without overwrite") + } + if _, _, err := resolveResourceDownloadPath(base, "existing", "https://example.test/file", true); err != nil { + t.Fatal(err) + } + dir := filepath.Join(base, "dir") + if err := os.Mkdir(dir, 0o755); err != nil { + t.Fatal(err) + } + if _, _, err := resolveResourceDownloadPath(base, filepath.Join("dir", "child")+"/", "https://example.test/file", false); err != nil { + t.Fatal(err) + } + link := filepath.Join(base, "link") + if err := os.Symlink(existing, link); err == nil { + if _, _, resolveErr := resolveResourceDownloadPath(base, "link", "https://example.test/file", false); resolveErr == nil { + t.Fatal("symlink target was accepted") + } + } + }) + t.Run("lstat error", func(t *testing.T) { + resetResourceDownloadHooks(t) + resourceLstat = func(string) (os.FileInfo, error) { return nil, errors.New("lstat") } + if _, _, err := resolveResourceDownloadPath(t.TempDir(), "file", "https://example.test/file", false); err == nil { + t.Fatal("lstat error was swallowed") + } + }) + t.Run("target becomes directory", func(t *testing.T) { + resetResourceDownloadHooks(t) + base := t.TempDir() + resourceStat = func(string) (os.FileInfo, error) { return nil, os.ErrNotExist } + resourceLstat = func(string) (os.FileInfo, error) { + return os.Stat(base) + } + if _, _, err := resolveResourceDownloadPath(base, "file", "https://example.test/file", false); err == nil { + t.Fatal("directory target was accepted") + } + }) + t.Run("final relative error", func(t *testing.T) { + resetResourceDownloadHooks(t) + calls := 0 + resourceRel = func(base, target string) (string, error) { + calls++ + if calls == 3 { + return "", errors.New("rel") + } + return filepath.Rel(base, target) + } + if _, _, err := resolveResourceDownloadPath(t.TempDir(), "file", "https://example.test/file", false); err == nil { + t.Fatal("final relative error was swallowed") + } + }) +} + +func TestCrossPlatformCoverageEnsureResourceDownloadParentErrors(t *testing.T) { + base := t.TempDir() + if err := ensureResourceDownloadParent(base, filepath.Join(base, "..", "outside")); err == nil { + t.Fatal("outside parent was accepted") + } + if err := ensureResourceDownloadParent(base, base); err != nil { + t.Fatal(err) + } + t.Run("mkdir and recheck error", func(t *testing.T) { + resetResourceDownloadHooks(t) + resourceMkdir = func(string, os.FileMode) error { return errors.New("mkdir") } + resourceLstat = func(string) (os.FileInfo, error) { return nil, os.ErrNotExist } + if err := ensureResourceDownloadParent(base, filepath.Join(base, "new")); err == nil { + t.Fatal("mkdir error was swallowed") + } + }) + t.Run("concurrent creator", func(t *testing.T) { + resetResourceDownloadHooks(t) + target := filepath.Join(base, "concurrent") + resourceMkdir = func(path string, mode os.FileMode) error { + if err := os.Mkdir(path, mode); err != nil { + t.Fatal(err) + } + return errors.New("lost race") + } + if err := ensureResourceDownloadParent(base, target); err != nil { + t.Fatal(err) + } + }) + t.Run("inspection error", func(t *testing.T) { + resetResourceDownloadHooks(t) + resourceLstat = func(string) (os.FileInfo, error) { return nil, errors.New("inspect") } + if err := ensureResourceDownloadParent(base, filepath.Join(base, "inspect")); err == nil { + t.Fatal("inspection error was swallowed") + } + }) + file := filepath.Join(base, "file") + if err := os.WriteFile(file, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + if err := ensureResourceDownloadParent(base, file); err == nil { + t.Fatal("file parent was accepted") + } + link := filepath.Join(base, "link-parent") + if err := os.Symlink(base, link); err == nil { + if err := ensureResourceDownloadParent(base, link); err == nil { + t.Fatal("symlink parent was accepted") + } + } +} + +func TestCrossPlatformCoverageResourceDownloadFilenameFallbacks(t *testing.T) { + for _, resourceURL := range []string{"://bad", "https://example.test/", "https://example.test/%zz"} { + if got := resourceDownloadFilename(resourceURL); got != "download" { + t.Errorf("resourceDownloadFilename(%q) = %q", resourceURL, got) + } + } + if got := resourceDownloadFilename("https://example.test/a%20b.txt"); got != "a b.txt" { + t.Fatalf("decoded filename = %q", got) + } +} + +type resourceRoundTripper func(*http.Request) (*http.Response, error) + +func (f resourceRoundTripper) RoundTrip(request *http.Request) (*http.Response, error) { + return f(request) +} + +func resourceResponseClient(status int, body string, length int64) *http.Client { + return &http.Client{Transport: resourceRoundTripper(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: status, + Body: io.NopCloser(strings.NewReader(body)), + ContentLength: length, + Header: make(http.Header), + }, nil + })} +} + +func TestCrossPlatformCoverageDownloadResourceHTTPFailures(t *testing.T) { + dest := filepath.Join(t.TempDir(), "resource") + if _, err := downloadResourceAtomically(context.Background(), nil, ":", nil, dest, false); err == nil { + t.Fatal("invalid request URL was accepted") + } + errorClient := &http.Client{Transport: resourceRoundTripper(func(*http.Request) (*http.Response, error) { + return nil, errors.New("transport") + })} + if _, err := downloadResourceAtomically(context.Background(), errorClient, "https://example.test/file", nil, dest, false); err == nil { + t.Fatal("transport error was swallowed") + } + if _, err := downloadResourceAtomically(context.Background(), resourceResponseClient(500, "", 0), "https://example.test/file", nil, dest, false); err == nil { + t.Fatal("HTTP 500 was accepted") + } + if _, err := downloadResourceAtomically(context.Background(), resourceResponseClient(200, "x", 2), "https://example.test/file", nil, dest, false); err == nil { + t.Fatal("content-length mismatch was accepted") + } + + plain := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + if request.Header.Get("X-Test") != "ok" { + t.Errorf("missing forwarded header") + } + _, _ = w.Write([]byte("body")) + })) + t.Cleanup(plain.Close) + nilClientDest := filepath.Join(t.TempDir(), "nil-client") + if _, err := downloadResourceAtomically(context.Background(), nil, plain.URL, map[string]string{"X-Test": "ok"}, nilClientDest, true); err != nil { + t.Fatal(err) + } +} + +func TestCrossPlatformCoverageDownloadResourceRedirectGuards(t *testing.T) { + httpRedirect := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Redirect(w, &http.Request{}, "http://example.test/file", http.StatusFound) + })) + t.Cleanup(httpRedirect.Close) + if _, err := downloadResourceAtomically( + context.Background(), httpRedirect.Client(), httpRedirect.URL, nil, + filepath.Join(t.TempDir(), "http-redirect"), false, + ); err == nil { + t.Fatal("HTTP redirect was accepted") + } + + var loop *httptest.Server + loop = httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Redirect(w, &http.Request{}, loop.URL, http.StatusFound) + })) + t.Cleanup(loop.Close) + if _, err := downloadResourceAtomically( + context.Background(), loop.Client(), loop.URL, nil, + filepath.Join(t.TempDir(), "loop"), false, + ); err == nil { + t.Fatal("redirect loop was accepted") + } + + original := loop.Client() + original.CheckRedirect = func(*http.Request, []*http.Request) error { + return errors.New("original redirect policy") + } + if _, err := downloadResourceAtomically( + context.Background(), original, loop.URL, nil, + filepath.Join(t.TempDir(), "original"), false, + ); err == nil { + t.Fatal("original redirect rejection was swallowed") + } +} + +func TestCrossPlatformCoverageDownloadResourceFileFailures(t *testing.T) { + client := resourceResponseClient(http.StatusOK, "body", 4) + run := func(t *testing.T, overwrite bool) error { + t.Helper() + _, err := downloadResourceAtomically( + context.Background(), client, "https://example.test/file", nil, + filepath.Join(t.TempDir(), "resource"), overwrite) + return err + } + t.Run("create temp", func(t *testing.T) { + resetResourceDownloadHooks(t) + resourceCreateTemp = func(string, string) (*os.File, error) { return nil, errors.New("create") } + if err := run(t, false); err == nil { + t.Fatal("create-temp error was swallowed") + } + }) + t.Run("copy", func(t *testing.T) { + resetResourceDownloadHooks(t) + resourceCopy = func(io.Writer, io.Reader) (int64, error) { return 0, errors.New("copy") } + if err := run(t, false); err == nil { + t.Fatal("copy error was swallowed") + } + }) + t.Run("sync", func(t *testing.T) { + resetResourceDownloadHooks(t) + resourceTempSync = func(*os.File) error { return errors.New("sync") } + if err := run(t, false); err == nil { + t.Fatal("sync error was swallowed") + } + }) + t.Run("close", func(t *testing.T) { + resetResourceDownloadHooks(t) + calls := 0 + resourceTempClose = func(file *os.File) error { + calls++ + if calls == 1 { + return errors.New("close") + } + return file.Close() + } + if err := run(t, false); err == nil { + t.Fatal("close error was swallowed") + } + }) + t.Run("rename", func(t *testing.T) { + resetResourceDownloadHooks(t) + resourceRename = func(string, string) error { return errors.New("rename") } + if err := run(t, true); err == nil { + t.Fatal("rename error was swallowed") + } + }) + t.Run("link exists", func(t *testing.T) { + resetResourceDownloadHooks(t) + resourceLink = func(string, string) error { return os.ErrExist } + if err := run(t, false); err == nil { + t.Fatal("link-exists error was swallowed") + } + }) + t.Run("link other", func(t *testing.T) { + resetResourceDownloadHooks(t) + resourceLink = func(string, string) error { return errors.New("link") } + if err := run(t, false); err == nil { + t.Fatal("link error was swallowed") + } + }) +} + +func TestCrossPlatformCoverageDownloadResourceCopySuccessWithBuffer(t *testing.T) { + resetResourceDownloadHooks(t) + var copied bytes.Buffer + resourceCopy = func(writer io.Writer, reader io.Reader) (int64, error) { + body, err := io.ReadAll(reader) + if err != nil { + return 0, err + } + copied.Write(body) + written, writeErr := writer.Write(body) + return int64(written), writeErr + } + dest := filepath.Join(t.TempDir(), "resource") + if _, err := downloadResourceAtomically( + context.Background(), resourceResponseClient(200, "ok", 2), + "https://example.test/file", nil, dest, true, + ); err != nil { + t.Fatal(err) + } + if copied.String() != "ok" { + t.Fatalf("copied = %q", copied.String()) + } +} diff --git a/internal/shortcut/chat/resource_download_test.go b/internal/shortcut/chat/resource_download_test.go new file mode 100644 index 00000000..11dcbf3c --- /dev/null +++ b/internal/shortcut/chat/resource_download_test.go @@ -0,0 +1,146 @@ +// 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 chat + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestResourceDownloadInfo(t *testing.T) { + url, headers, err := resourceDownloadInfo(map[string]any{ + "result": map[string]any{ + "resourceUrl": []any{"https://download.example.test/path/image.png"}, + "headers": map[string]any{ + "X-Test": "value", + }, + }, + }) + if err != nil { + t.Fatal(err) + } + if url != "https://download.example.test/path/image.png" { + t.Fatalf("url = %q", url) + } + if headers["X-Test"] != "value" { + t.Fatalf("headers = %#v", headers) + } + upgraded, _, err := resourceDownloadInfo(map[string]any{ + "result": map[string]any{ + "downloadUrl": "http://bucket.oss-cn-hangzhou.aliyuncs.com/file?signature=x", + }, + }) + if err != nil { + t.Fatal(err) + } + if upgraded != "https://bucket.oss-cn-hangzhou.aliyuncs.com/file?signature=x" { + t.Fatalf("upgraded URL = %q", upgraded) + } + if _, _, err := resourceDownloadInfo(map[string]any{ + "result": map[string]any{"resourceUrl": "http://download.example.test/a"}, + }); err == nil { + t.Fatal("plain HTTP URL unexpectedly accepted") + } +} + +func TestResolveResourceDownloadPath(t *testing.T) { + base := t.TempDir() + if err := os.Mkdir(filepath.Join(base, "downloads"), 0o755); err != nil { + t.Fatal(err) + } + absolute, relative, err := resolveResourceDownloadPath( + base, + "downloads/", + "https://download.example.test/path/photo.png?token=redacted", + false, + ) + if err != nil { + t.Fatal(err) + } + if filepath.Base(absolute) != "photo.png" || filepath.ToSlash(relative) != "downloads/photo.png" { + t.Fatalf("path = %q / %q", absolute, relative) + } + + for _, unsafe := range []string{"../escape", filepath.Join("a", "..", "..", "escape")} { + if _, _, err := resolveResourceDownloadPath( + base, unsafe, "https://download.example.test/file", false, + ); err == nil { + t.Fatalf("unsafe output %q unexpectedly accepted", unsafe) + } + } +} + +func TestValidateResourceDownloadOutputUsesOwningFlagName(t *testing.T) { + err := validateResourceDownloadOutputFlag("../escape", "--output-dir") + if err == nil || !strings.Contains(err.Error(), "--output-dir") { + t.Fatalf("error = %v, want --output-dir", err) + } +} + +func TestResolveResourceDownloadPathRejectsSymlinkParentBeforeCreatingOutside(t *testing.T) { + base := t.TempDir() + outside := t.TempDir() + link := filepath.Join(base, "linked") + if err := os.Symlink(outside, link); err != nil { + t.Skipf("symlink unsupported: %v", err) + } + + _, _, err := resolveResourceDownloadPath( + base, + filepath.Join("linked", "new-child", "resource.bin"), + "https://download.example.test/resource.bin", + false, + ) + if err == nil || !strings.Contains(err.Error(), "符号链接") { + t.Fatalf("symlink-parent error = %v", err) + } + if _, statErr := os.Stat(filepath.Join(outside, "new-child")); !os.IsNotExist(statErr) { + t.Fatalf("outside directory was created before rejection: %v", statErr) + } +} + +func TestDownloadResourceAtomically(t *testing.T) { + const body = "verified download bytes" + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(body)) + })) + t.Cleanup(server.Close) + dest := filepath.Join(t.TempDir(), "resource.bin") + + size, err := downloadResourceAtomically( + context.Background(), server.Client(), server.URL, nil, dest, false) + if err != nil { + t.Fatal(err) + } + if size != int64(len(body)) { + t.Fatalf("size = %d, want %d", size, len(body)) + } + got, err := os.ReadFile(dest) + if err != nil { + t.Fatal(err) + } + if string(got) != body { + t.Fatalf("body = %q", got) + } + if _, err := downloadResourceAtomically( + context.Background(), server.Client(), server.URL, nil, dest, false, + ); err == nil || !strings.Contains(err.Error(), "已存在") { + t.Fatalf("no-clobber error = %v", err) + } +} diff --git a/internal/shortcut/chat/unified_send.go b/internal/shortcut/chat/unified_send.go new file mode 100644 index 00000000..f9c51fc8 --- /dev/null +++ b/internal/shortcut/chat/unified_send.go @@ -0,0 +1,259 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. + +package chat + +import ( + "encoding/json" + "fmt" + "strings" + "unicode/utf8" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +// MessagesSend is the identity-aware common text/Markdown entry point. Media +// upload remains on the native message send command until DWS has one shared +// upload contract; this Shortcut does not pretend webhook/bot/current-user +// transports support identical capabilities. +var MessagesSend = shortcut.Shortcut{ + Service: "chat", + Command: "+messages-send", + Product: "chat", + Description: "统一发送文本/Markdown(current user、bot、webhook)", + Intent: "当你希望用同一个入口选择 current-user、bot 或 webhook 身份发送文本/Markdown 时使用;命令会按身份校验目标和凭据并路由到真实下层。current-user 支持幂等键,bot 支持群聊或批量单聊,webhook 的目标由 token 所在群决定。媒体上传仍使用原生命令。", + Risk: shortcut.RiskWrite, + Flags: []shortcut.Flag{ + {Name: "identity", Type: shortcut.FlagString, Default: "user", Enum: []string{"user", "bot", "webhook"}, Desc: "发送身份"}, + {Name: "as", Type: shortcut.FlagString, Enum: []string{"user", "bot", "webhook"}, Desc: "--identity 的 lark-cli 对齐别名"}, + {Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId(user/bot 群聊)"}, + {Name: "chat-id", Type: shortcut.FlagString, Desc: "--group 的 lark-cli 对齐别名"}, + {Name: "open-dingtalk-id", Type: shortcut.FlagString, Desc: "单聊接收者 openDingTalkId(user)"}, + {Name: "users", Type: shortcut.FlagStringSlice, Desc: "批量单聊接收者 userId(bot)"}, + {Name: "open-dingtalk-ids", Type: shortcut.FlagStringSlice, Desc: "批量单聊接收者 openDingTalkId(bot)"}, + {Name: "robot-code", Type: shortcut.FlagString, Desc: "机器人 Code(bot 必填)"}, + {Name: "webhook-token", Type: shortcut.FlagString, Desc: "自定义机器人 Webhook token(webhook 必填)"}, + {Name: "title", Type: shortcut.FlagString, Desc: "消息标题(不传则从正文生成)"}, + {Name: "text", Type: shortcut.FlagString, Desc: "纯文本正文"}, + {Name: "markdown", Type: shortcut.FlagString, Desc: "Markdown 正文"}, + {Name: "uuid", Type: shortcut.FlagString, Desc: "幂等键(仅 user)"}, + {Name: "idempotency-key", Type: shortcut.FlagString, Desc: "--uuid 的 lark-cli 对齐别名(仅 user)"}, + {Name: "at-open-dingtalk-ids", Type: shortcut.FlagStringSlice, Desc: "@ 的 openDingTalkId(user/bot 群聊)"}, + {Name: "at-user-ids", Type: shortcut.FlagStringSlice, Desc: "@ 的 userId(bot/webhook)"}, + {Name: "at-mobiles", Type: shortcut.FlagStringSlice, Desc: "@ 的手机号(webhook)"}, + {Name: "at-all", Type: shortcut.FlagBool, Desc: "@所有人"}, + shortcut.AIMessageTagFlag(), + }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintAtLeastOne, Flags: []string{"text", "markdown"}}, + {Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"text", "markdown"}}, + {Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"identity", "as"}}, + {Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"group", "chat-id"}}, + {Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"uuid", "idempotency-key"}}, + { + Kind: shortcut.ConstraintCustom, + Flags: []string{"identity", "as", "group", "chat-id", "open-dingtalk-id", "users", "open-dingtalk-ids", "robot-code", "webhook-token", "uuid", "idempotency-key"}, + Description: "user 必须指定 group/open-dingtalk-id 之一;bot 必须指定 robot-code 和群聊/批量单聊目标之一;webhook 必须指定 webhook-token;幂等键仅 user 支持", + }, + }, + Tips: []string{ + `dws chat +messages-send --as user --chat-id --markdown "## 周报" --idempotency-key `, + `dws chat +messages-send --as bot --robot-code --users userId1,userId2 --text "请提交周报"`, + }, + Validate: validateMessagesSend, + Execute: executeMessagesSend, +} + +func validateMessagesSend(rt *shortcut.RuntimeContext) error { + identity := messagesSendIdentity(rt) + group := rt.StrFirst("chat-id", "group") + openID := rt.Str("open-dingtalk-id") + users := uniqueShortcutStrings(rt.StrSlice("users")) + openIDs := uniqueShortcutStrings(rt.StrSlice("open-dingtalk-ids")) + atOpenIDs := uniqueShortcutStrings(rt.StrSlice("at-open-dingtalk-ids")) + atUserIDs := uniqueShortcutStrings(rt.StrSlice("at-user-ids")) + atMobiles := uniqueShortcutStrings(rt.StrSlice("at-mobiles")) + switch identity { + case "user": + if (group == "") == (openID == "") { + return apperrors.NewValidation("--identity user 时 --group 与 --open-dingtalk-id 必须且只能指定一个") + } + if len(users) > 0 || len(openIDs) > 0 || rt.Str("robot-code") != "" || rt.Str("webhook-token") != "" { + return apperrors.NewValidation("--identity user 不接受 bot/webhook 凭据或批量目标") + } + if len(atUserIDs) > 0 || len(atMobiles) > 0 { + return apperrors.NewValidation("--identity user 只接受 --at-open-dingtalk-ids") + } + if openID != "" && (len(atOpenIDs) > 0 || rt.Bool("at-all")) { + return apperrors.NewValidation("user 单聊不接受 @ 参数;@ 只适用于群聊") + } + case "bot": + if rt.Str("robot-code") == "" { + return apperrors.NewValidation("--identity bot 必须指定 --robot-code") + } + hasDirect := len(users)+len(openIDs) > 0 + if (group != "") == hasDirect { + return apperrors.NewValidation("--identity bot 时 --group 与批量单聊目标必须且只能指定一类") + } + if openID != "" || rt.Str("webhook-token") != "" { + return apperrors.NewValidation("--identity bot 不接受 --open-dingtalk-id 或 --webhook-token") + } + if len(atMobiles) > 0 { + return apperrors.NewValidation("--identity bot 不接受 --at-mobiles") + } + if hasDirect && (len(atOpenIDs) > 0 || len(atUserIDs) > 0) { + return apperrors.NewValidation("bot 批量单聊不接受指定 @对象;@对象只适用于 bot 群聊") + } + if messagesSendIdempotencyKey(rt) != "" { + return apperrors.NewValidation("--uuid 当前仅 user 身份的下层支持") + } + case "webhook": + if rt.Str("webhook-token") == "" { + return apperrors.NewValidation("--identity webhook 必须指定 --webhook-token") + } + if group != "" || openID != "" || len(users) > 0 || len(openIDs) > 0 || rt.Str("robot-code") != "" { + return apperrors.NewValidation("--identity webhook 的目标由 token 所在群决定,不接受其他目标或 bot Code") + } + if len(atOpenIDs) > 0 { + return apperrors.NewValidation("--identity webhook 不接受 --at-open-dingtalk-ids") + } + if messagesSendIdempotencyKey(rt) != "" { + return apperrors.NewValidation("--uuid 当前仅 user 身份的下层支持") + } + } + return nil +} + +func executeMessagesSend(rt *shortcut.RuntimeContext) error { + identity := messagesSendIdentity(rt) + body := messagesSendBody(rt) + title := rt.Str("title") + if title == "" { + title = shortcutMessageTitle(body) + } + switch identity { + case "user": + content, _ := json.Marshal(map[string]string{"title": title, "text": body}) + params := rt.AddAIMessageTag(map[string]any{ + "msgType": "markdown", + "content": string(content), + }) + if group := rt.StrFirst("chat-id", "group"); group != "" { + params["openConversationId"] = group + if values := uniqueShortcutStrings(rt.StrSlice("at-open-dingtalk-ids")); len(values) > 0 { + params["atOpenDingTalkIds"] = values + } + if rt.Bool("at-all") { + params["atAll"] = true + } + } else { + params["receiverOpenDingTalkId"] = rt.Str("open-dingtalk-id") + } + if value := messagesSendIdempotencyKey(rt); value != "" { + params["uuid"] = value + } + return executeUnifiedMessageWrite(rt, "chat", "send_personal_message", params) + case "bot": + params := map[string]any{ + "robotCode": rt.Str("robot-code"), + "title": title, + "markdown": body, + } + if group := rt.StrFirst("chat-id", "group"); group != "" { + params["openConversationId"] = group + if values := uniqueShortcutStrings(rt.StrSlice("at-user-ids")); len(values) > 0 { + params["atUserIds"] = values + } + if values := uniqueShortcutStrings(rt.StrSlice("at-open-dingtalk-ids")); len(values) > 0 { + params["atOpendingtalkIds"] = values + } + if rt.Bool("at-all") { + params["isAtAll"] = "true" + } + return executeUnifiedMessageWrite(rt, "bot", "send_robot_group_message", params) + } + if values := uniqueShortcutStrings(rt.StrSlice("users")); len(values) > 0 { + params["userIds"] = values + } + if values := uniqueShortcutStrings(rt.StrSlice("open-dingtalk-ids")); len(values) > 0 { + params["openDingtalkIds"] = values + } + if rt.Bool("at-all") { + params["isAtAll"] = "true" + } + return executeUnifiedMessageWrite(rt, "bot", "batch_send_robot_msg_to_users", params) + case "webhook": + params := map[string]any{ + "robotToken": rt.Str("webhook-token"), + "title": title, + "text": body, + } + if values := uniqueShortcutStrings(rt.StrSlice("at-user-ids")); len(values) > 0 { + params["atUserIds"] = values + } + if values := uniqueShortcutStrings(rt.StrSlice("at-mobiles")); len(values) > 0 { + params["atMobiles"] = values + } + if rt.Bool("at-all") { + params["isAtAll"] = true + } + return executeUnifiedMessageWrite(rt, "bot", "send_message_by_custom_robot", params) + default: + return fmt.Errorf("unsupported identity %q", identity) + } +} + +func executeUnifiedMessageWrite(rt *shortcut.RuntimeContext, product, tool string, arguments map[string]any) error { + if rt.DryRun() { + return rt.Output(map[string]any{ + "dry_run": true, + "executed": false, + "preview_kind": "plan", + "tool": tool, + "actionCount": 1, + "failedCount": 0, + "actions": []map[string]any{{ + "identity": messagesSendIdentity(rt), + "tool": tool, + "arguments": arguments, + }}, + }) + } + data, err := rt.CallMCPWriteData(product, tool, arguments) + if err != nil { + return err + } + return rt.Output(map[string]any{ + "ok": true, + "identity": messagesSendIdentity(rt), + "tool": tool, + "result": data, + }) +} + +func messagesSendIdentity(rt *shortcut.RuntimeContext) string { + return rt.StrFirst("as", "identity") +} + +func messagesSendBody(rt *shortcut.RuntimeContext) string { + return rt.StrFirst("markdown", "text") +} + +func messagesSendIdempotencyKey(rt *shortcut.RuntimeContext) string { + return rt.StrFirst("idempotency-key", "uuid") +} + +func shortcutMessageTitle(text string) string { + text = strings.TrimSpace(strings.SplitN(text, "\n", 2)[0]) + if utf8.RuneCountInString(text) <= 40 { + return text + } + runes := []rune(text) + return string(runes[:40]) +} + +func init() { + shortcut.Register(MessagesSend) +} diff --git a/internal/shortcut/chatmsg/chatmsg.go b/internal/shortcut/chatmsg/chatmsg.go index b1389819..62e0b2bf 100644 --- a/internal/shortcut/chatmsg/chatmsg.go +++ b/internal/shortcut/chatmsg/chatmsg.go @@ -31,7 +31,10 @@ package chatmsg import ( "encoding/json" + "fmt" + "reflect" "regexp" + "sort" "strings" ) @@ -116,6 +119,439 @@ func CreateTime(m map[string]any) any { return nil } +// MessageID preserves the stable message identity needed by follow-up reply, +// reaction, resource and deduplication operations. +func MessageID(m map[string]any) any { + return firstMessageValue(m, "openMessageId", "openMsgId", "messageId", "message_id", "msgId", "msg_id", "id") +} + +// ConversationID preserves the stable conversation identity carried by list +// and search responses. +func ConversationID(m map[string]any) any { + return firstMessageValue(m, "openConversationId", "openconversation_id", "conversationId", "conversation_id", "chatId", "chat_id") +} + +// ThreadID preserves the stable topic/thread identity needed to continue from +// a message-list result into the thread-replies command. +func ThreadID(m map[string]any) any { + return firstMessageValue( + m, + "openConvThreadId", + "openConversationThreadId", + "threadId", + "thread_id", + "topicId", + "topic_id", + ) +} + +// MessageType preserves the lower message type when present. +func MessageType(m map[string]any) any { + return firstMessageValue(m, "msgType", "messageType", "message_type", "type") +} + +// QuotedMessage projects one level of quoted/replied-to context. It is +// deliberately non-recursive: a reply chain may be arbitrarily deep or even +// cyclic after gateway reshaping, while an Agent primarily needs the quoted +// message's stable identity, speaker, readable body and time. +func QuotedMessage(m map[string]any) map[string]any { + var quoted map[string]any + for _, key := range []string{"quotedMessage", "replyMessage", "quoted", "replyToMessage"} { + if value, ok := m[key].(map[string]any); ok { + quoted = value + break + } + } + if quoted == nil { + return nil + } + out := map[string]any{} + if value := MessageID(quoted); value != nil { + out["messageId"] = value + } + if value := ConversationID(quoted); value != nil { + out["conversationId"] = value + } + if value := ThreadID(quoted); value != nil { + out["threadId"] = value + } + if value := Sender(quoted); value != nil { + out["sender"] = value + } + if value := Text(quoted); value != nil { + out["text"] = value + } + if value := CreateTime(quoted); value != nil { + out["createTime"] = value + } + if value := MessageType(quoted); value != nil { + out["messageType"] = value + } + if resources := Resources(quoted); len(resources) > 0 { + out["resourceRefs"] = resources + } + return out +} + +func firstMessageValue(m map[string]any, keys ...string) any { + for _, key := range keys { + value, ok := m[key] + if !ok || value == nil { + continue + } + if text, isString := value.(string); isString && strings.TrimSpace(text) == "" { + continue + } + return value + } + return nil +} + +// Resources extracts actionable media references from both structured message +// fields and the textual mediaId notation returned by older DingTalk message +// APIs. Every reference publishes the exact Shortcut arguments already known +// from the message, plus ready=false and missing fields when a follow-up lookup +// is still required. This shared shape is used by list, search, mget, quoted +// messages and thread replies. +func Resources(m map[string]any) []map[string]any { + if m == nil { + return nil + } + ids := make([]string, 0) + collectMediaIDs(m, &ids) + ids = uniqueMediaIDs(ids) + sort.Strings(ids) + if len(ids) == 0 { + return nil + } + + messageID := strings.TrimSpace(fmt.Sprint(MessageID(m))) + conversationID := strings.TrimSpace(fmt.Sprint(ConversationID(m))) + if messageID == "" { + messageID = "" + } + if conversationID == "" { + conversationID = "" + } + + out := make([]map[string]any, 0, len(ids)) + for _, id := range ids { + arguments := map[string]any{ + "type": "mediaId", + "resource-id": id, + } + missing := make([]string, 0, 2) + if messageID != "" { + arguments["message-id"] = messageID + } else { + missing = append(missing, "message-id") + } + if conversationID != "" { + arguments["open-conversation-id"] = conversationID + } else { + missing = append(missing, "open-conversation-id") + } + out = append(out, map[string]any{ + "type": "mediaId", + "resourceId": id, + "download": map[string]any{ + "shortcut": "+messages-resource-download", + "arguments": arguments, + "ready": len(missing) == 0, + "missing": missing, + }, + }) + } + return out +} + +var mediaIDTextRE = regexp.MustCompile(`(?i)media[_-]?id\s*[:=]\s*["']?([^"'\s)\]}>,]+)`) + +func collectMediaIDs(value any, out *[]string) { + switch typed := value.(type) { + case map[string]any: + resourceType := strings.TrimSpace(fmt.Sprint(firstMessageValue(typed, "resourceType", "resource_type"))) + for key, child := range typed { + normalizedKey := strings.ToLower(strings.NewReplacer("_", "", "-", "").Replace(key)) + if normalizedKey == "mediaid" || (normalizedKey == "resourceid" && strings.EqualFold(resourceType, "mediaId")) { + if id := mediaIDScalar(child); id != "" { + *out = append(*out, id) + } + } + collectMediaIDs(child, out) + } + case []any: + for _, child := range typed { + collectMediaIDs(child, out) + } + case []map[string]any: + for _, child := range typed { + collectMediaIDs(child, out) + } + case string: + for _, match := range mediaIDTextRE.FindAllStringSubmatch(typed, -1) { + if len(match) > 1 { + if id := mediaIDScalar(match[1]); id != "" { + *out = append(*out, id) + } + } + } + trimmed := strings.TrimSpace(typed) + if strings.HasPrefix(trimmed, "{") || strings.HasPrefix(trimmed, "[") { + var decoded any + if json.Unmarshal([]byte(trimmed), &decoded) == nil { + collectMediaIDs(decoded, out) + } + } + } +} + +func mediaIDScalar(value any) string { + text, ok := value.(string) + if !ok { + return "" + } + return strings.Trim(strings.TrimSpace(text), `"'`) +} + +func uniqueMediaIDs(values []string) []string { + out := make([]string, 0, len(values)) + seen := map[string]bool{} + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" || seen[value] { + continue + } + seen[value] = true + out = append(out, value) + } + return out +} + +// UpdateTime reads an edited message's update time. Gateways sometimes echo +// createTime as updateTime even when the message was never edited; omit that +// duplicate so Agents do not infer a nonexistent edit. +func UpdateTime(m map[string]any) any { + createTime := CreateTime(m) + for _, key := range []string{"updateTime", "modifiedTime", "gmtModified", "editTime"} { + if v, ok := m[key]; ok && v != nil { + if createTime != nil && reflect.DeepEqual(v, createTime) { + return nil + } + return v + } + } + return nil +} + +// Reactions normalises DingTalk's inline emotionReplyList into one compact, +// Agent-friendly block. Unlike Lark, DingTalk already returns these reactions +// with message-list responses, so this projection performs no extra network +// request. +// +// Output shape: +// +// "reactions": { +// "counts": [{"emoji": "赞", "count": 3}], +// "details": [{"emoji": "赞", "replyUsers": ["..."]}] +// } +func Reactions(m map[string]any) map[string]any { + var raw []any + for _, key := range []string{"emotionReplyList", "reactionList", "reactions"} { + switch value := m[key].(type) { + case []any: + raw = value + case []map[string]any: + raw = make([]any, 0, len(value)) + for _, item := range value { + raw = append(raw, item) + } + } + if len(raw) > 0 { + break + } + } + if len(raw) == 0 { + return nil + } + + counts := make([]map[string]any, 0, len(raw)) + details := make([]map[string]any, 0, len(raw)) + for _, item := range raw { + entry, ok := item.(map[string]any) + if !ok { + continue + } + emoji := firstReactionValue(entry, "emoji", "emojiName", "reactionType", "emotionName", "text") + users := reactionUsers(entry) + count := reactionCount(entry, len(users)) + if emoji == nil && count == 0 && len(users) == 0 { + continue + } + + countRow := map[string]any{"count": count} + detailRow := map[string]any{} + if emoji != nil { + countRow["emoji"] = emoji + detailRow["emoji"] = emoji + } + if len(users) > 0 { + detailRow["replyUsers"] = users + } + counts = append(counts, countRow) + if len(detailRow) > 0 { + details = append(details, detailRow) + } + } + if len(counts) == 0 && len(details) == 0 { + return nil + } + out := map[string]any{} + if len(counts) > 0 { + out["counts"] = counts + } + if len(details) > 0 { + out["details"] = details + } + return out +} + +func firstReactionValue(m map[string]any, keys ...string) any { + for _, key := range keys { + value, ok := m[key] + if !ok || value == nil { + continue + } + if text, isString := value.(string); isString && strings.TrimSpace(text) == "" { + continue + } + return value + } + return nil +} + +func reactionUsers(m map[string]any) []any { + for _, key := range []string{"replyUsers", "users", "operators"} { + switch value := m[key].(type) { + case []any: + return value + case []string: + out := make([]any, 0, len(value)) + for _, item := range value { + out = append(out, item) + } + return out + } + } + return nil +} + +func reactionCount(m map[string]any, fallback int) any { + for _, key := range []string{"count", "replyCount", "reactionCount"} { + switch value := m[key].(type) { + case int, int32, int64, float32, float64, json.Number: + return value + case string: + if strings.TrimSpace(value) != "" { + return value + } + } + } + return fallback +} + +// ApplyPagination carries lower-layer completeness facts into a projected +// Shortcut payload. It intentionally preserves cursor values only in the +// command output (where callers need them to continue); audit reports redact +// those values and retain only their presence. +func ApplyPagination(payload, data map[string]any) { + for key, value := range Pagination(data) { + payload[key] = value + } +} + +// ApplyMessagePagination publishes message-list completeness without claiming +// the lower response's nextCursor is a valid CLI input. DingTalk's executable +// message-list contract paginates with the boundary message createTime, so the +// resume object uses exactly that accepted parameter. +func ApplyMessagePagination(payload, data map[string]any, messages []map[string]any, direction string) { + page := Pagination(data) + if len(page) == 0 { + return + } + if value, ok := page["hasMore"]; ok { + payload["hasMore"] = value + } + if value, ok := page["complete"]; ok { + payload["complete"] = value + } + hasMore, _ := page["hasMore"].(bool) + if !hasMore || len(messages) == 0 { + return + } + boundary := CreateTime(messages[len(messages)-1]) + if boundary == nil { + return + } + next := map[string]any{"time": boundary} + if strings.TrimSpace(direction) != "" { + next["direction"] = direction + } + payload["nextPage"] = next +} + +// Pagination extracts hasMore/nextCursor from the response root or a common +// result/data envelope. When hasMore is present it also emits the explicit +// inverse "complete", making truncation hard for an Agent to overlook. +func Pagination(data map[string]any) map[string]any { + if data == nil { + return nil + } + scopes := []map[string]any{data} + for _, key := range []string{"result", "data"} { + if inner, ok := data[key].(map[string]any); ok { + scopes = append(scopes, inner) + } + } + for _, scope := range scopes { + out := map[string]any{} + for _, key := range []string{"hasMore", "has_more"} { + if value, ok := scope[key].(bool); ok { + out["hasMore"] = value + out["complete"] = !value + break + } + } + for _, key := range []string{"nextCursor", "next_cursor", "nextToken", "next_token", "pageToken", "page_token"} { + if value, ok := scope[key]; ok && paginationValuePresent(value) { + out["nextCursor"] = value + break + } + } + if len(out) > 0 { + return out + } + } + return nil +} + +func paginationValuePresent(value any) bool { + switch typed := value.(type) { + case nil: + return false + case string: + return strings.TrimSpace(typed) != "" && strings.TrimSpace(typed) != "0" + case int: + return typed != 0 + case int64: + return typed != 0 + case float64: + return typed != 0 + default: + return true + } +} + // Forwarded projects the nested messages of a forwarded chat record. The caller // supplies its own per-message projection so each command keeps its own row // shape; project is applied recursively, so multi-level forwards expand too. diff --git a/internal/shortcut/chatmsg/chatmsg_test.go b/internal/shortcut/chatmsg/chatmsg_test.go index b9f0c618..68685fa7 100644 --- a/internal/shortcut/chatmsg/chatmsg_test.go +++ b/internal/shortcut/chatmsg/chatmsg_test.go @@ -145,6 +145,187 @@ func TestCreateTime(t *testing.T) { } } +func TestStableMessageIdentity(t *testing.T) { + message := map[string]any{ + "openMessageId": "msg-1", + "openConversationId": "cid-1", + "openConvThreadId": "thread-1", + "msgType": "text", + } + if got := MessageID(message); got != "msg-1" { + t.Errorf("MessageID = %v", got) + } + if got := ConversationID(message); got != "cid-1" { + t.Errorf("ConversationID = %v", got) + } + if got := ThreadID(message); got != "thread-1" { + t.Errorf("ThreadID = %v", got) + } + if got := MessageType(message); got != "text" { + t.Errorf("MessageType = %v", got) + } +} + +func TestQuotedMessageIsBoundedAndSemantic(t *testing.T) { + got := QuotedMessage(map[string]any{ + "quotedMessage": map[string]any{ + "openMessageId": "quoted-1", + "openConversationId": "cid-1", + "openConvThreadId": "thread-1", + "sender": "Alice", + "content": "原消息", + "createTime": "2026-07-28 10:00:00", + "quotedMessage": map[string]any{"openMessageId": "nested-must-not-expand"}, + }, + }) + if got["messageId"] != "quoted-1" || got["sender"] != "Alice" || got["text"] != "原消息" { + t.Fatalf("quoted message = %#v", got) + } + if got["threadId"] != "thread-1" { + t.Fatalf("quoted thread identity = %#v", got) + } + if _, recursive := got["quotedMessage"]; recursive { + t.Fatalf("quoted message expanded recursively: %#v", got) + } +} + +func TestUpdateTimeOmitsUneditedEcho(t *testing.T) { + if got := UpdateTime(map[string]any{ + "createTime": "2026-07-19 13:37:03", + "updateTime": "2026-07-19 13:37:03", + }); got != nil { + t.Errorf("UpdateTime echoed create time = %v, want nil", got) + } + if got := UpdateTime(map[string]any{ + "createTime": "2026-07-19 13:37:03", + "updateTime": "2026-07-19 14:00:00", + }); got != "2026-07-19 14:00:00" { + t.Errorf("UpdateTime edited = %v", got) + } +} + +func TestReactionsNormalizesEmotionReplyList(t *testing.T) { + got := Reactions(map[string]any{ + "emotionReplyList": []any{ + map[string]any{ + "emoji": "赞", + "replyUsers": []any{"user-a", "user-b"}, + }, + map[string]any{ + "emotionName": "收到", + "replyCount": float64(3), + }, + }, + }) + counts, ok := got["counts"].([]map[string]any) + if !ok || len(counts) != 2 { + t.Fatalf("reaction counts = %#v", got["counts"]) + } + if counts[0]["emoji"] != "赞" || counts[0]["count"] != 2 { + t.Errorf("first reaction count = %#v", counts[0]) + } + if counts[1]["emoji"] != "收到" || counts[1]["count"] != float64(3) { + t.Errorf("second reaction count = %#v", counts[1]) + } + details, ok := got["details"].([]map[string]any) + if !ok || len(details) != 2 { + t.Fatalf("reaction details = %#v", got["details"]) + } + users, ok := details[0]["replyUsers"].([]any) + if !ok || len(users) != 2 { + t.Errorf("reaction users = %#v", details[0]["replyUsers"]) + } + if got := Reactions(map[string]any{}); got != nil { + t.Errorf("empty reactions = %#v, want nil", got) + } +} + +func TestApplyPaginationReadsNestedEnvelope(t *testing.T) { + payload := map[string]any{"count": 98} + ApplyPagination(payload, map[string]any{ + "result": map[string]any{ + "hasMore": true, + "nextCursor": "cursor-redacted-in-audits", + }, + }) + if payload["hasMore"] != true || payload["complete"] != false { + t.Errorf("pagination completeness = %#v", payload) + } + if payload["nextCursor"] != "cursor-redacted-in-audits" { + t.Errorf("nextCursor = %#v", payload["nextCursor"]) + } + + payload = map[string]any{} + ApplyPagination(payload, map[string]any{ + "data": map[string]any{"has_more": false}, + }) + if payload["hasMore"] != false || payload["complete"] != true { + t.Errorf("completed pagination = %#v", payload) + } +} + +func TestApplyMessagePaginationUsesExecutableTimeBoundary(t *testing.T) { + payload := map[string]any{} + ApplyMessagePagination(payload, map[string]any{ + "result": map[string]any{ + "hasMore": true, + "nextCursor": "not-a-message-list-cli-flag", + }, + }, []map[string]any{ + {"createTime": "2026-07-28 10:00:00"}, + {"createTime": "2026-07-28 09:00:00"}, + }, "older") + if _, leaked := payload["nextCursor"]; leaked { + t.Fatalf("message pagination exposed unusable cursor: %#v", payload) + } + next, ok := payload["nextPage"].(map[string]any) + if !ok || next["time"] != "2026-07-28 09:00:00" || next["direction"] != "older" { + t.Fatalf("message nextPage = %#v", payload["nextPage"]) + } +} + +func TestResourcesBuildsActionableDownloadReferences(t *testing.T) { + resources := Resources(map[string]any{ + "openMessageId": "msg-1", + "openConversationId": "cid-1", + "content": `图片 [图片消息](mediaId=@image-a)`, + "attachments": []any{ + map[string]any{"mediaId": "@image-b"}, + map[string]any{"content": `{"mediaId":"@image-a"}`}, + }, + }) + if len(resources) != 2 { + t.Fatalf("resources = %#v", resources) + } + first := resources[0] + if first["resourceId"] != "@image-a" || first["type"] != "mediaId" { + t.Fatalf("first resource = %#v", first) + } + download, _ := first["download"].(map[string]any) + arguments, _ := download["arguments"].(map[string]any) + if download["ready"] != true || + arguments["message-id"] != "msg-1" || + arguments["open-conversation-id"] != "cid-1" || + arguments["resource-id"] != "@image-a" { + t.Fatalf("download = %#v", download) + } +} + +func TestResourcesReportsMissingDownloadContext(t *testing.T) { + resources := Resources(map[string]any{"content": `{"mediaId":"@image-a"}`}) + if len(resources) != 1 { + t.Fatalf("resources = %#v", resources) + } + download, _ := resources[0]["download"].(map[string]any) + if download["ready"] != false { + t.Fatalf("download = %#v", download) + } + missing, _ := download["missing"].([]string) + if len(missing) != 2 || missing[0] != "message-id" || missing[1] != "open-conversation-id" { + t.Fatalf("missing = %#v", missing) + } +} + func TestForwarded(t *testing.T) { var project func(m map[string]any) map[string]any project = func(m map[string]any) map[string]any { diff --git a/internal/shortcut/chatmsg/coverage_completion_test.go b/internal/shortcut/chatmsg/coverage_completion_test.go new file mode 100644 index 00000000..52eda4de --- /dev/null +++ b/internal/shortcut/chatmsg/coverage_completion_test.go @@ -0,0 +1,119 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package chatmsg + +import ( + "encoding/json" + "reflect" + "testing" +) + +func TestCrossPlatformCoverageQuotedResourcesAndScalarVariants(t *testing.T) { + quoted := QuotedMessage(map[string]any{ + "quotedMessage": map[string]any{ + "openMessageId": "msg-quoted", + "msgType": "file", + "content": `{"mediaId":"@quoted-file"}`, + }, + }) + if quoted["messageType"] != "file" || len(quoted["resourceRefs"].([]map[string]any)) != 1 { + t.Fatalf("quoted projection = %#v", quoted) + } + if firstMessageValue(map[string]any{"a": " ", "b": "value"}, "a", "b") != "value" { + t.Fatal("blank string did not fall through") + } + if Resources(nil) != nil { + t.Fatal("nil message returned resources") + } + resources := Resources(map[string]any{ + "attachments": []map[string]any{ + {"resourceType": "mediaId", "resourceId": "@file-a"}, + {"mediaId": 42}, + }, + }) + if len(resources) != 1 || resources[0]["resourceId"] != "@file-a" { + t.Fatalf("resources = %#v", resources) + } + if got := mediaIDScalar(42); got != "" { + t.Fatalf("non-string media ID = %q", got) + } +} + +func TestCrossPlatformCoverageReactionShapeVariants(t *testing.T) { + got := Reactions(map[string]any{ + "reactions": []map[string]any{ + {"emoji": " ", "count": 0}, + {"emojiName": "赞", "replyUsers": []string{"u1"}, "replyCount": "1"}, + {"reactionType": "笑", "operators": []any{"u2"}, "reactionCount": json.Number("2")}, + }, + }) + counts := got["counts"].([]map[string]any) + details := got["details"].([]map[string]any) + if len(counts) != 2 || len(details) != 2 { + t.Fatalf("reactions = %#v", got) + } + if firstReactionValue(map[string]any{"a": " ", "b": "ok"}, "a", "b") != "ok" { + t.Fatal("blank reaction value did not fall through") + } + if users := reactionUsers(map[string]any{"users": []string{"u1", "u2"}}); !reflect.DeepEqual(users, []any{"u1", "u2"}) { + t.Fatalf("users = %#v", users) + } + for _, tc := range []struct { + value any + want any + }{ + {int(1), int(1)}, + {int32(2), int32(2)}, + {int64(3), int64(3)}, + {float32(4), float32(4)}, + {float64(5), float64(5)}, + {json.Number("6"), json.Number("6")}, + {"7", "7"}, + } { + if got := reactionCount(map[string]any{"count": tc.value}, 9); !reflect.DeepEqual(got, tc.want) { + t.Errorf("reactionCount(%T) = %#v, want %#v", tc.value, got, tc.want) + } + } + if got := Reactions(map[string]any{"reactions": []any{"invalid", map[string]any{}}}); got != nil { + t.Fatalf("empty reaction rows = %#v", got) + } +} + +func TestCrossPlatformCoveragePaginationVariants(t *testing.T) { + payload := map[string]any{} + ApplyMessagePagination(payload, map[string]any{"hasMore": true}, []map[string]any{{}}, "older") + if _, ok := payload["nextPage"]; ok { + t.Fatalf("missing time produced next page: %#v", payload) + } + if Pagination(nil) != nil { + t.Fatal("nil pagination was non-nil") + } + page := Pagination(map[string]any{"data": map[string]any{ + "has_more": true, + "next_token": int64(8), + }}) + if page["hasMore"] != true || page["nextCursor"] != int64(8) { + t.Fatalf("page = %#v", page) + } + for _, tc := range []struct { + value any + want bool + }{ + {nil, false}, + {" ", false}, + {"0", false}, + {"cursor", true}, + {int(0), false}, + {int(1), true}, + {int64(0), false}, + {int64(1), true}, + {float64(0), false}, + {float64(1), true}, + {true, true}, + } { + if got := paginationValuePresent(tc.value); got != tc.want { + t.Errorf("paginationValuePresent(%#v) = %v, want %v", tc.value, got, tc.want) + } + } +} diff --git a/internal/shortcut/public_catalog.go b/internal/shortcut/public_catalog.go index 2add27d5..7af4f2fc 100644 --- a/internal/shortcut/public_catalog.go +++ b/internal/shortcut/public_catalog.go @@ -10,6 +10,9 @@ func publicCatalogKey(service, command string) string { } func applyPublicCatalog(s Shortcut) Shortcut { + if reviewed, ok := applyReviewedSemanticCatalog(s); ok { + return reviewed + } if len(publicShortcutCatalog) == 0 { return s } @@ -22,6 +25,9 @@ func applyPublicCatalog(s Shortcut) Shortcut { // InPublicCatalog reports whether a shortcut belongs to the generated public // shortcut surface used by help, listing, and skill generation. func InPublicCatalog(service, command string) bool { + if reviewed, ok := reviewedSemanticCatalog[publicCatalogKey(service, command)]; ok { + return reviewed.Public && reviewed.Availability == AvailabilityAvailable + } _, ok := publicShortcutCatalog[publicCatalogKey(service, command)] return ok } diff --git a/internal/shortcut/public_catalog_generated.go b/internal/shortcut/public_catalog_generated.go index 25d0ddcc..8f190845 100644 --- a/internal/shortcut/public_catalog_generated.go +++ b/internal/shortcut/public_catalog_generated.go @@ -78,43 +78,98 @@ func generatedPublicShortcutCatalog() map[string]struct{} { "chat\u0000+bot-find": {}, "chat\u0000+bot-search": {}, "chat\u0000+broadcast": {}, + "chat\u0000+category-add-conversation": {}, "chat\u0000+category-create": {}, "chat\u0000+category-delete": {}, "chat\u0000+category-list": {}, + "chat\u0000+category-list-conversations": {}, + "chat\u0000+category-remove-conversation": {}, "chat\u0000+category-rename": {}, + "chat\u0000+chat-add-bot": {}, + "chat\u0000+chat-audit-join": {}, "chat\u0000+chat-bots": {}, + "chat\u0000+chat-create": {}, "chat\u0000+chat-dismiss": {}, + "chat\u0000+chat-get-by-id": {}, "chat\u0000+chat-invite-url": {}, "chat\u0000+chat-list-all": {}, "chat\u0000+chat-list-join-requests": {}, "chat\u0000+chat-list-mine": {}, + "chat\u0000+chat-members-get": {}, + "chat\u0000+chat-members-list": {}, + "chat\u0000+chat-messages": {}, "chat\u0000+chat-mute": {}, + "chat\u0000+chat-mute-member": {}, + "chat\u0000+chat-quit": {}, + "chat\u0000+chat-remove-bot": {}, "chat\u0000+chat-role-add": {}, "chat\u0000+chat-role-list": {}, "chat\u0000+chat-role-query-user": {}, + "chat\u0000+chat-role-remove": {}, + "chat\u0000+chat-role-remove-user": {}, "chat\u0000+chat-role-set-user": {}, "chat\u0000+chat-role-update": {}, "chat\u0000+chat-search": {}, "chat\u0000+chat-set-admin": {}, "chat\u0000+chat-set-history": {}, + "chat\u0000+chat-transfer-owner": {}, + "chat\u0000+chat-update": {}, "chat\u0000+chat-update-alias": {}, + "chat\u0000+chat-update-icon": {}, "chat\u0000+chat-update-nick": {}, + "chat\u0000+chat-update-settings": {}, "chat\u0000+conversation-clear-all-red-point": {}, + "chat\u0000+conversation-clear-messages": {}, + "chat\u0000+conversation-clear-red-point": {}, + "chat\u0000+conversation-hide": {}, "chat\u0000+conversation-info": {}, "chat\u0000+conversation-list": {}, "chat\u0000+conversation-list-top": {}, + "chat\u0000+conversation-mark-read": {}, + "chat\u0000+conversation-mark-unread": {}, + "chat\u0000+conversation-mute": {}, + "chat\u0000+conversation-set-top": {}, "chat\u0000+dm": {}, + "chat\u0000+feed-group-query-item": {}, + "chat\u0000+flag-cancel": {}, + "chat\u0000+flag-create": {}, + "chat\u0000+flag-list": {}, "chat\u0000+group-members": {}, + "chat\u0000+messages-add-emoji": {}, + "chat\u0000+messages-add-text-emotion": {}, + "chat\u0000+messages-batch-recall-by-bot": {}, + "chat\u0000+messages-batch-send-by-bot": {}, + "chat\u0000+messages-combine-forward": {}, + "chat\u0000+messages-create-text-emotion": {}, + "chat\u0000+messages-forward": {}, + "chat\u0000+messages-forward-topic": {}, + "chat\u0000+messages-list": {}, "chat\u0000+messages-list-direct": {}, "chat\u0000+messages-list-pin": {}, "chat\u0000+messages-list-unread-conversations": {}, "chat\u0000+messages-mget": {}, "chat\u0000+messages-query-send-status": {}, "chat\u0000+messages-read-status": {}, + "chat\u0000+messages-recall": {}, + "chat\u0000+messages-recall-by-bot": {}, + "chat\u0000+messages-remove-emoji": {}, + "chat\u0000+messages-remove-text-emotion": {}, + "chat\u0000+messages-reply": {}, + "chat\u0000+messages-resource-download": {}, + "chat\u0000+messages-resource-url": {}, + "chat\u0000+messages-send": {}, + "chat\u0000+messages-send-by-bot": {}, "chat\u0000+messages-send-by-webhook": {}, + "chat\u0000+messages-send-card": {}, + "chat\u0000+messages-set-pin": {}, + "chat\u0000+messages-set-top": {}, + "chat\u0000+messages-unset-pin": {}, + "chat\u0000+messages-unset-top": {}, "chat\u0000+messages-update-card": {}, "chat\u0000+my-groups": {}, + "chat\u0000+search-msg": {}, "chat\u0000+send-to-group": {}, + "chat\u0000+thread-replies": {}, "chat\u0000+unread-chats": {}, "contact\u0000+by-mobile": {}, "contact\u0000+dept-members": {}, diff --git a/internal/shortcut/runner.go b/internal/shortcut/runner.go index b5d3baf4..93ab8c81 100644 --- a/internal/shortcut/runner.go +++ b/internal/shortcut/runner.go @@ -142,11 +142,15 @@ func (rt *RuntimeContext) CallMCP(tool string, params map[string]any) error { // The product is explicit (not the shortcut's own) because smart shortcuts // routinely cross services — e.g. resolve a name via `contact` then act via // `chat`. Reads run even under --dry-run so a preview can still resolve inputs. -// Write tools that need parsed responses must use CallMCPWriteData instead; as -// a backstop, obvious write-like tool names are rejected here under --dry-run. +// Write tools that need parsed responses must use CallMCPWriteData instead. +// Under --dry-run this path fails closed unless the tool name belongs to the +// narrow read-only naming contract used by the current MCP registry. func (rt *RuntimeContext) CallMCPData(product, tool string, params map[string]any) (map[string]any, error) { - if rt.DryRun() && looksWriteTool(tool) { - return nil, dryRunWriteError(product, tool) + if rt.DryRun() { + if !looksReadTool(tool) { + return nil, dryRunWriteError(product, tool) + } + return rt.callMCPReadData(product, tool, params) } return rt.callMCPData(product, tool, params) } @@ -163,17 +167,14 @@ func (rt *RuntimeContext) CallMCPWriteData(product, tool string, params map[stri func dryRunWriteError(product, tool string) error { return apperrors.NewValidation(fmt.Sprintf( - "--dry-run 下禁止执行写操作 %s/%s;请在 shortcut 中输出 preview 后返回", product, tool)) + "--dry-run 下禁止执行未明确分类为只读的工具 %s/%s;请在 shortcut 中输出 preview 后返回", + product, tool)) } -func looksWriteTool(tool string) bool { +func looksReadTool(tool string) bool { tool = strings.TrimSpace(strings.ToLower(tool)) for _, prefix := range []string{ - "add_", "append_", "approve_", "archive_", "cancel_", "create_", - "delete_", "disable_", "enable_", "grant_", "import_", "insert_", - "invite_", "move_", "publish_", "reject_", "remove_", "replace_", - "respond", "revoke_", "send_", "set_", "submit_", "update_", - "upload_", "write_", + "get_", "list_", "query_", "search_", "unread_", } { if strings.HasPrefix(tool, prefix) { return true @@ -200,6 +201,24 @@ func (rt *RuntimeContext) callMCPData(product, tool string, params map[string]an return out, nil } +func (rt *RuntimeContext) callMCPReadData(product, tool string, params map[string]any) (map[string]any, error) { + if params == nil { + params = map[string]any{} + } + text, err := helpers.CallMCPReadToolTextOnServer(product, tool, params) + if err != nil { + return nil, err + } + if strings.TrimSpace(text) == "" { + return map[string]any{}, nil + } + var out map[string]any + if err := json.Unmarshal([]byte(text), &out); err != nil { + return nil, apperrors.NewInternal(fmt.Sprintf("解析 %s 返回失败: %v", tool, err)) + } + return out, nil +} + // Output prints a (typically reshaped/projected) payload honouring the root // --format/--jq/--fields flags. Multi-step shortcuts use it to emit a clean, // composed result instead of the raw MCP response — the output-projection diff --git a/internal/shortcut/semantic_catalog.go b/internal/shortcut/semantic_catalog.go new file mode 100644 index 00000000..a3800173 --- /dev/null +++ b/internal/shortcut/semantic_catalog.go @@ -0,0 +1,96 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package shortcut + +import ( + _ "embed" + "encoding/json" + "fmt" + "strings" +) + +//go:embed semantic_catalog.json +var semanticCatalogJSON []byte + +type semanticCatalogFile struct { + Version int `json:"version"` + Service string `json:"service"` + Availability Availability `json:"default_availability"` + Shortcuts map[string]semanticCatalogRecord `json:"shortcuts"` +} + +type semanticCatalogRecord struct { + Disposition SemanticDisposition `json:"disposition"` + SemanticDelta string `json:"semantic_delta"` + Risk Risk `json:"risk"` + Availability Availability `json:"availability,omitempty"` + Primary string `json:"primary,omitempty"` + Public bool `json:"public"` + Reviewed bool `json:"reviewed"` +} + +var reviewedSemanticCatalog = mustLoadSemanticCatalog() + +func mustLoadSemanticCatalog() map[string]semanticCatalogRecord { + var source semanticCatalogFile + if err := json.Unmarshal(semanticCatalogJSON, &source); err != nil { + panic(fmt.Sprintf("invalid shortcut semantic catalog: %v", err)) + } + if source.Version != 1 || strings.TrimSpace(source.Service) == "" { + panic("invalid shortcut semantic catalog header") + } + out := make(map[string]semanticCatalogRecord, len(source.Shortcuts)) + for command, record := range source.Shortcuts { + if !strings.HasPrefix(command, "+") { + panic(fmt.Sprintf("semantic catalog command %q lacks + prefix", command)) + } + if !record.Reviewed || strings.TrimSpace(record.SemanticDelta) == "" { + panic(fmt.Sprintf("semantic catalog command %q is not reviewed", command)) + } + switch record.Disposition { + case DispositionPrimarySmart, DispositionSemanticAdapter, DispositionSchemaLeaf, DispositionAliasInternal: + default: + panic(fmt.Sprintf("semantic catalog command %q has invalid disposition %q", command, record.Disposition)) + } + switch record.Risk { + case RiskRead, RiskWrite, RiskHighWrite: + default: + panic(fmt.Sprintf("semantic catalog command %q has invalid risk %q", command, record.Risk)) + } + if record.Availability == "" { + record.Availability = source.Availability + } + switch record.Availability { + case AvailabilityAvailable, AvailabilityUnavailable, AvailabilityDeprecated: + default: + panic(fmt.Sprintf("semantic catalog command %q has invalid availability %q", command, record.Availability)) + } + if record.Disposition == DispositionAliasInternal && strings.TrimSpace(record.Primary) == "" { + panic(fmt.Sprintf("semantic alias %q must name its primary command", command)) + } + // Public visibility is an explicit reviewed product decision. Disposition + // remains routing metadata: a reviewed, available Schema projection or + // compatibility alias can still be intentionally exposed as a Shortcut. + if record.Public && record.Availability != AvailabilityAvailable { + panic(fmt.Sprintf("semantic catalog command %q cannot be public with availability %q", + command, record.Availability)) + } + out[publicCatalogKey(source.Service, command)] = record + } + return out +} + +func applyReviewedSemanticCatalog(s Shortcut) (Shortcut, bool) { + record, ok := reviewedSemanticCatalog[publicCatalogKey(s.Service, s.Command)] + if !ok { + return s, false + } + s.Disposition = record.Disposition + s.SemanticDelta = record.SemanticDelta + s.Availability = record.Availability + s.PrimaryCommand = record.Primary + s.SemanticReviewed = record.Reviewed + s.Hidden = !record.Public + return s, true +} diff --git a/internal/shortcut/semantic_catalog.json b/internal/shortcut/semantic_catalog.json new file mode 100644 index 00000000..6a4f9bfe --- /dev/null +++ b/internal/shortcut/semantic_catalog.json @@ -0,0 +1,707 @@ +{ + "version": 1, + "service": "chat", + "default_availability": "available", + "shortcuts": { + "+bot-search": { + "disposition": "schema_leaf", + "semantic_delta": "仅绑定并投影当前用户创建的机器人搜索;无跨工具编排,默认由 bot Schema leaf 承担。", + "risk": "read", + "public": true, + "reviewed": true + }, + "+bot-find": { + "disposition": "schema_leaf", + "semantic_delta": "仅绑定并投影全部可用机器人搜索;底层 leaf 已接受关键词和分页参数。", + "risk": "read", + "public": true, + "reviewed": true + }, + "+flag-create": { + "disposition": "semantic_adapter", + "semantic_delta": "把 Lark Flag 意图忠实映射到 DingTalk message favorite,并为同会话最多 10 条消息提供逐项成功/失败 ledger;不改写 Pin、会话置顶或 feed-layer thread flag。", + "risk": "write", + "public": true, + "reviewed": true + }, + "+flag-cancel": { + "disposition": "semantic_adapter", + "semantic_delta": "批量取消当前用户的 DingTalk message favorite,并逐项报告失败;不伪造 Lark 的双层 best-effort 取消。", + "risk": "write", + "public": true, + "reviewed": true + }, + "+flag-list": { + "disposition": "schema_leaf", + "semantic_delta": "先创建真实 message favorite,再分别通过 native leaf 与 Shortcut 读取非空列表;两层返回相同收藏项且包含新建 Fixture,已完成非空结果证明。", + "risk": "read", + "public": true, + "reviewed": true + }, + "+feed-group-query-item": { + "disposition": "semantic_adapter", + "semantic_delta": "读取 DingTalk 会话分组后按多个 openConversationId 本地精确过滤;只有完整响应才判定 notFound,若下层仍有后续页但无可执行游标则返回 unresolved 与失败 ledger,且不伪造 Lark deleted_items。", + "risk": "read", + "public": true, + "reviewed": true + }, + "+conversation-info": { + "disposition": "semantic_adapter", + "semantic_delta": "统一群聊 openConversationId 与单聊 openDingTalkId 两种目标,并路由到对应读取工具。", + "risk": "read", + "public": true, + "reviewed": true + }, + "+conversation-set-top": { + "disposition": "semantic_adapter", + "semantic_delta": "把会话置顶扩展为最多 10 个目标的逐项写入,统一输出 requested/succeeded/failed ledger。", + "risk": "write", + "public": true, + "reviewed": true + }, + "+conversation-mute": { + "disposition": "schema_leaf", + "semantic_delta": "会话免打扰是一对一状态写入;无名称解析、编排或恢复增益。", + "risk": "write", + "public": true, + "reviewed": true + }, + "+conversation-mute-at-all": { + "disposition": "schema_leaf", + "semantic_delta": "在真实双成员临时群中分别调用 native 与 Shortcut,两层均由 im/update_at_all_notification_off 返回 server_error_code=1002(系统繁忙);路由和参数一致,但下层未恢复前继续隐藏。", + "risk": "write", + "availability": "unavailable", + "public": false, + "reviewed": true + }, + "+conversation-mute-red-envelope": { + "disposition": "schema_leaf", + "semantic_delta": "在真实双成员临时群中分别调用 native 与 Shortcut,两层均由 im/update_red_env_notification_off 返回 server_error_code=1002(系统繁忙);路由和参数一致,但下层未恢复前继续隐藏。", + "risk": "write", + "availability": "unavailable", + "public": false, + "reviewed": true + }, + "+conversation-mark-unread": { + "disposition": "schema_leaf", + "semantic_delta": "标记未读是一对一会话操作;Shortcut 未增加额外语义编排。", + "risk": "write", + "public": true, + "reviewed": true + }, + "+conversation-clear-red-point": { + "disposition": "schema_leaf", + "semantic_delta": "清除单会话红点是一对一操作;默认由 Runtime Schema leaf 精确选择。", + "risk": "write", + "public": true, + "reviewed": true + }, + "+conversation-clear-all-red-point": { + "disposition": "schema_leaf", + "semantic_delta": "全局清红点是一对一批量状态写入;高影响意图应直接选择带安全事实的 leaf。", + "risk": "write", + "public": true, + "reviewed": true + }, + "+conversation-list": { + "disposition": "schema_leaf", + "semantic_delta": "会话分页列表只做字段投影;底层 leaf 已提供 limit/cursor/exclude-muted。", + "risk": "read", + "public": true, + "reviewed": true + }, + "+conversation-list-top": { + "disposition": "semantic_adapter", + "semantic_delta": "将下层 singleChat 规范化为 Agent 友好的 group/direct 类型,并支持 --type all|group|direct 本地过滤。", + "risk": "read", + "public": true, + "reviewed": true + }, + "+conversation-clear-messages": { + "disposition": "schema_leaf", + "semantic_delta": "清空聊天记录不可逆且是一对一操作;应由 Runtime Schema leaf 的高风险门禁承载。", + "risk": "high-risk-write", + "public": true, + "reviewed": true + }, + "+conversation-mark-read": { + "disposition": "schema_leaf", + "semantic_delta": "消息已读标记是一对一写入;无额外解析或编排。", + "risk": "write", + "public": true, + "reviewed": true + }, + "+conversation-hide": { + "disposition": "schema_leaf", + "semantic_delta": "隐藏会话是一对一写入;默认由 Runtime Schema leaf 承担。", + "risk": "write", + "public": true, + "reviewed": true + }, + "+category-list": { + "disposition": "schema_leaf", + "semantic_delta": "会话分组列表只做字段投影;底层 leaf 已可直接读取。", + "risk": "read", + "public": true, + "reviewed": true + }, + "+category-list-conversations": { + "disposition": "schema_leaf", + "semantic_delta": "按分组列会话是一对一分页读取;无跨工具编排。", + "risk": "read", + "public": true, + "reviewed": true + }, + "+category-create": { + "disposition": "schema_leaf", + "semantic_delta": "创建会话分组是一对一写入;默认由 Runtime Schema leaf 承担。", + "risk": "write", + "public": true, + "reviewed": true + }, + "+category-delete": { + "disposition": "schema_leaf", + "semantic_delta": "删除会话分组是一对一高风险写入;应直接使用带删除确认的 leaf。", + "risk": "high-risk-write", + "public": true, + "reviewed": true + }, + "+category-rename": { + "disposition": "schema_leaf", + "semantic_delta": "重命名会话分组是一对一写入;无额外语义增益。", + "risk": "write", + "public": true, + "reviewed": true + }, + "+category-add-conversation": { + "disposition": "schema_leaf", + "semantic_delta": "将会话加入分组是一对一关系写入;默认由 leaf 承担。", + "risk": "write", + "public": true, + "reviewed": true + }, + "+category-remove-conversation": { + "disposition": "schema_leaf", + "semantic_delta": "将会话移出分组是一对一关系写入;默认由 leaf 承担。", + "risk": "write", + "public": true, + "reviewed": true + }, + "+chat-search": { + "disposition": "schema_leaf", + "semantic_delta": "群关键词搜索只做字段投影;底层 search_groups leaf 已提供分页。", + "risk": "read", + "public": true, + "reviewed": true + }, + "+chat-create": { + "disposition": "semantic_adapter", + "semantic_delta": "自动解析当前用户并加入初始成员以满足群主约束,规范化创建结果,同时明确不支持 Lark-only owner/description/bot/visibility 语义。", + "risk": "write", + "public": true, + "reviewed": true + }, + "+chat-update": { + "disposition": "schema_leaf", + "semantic_delta": "只发布 DWS 与 lark-cli 共有的群名称更新子集;不接受或静默忽略 description。", + "risk": "write", + "public": true, + "reviewed": true + }, + "+chat-members-get": { + "disposition": "schema_leaf", + "semantic_delta": "按成员 ID 批量取详情是一对一读取;默认由 Runtime Schema leaf 承担。", + "risk": "read", + "public": true, + "reviewed": true + }, + "+chat-transfer-owner": { + "disposition": "schema_leaf", + "semantic_delta": "转让群主是一对一高影响治理操作;应直接使用精确 leaf 与安全确认。", + "risk": "write", + "public": true, + "reviewed": true + }, + "+chat-invite-url": { + "disposition": "schema_leaf", + "semantic_delta": "群邀请链接是一对一读取;Shortcut 未增加生命周期或分享编排。", + "risk": "read", + "public": true, + "reviewed": true + }, + "+chat-quit": { + "disposition": "schema_leaf", + "semantic_delta": "退出群聊是一对一成员关系写入;默认由 Runtime Schema leaf 承担。", + "risk": "write", + "public": true, + "reviewed": true + }, + "+chat-update-icon": { + "disposition": "schema_leaf", + "semantic_delta": "更新群头像是一对一媒体 ID 写入;尚无本地媒体解析编排。", + "risk": "write", + "public": true, + "reviewed": true + }, + "+chat-update-settings": { + "disposition": "schema_leaf", + "semantic_delta": "群 settingKey/status 是精确原子设置;Shortcut 未增加语义枚举。", + "risk": "write", + "public": true, + "reviewed": true + }, + "+chat-dismiss": { + "disposition": "schema_leaf", + "semantic_delta": "解散群聊不可逆且是一对一操作;应由 Runtime Schema leaf 的高风险门禁承载。", + "risk": "high-risk-write", + "public": true, + "reviewed": true + }, + "+chat-set-history": { + "disposition": "schema_leaf", + "semantic_delta": "新成员历史可见范围是一对一群设置;无额外编排。", + "risk": "write", + "public": true, + "reviewed": true + }, + "+chat-update-nick": { + "disposition": "schema_leaf", + "semantic_delta": "更新本人群昵称是一对一写入;默认由 leaf 承担。", + "risk": "write", + "public": true, + "reviewed": true + }, + "+chat-update-alias": { + "disposition": "schema_leaf", + "semantic_delta": "更新本人群备注是一对一写入;默认由 leaf 承担。", + "risk": "write", + "public": true, + "reviewed": true + }, + "+chat-list-mine": { + "disposition": "schema_leaf", + "semantic_delta": "群主/管理员群列表只做字段投影;精确角色筛选由底层 leaf 提供。", + "risk": "read", + "public": true, + "reviewed": true + }, + "+chat-list-all": { + "disposition": "alias_internal", + "semantic_delta": "与 +my-groups 重复;保留旧命令兼容,默认入口使用带类型过滤和完整性语义的 +my-groups。", + "risk": "read", + "primary": "+my-groups", + "public": true, + "reviewed": true + }, + "+chat-list-join-requests": { + "disposition": "schema_leaf", + "semantic_delta": "入群申请列表是一对一分页读取;无额外审批编排。", + "risk": "read", + "public": true, + "reviewed": true + }, + "+chat-audit-join": { + "disposition": "schema_leaf", + "semantic_delta": "审批单条入群申请是一对一治理写入;应直接使用带动作枚举的 leaf。", + "risk": "write", + "public": true, + "reviewed": true + }, + "+chat-get-by-id": { + "disposition": "schema_leaf", + "semantic_delta": "按数字群号取群信息是一对一读取;与 openConversationId 不可互换。", + "risk": "read", + "public": true, + "reviewed": true + }, + "+chat-add-bot": { + "disposition": "schema_leaf", + "semantic_delta": "机器人入群是一对一治理写入;尚无按名称解析机器人编排。", + "risk": "write", + "public": true, + "reviewed": true + }, + "+chat-bots": { + "disposition": "schema_leaf", + "semantic_delta": "群机器人列表只做字段投影;完整成员视图使用 +chat-members-list。", + "risk": "read", + "public": true, + "reviewed": true + }, + "+chat-remove-bot": { + "disposition": "schema_leaf", + "semantic_delta": "移除群机器人是一对一高风险治理写入;默认由 leaf 承担。", + "risk": "high-risk-write", + "public": true, + "reviewed": true + }, + "+chat-set-admin": { + "disposition": "schema_leaf", + "semantic_delta": "管理员设置是一对一群治理操作;默认由 Runtime Schema leaf 承担。", + "risk": "write", + "public": true, + "reviewed": true + }, + "+chat-mute": { + "disposition": "schema_leaf", + "semantic_delta": "全员禁言是一对一群治理操作;默认由 leaf 精确路由。", + "risk": "write", + "public": true, + "reviewed": true + }, + "+chat-mute-member": { + "disposition": "schema_leaf", + "semantic_delta": "成员禁言是一对一群治理操作;默认由 leaf 精确路由。", + "risk": "write", + "public": true, + "reviewed": true + }, + "+chat-role-list": { + "disposition": "schema_leaf", + "semantic_delta": "群身份列表是一对一读取;无额外成员富化。", + "risk": "read", + "public": true, + "reviewed": true + }, + "+chat-role-add": { + "disposition": "schema_leaf", + "semantic_delta": "添加群身份是一对一写入;默认由 leaf 承担。", + "risk": "write", + "public": true, + "reviewed": true + }, + "+chat-role-update": { + "disposition": "schema_leaf", + "semantic_delta": "更新群身份是一对一写入;默认由 leaf 承担。", + "risk": "write", + "public": true, + "reviewed": true + }, + "+chat-role-remove": { + "disposition": "schema_leaf", + "semantic_delta": "删除群身份是一对一高风险写入;默认由带确认的 leaf 承担。", + "risk": "high-risk-write", + "public": true, + "reviewed": true + }, + "+chat-role-set-user": { + "disposition": "schema_leaf", + "semantic_delta": "覆盖成员全部群身份是一对一高影响写入;默认由 leaf 承担。", + "risk": "write", + "public": true, + "reviewed": true + }, + "+chat-role-remove-user": { + "disposition": "schema_leaf", + "semantic_delta": "移除成员指定群身份是一对一写入;默认由 leaf 承担。", + "risk": "write", + "public": true, + "reviewed": true + }, + "+chat-role-query-user": { + "disposition": "schema_leaf", + "semantic_delta": "查询成员群身份是一对一读取;无额外语义编排。", + "risk": "read", + "public": true, + "reviewed": true + }, + "+messages-send-by-bot": { + "disposition": "schema_leaf", + "semantic_delta": "机器人群发 Markdown 是一对一身份明确的发送;默认由 bot leaf 承担。", + "risk": "write", + "public": true, + "reviewed": true + }, + "+messages-batch-send-by-bot": { + "disposition": "schema_leaf", + "semantic_delta": "机器人批量单聊是一对一批量发送;默认由 bot leaf 承担。", + "risk": "write", + "public": true, + "reviewed": true + }, + "+messages-send-by-webhook": { + "disposition": "schema_leaf", + "semantic_delta": "Webhook 发送是一对一 token 目标写入;默认由 bot leaf 承担。", + "risk": "write", + "public": true, + "reviewed": true + }, + "+messages-reply": { + "disposition": "semantic_adapter", + "semantic_delta": "提供当前用户纯文本引用回复的稳定入口、幂等键和 AI 标记,并通过 mget 自动补全原发送者;明确不支持 bot、富媒体或 thread writer。", + "risk": "write", + "public": true, + "reviewed": true + }, + "+messages-send": { + "disposition": "semantic_adapter", + "semantic_delta": "用统一 identity 参数路由 current-user、bot、webhook 文本/Markdown 发送;按身份校验目标与凭据,幂等键只在真实支持的 user 分支开放,媒体上传仍诚实留在 native leaf。", + "risk": "write", + "public": true, + "reviewed": true + }, + "+messages-recall": { + "disposition": "schema_leaf", + "semantic_delta": "当前用户撤回是一对一消息操作;默认由 leaf 承担。", + "risk": "write", + "public": true, + "reviewed": true + }, + "+messages-recall-by-bot": { + "disposition": "schema_leaf", + "semantic_delta": "机器人群消息撤回是一对一身份专用操作;默认由 bot leaf 承担。", + "risk": "write", + "public": true, + "reviewed": true + }, + "+messages-batch-recall-by-bot": { + "disposition": "schema_leaf", + "semantic_delta": "机器人单聊批量撤回是一对一批量操作;默认由 bot leaf 承担。", + "risk": "write", + "public": true, + "reviewed": true + }, + "+messages-list": { + "disposition": "alias_internal", + "semantic_delta": "群历史读取与 +chat-messages 重复;保留旧参数兼容,默认入口使用统一群/单聊投影。", + "risk": "read", + "primary": "+chat-messages", + "public": true, + "reviewed": true + }, + "+messages-list-direct": { + "disposition": "alias_internal", + "semantic_delta": "单聊历史读取与 +chat-messages 重复;保留 user/openDingTalkId 兼容,默认入口使用统一历史投影。", + "risk": "read", + "primary": "+chat-messages", + "public": true, + "reviewed": true + }, + "+messages-list-unread-conversations": { + "disposition": "alias_internal", + "semantic_delta": "未读会话列表与 +unread-chats 重复;保留旧入口兼容,默认使用稳定投影入口。", + "risk": "read", + "primary": "+unread-chats", + "public": true, + "reviewed": true + }, + "+messages-mget": { + "disposition": "semantic_adapter", + "semantic_delta": "按最多 50 个消息 ID 批量读取并输出稳定消息投影、reaction 与 resourceRefs;可用 --download-resources 复用 HTTPS、相对路径、无覆盖和原子落盘防护,逐资源返回下载失败 ledger。", + "risk": "read", + "public": true, + "reviewed": true + }, + "+messages-query-send-status": { + "disposition": "schema_leaf", + "semantic_delta": "发送状态查询是一对一读取;等待/轮询送达尚未形成语义编排。", + "risk": "read", + "public": true, + "reviewed": true + }, + "+messages-read-status": { + "disposition": "schema_leaf", + "semantic_delta": "已读状态查询是一对一读取;默认由 Runtime Schema leaf 承担。", + "risk": "read", + "public": true, + "reviewed": true + }, + "+messages-add-emoji": { + "disposition": "schema_leaf", + "semantic_delta": "添加 emoji reaction 是精确原子操作;默认由 Runtime Schema leaf 承担。", + "risk": "write", + "public": true, + "reviewed": true + }, + "+messages-remove-emoji": { + "disposition": "schema_leaf", + "semantic_delta": "移除 emoji reaction 是精确原子操作;默认由 Runtime Schema leaf 承担。", + "risk": "write", + "public": true, + "reviewed": true + }, + "+messages-add-text-emotion": { + "disposition": "schema_leaf", + "semantic_delta": "添加文字表情是精确原子操作;默认由 Runtime Schema leaf 承担。", + "risk": "write", + "public": true, + "reviewed": true + }, + "+messages-remove-text-emotion": { + "disposition": "schema_leaf", + "semantic_delta": "移除文字表情是精确原子操作;默认由 Runtime Schema leaf 承担。", + "risk": "write", + "public": true, + "reviewed": true + }, + "+messages-create-text-emotion": { + "disposition": "schema_leaf", + "semantic_delta": "创建文字表情模板是一对一写入;默认由 Runtime Schema leaf 承担。", + "risk": "write", + "public": true, + "reviewed": true + }, + "+messages-send-card": { + "disposition": "schema_leaf", + "semantic_delta": "创建流式卡片是一对一写入;完整卡片生命周期需由 send/update leaf 明确编排。", + "risk": "write", + "public": true, + "reviewed": true + }, + "+messages-update-card": { + "disposition": "schema_leaf", + "semantic_delta": "更新流式卡片是一对一写入;默认由 Runtime Schema leaf 承担。", + "risk": "write", + "public": true, + "reviewed": true + }, + "+messages-resource-url": { + "disposition": "schema_leaf", + "semantic_delta": "仅换取临时资源 URL;尚未提供安全落盘、去重和局部失败隔离,不作为默认 Semantic Shortcut。", + "risk": "read", + "public": true, + "reviewed": true + }, + "+messages-resource-download": { + "disposition": "primary_smart", + "semantic_delta": "把临时资源 URL 解析、工作目录内安全路径、默认不覆盖、临时文件下载和原子发布封装为结构化单步结果。", + "risk": "read", + "public": true, + "reviewed": true + }, + "+messages-forward": { + "disposition": "schema_leaf", + "semantic_delta": "转发单条消息是一对一操作;默认由 Runtime Schema leaf 承担。", + "risk": "write", + "public": true, + "reviewed": true + }, + "+messages-combine-forward": { + "disposition": "schema_leaf", + "semantic_delta": "使用两个真实群、两条非空源消息分别完成 native leaf 与 Shortcut 双层回归,并以 mget 回读确认两条目标合并消息均实际存在且包含完整 forwarded 子消息。", + "risk": "write", + "public": true, + "reviewed": true + }, + "+messages-forward-topic": { + "disposition": "schema_leaf", + "semantic_delta": "话题转发是一对一消息操作;默认由 leaf 承担。", + "risk": "write", + "public": true, + "reviewed": true + }, + "+messages-set-pin": { + "disposition": "schema_leaf", + "semantic_delta": "Pin 消息是一对一操作;默认由 Runtime Schema leaf 承担。", + "risk": "write", + "public": true, + "reviewed": true + }, + "+messages-unset-pin": { + "disposition": "schema_leaf", + "semantic_delta": "取消 Pin 是一对一操作;默认由 Runtime Schema leaf 承担。", + "risk": "write", + "public": true, + "reviewed": true + }, + "+messages-list-pin": { + "disposition": "schema_leaf", + "semantic_delta": "Pin 列表只做字段投影;无额外语义编排。", + "risk": "read", + "public": true, + "reviewed": true + }, + "+messages-set-top": { + "disposition": "schema_leaf", + "semantic_delta": "置顶消息是一对一操作;默认由 Runtime Schema leaf 承担。", + "risk": "write", + "public": true, + "reviewed": true + }, + "+messages-unset-top": { + "disposition": "schema_leaf", + "semantic_delta": "取消置顶消息是一对一操作;默认由 Runtime Schema leaf 承担。", + "risk": "write", + "public": true, + "reviewed": true + }, + "+at-me": { + "disposition": "primary_smart", + "semantic_delta": "自动构造时间窗,分页拉取跨会话 @我 消息,并保留身份、引用、reaction、resourceRefs 与完整性。", + "risk": "read", + "public": true, + "reviewed": true + }, + "+broadcast": { + "disposition": "primary_smart", + "semantic_delta": "按多人姓名逐一解析目标、拒绝歧义、生成可执行 dry-run 计划并隔离单人发送失败。", + "risk": "write", + "public": true, + "reviewed": true + }, + "+chat-messages": { + "disposition": "primary_smart", + "semantic_delta": "统一群聊与两类单聊目标,输出稳定消息身份、引用、reaction、resourceRefs、时间边界翻页和可读正文。", + "risk": "read", + "public": true, + "reviewed": true + }, + "+dm": { + "disposition": "primary_smart", + "semantic_delta": "把自然语言姓名安全解析为唯一用户,再以当前用户身份发送并提供真实 dry-run 计划。", + "risk": "write", + "public": true, + "reviewed": true + }, + "+group-members": { + "disposition": "alias_internal", + "semantic_delta": "按群名列用户成员已由 +chat-members-list 覆盖;保留旧入口兼容。", + "risk": "read", + "primary": "+chat-members-list", + "public": true, + "reviewed": true + }, + "+chat-members-list": { + "disposition": "primary_smart", + "semantic_delta": "支持群名唯一解析或直接会话 ID,组合用户与机器人两个下层工具并分桶输出完整性。", + "risk": "read", + "public": true, + "reviewed": true + }, + "+my-groups": { + "disposition": "semantic_adapter", + "semantic_delta": "提供稳定群字段、本地类型过滤和可继续的分页完整性,替代重复群列表入口。", + "risk": "read", + "public": true, + "reviewed": true + }, + "+search-msg": { + "disposition": "primary_smart", + "semantic_delta": "统一关键词、发送者、@对象、会话、消息类型和机器人来源过滤;支持精确时间窗、page-all、50 条一组 mget 富化,并以 failure ledger 显式报告截断或富化失败。", + "risk": "read", + "public": true, + "reviewed": true + }, + "+send-to-group": { + "disposition": "primary_smart", + "semantic_delta": "按群名解析唯一会话、拒绝歧义并生成当前用户发送的真实 dry-run 计划。", + "risk": "write", + "public": true, + "reviewed": true + }, + "+thread-replies": { + "disposition": "primary_smart", + "semantic_delta": "接受消息列表直接返回的 threadId(兼容 topicId),拉取回复并输出稳定身份、引用、reaction、resourceRefs、可读正文和时间边界分页。", + "risk": "read", + "public": true, + "reviewed": true + }, + "+unread-chats": { + "disposition": "semantic_adapter", + "semantic_delta": "把未读会话投影为会话身份、名称和未读计数,并保留下层分页完整性。", + "risk": "read", + "public": true, + "reviewed": true + } + } +} diff --git a/internal/shortcut/semantic_catalog_coverage_test.go b/internal/shortcut/semantic_catalog_coverage_test.go new file mode 100644 index 00000000..8c3e3c4b --- /dev/null +++ b/internal/shortcut/semantic_catalog_coverage_test.go @@ -0,0 +1,209 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package shortcut + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" +) + +func TestCrossPlatformCoverageSemanticCatalogRejectsInvalidRecords(t *testing.T) { + cases := map[string]string{ + "json": `{`, + "header": `{ + "version": 2, + "service": "chat", + "default_availability": "available", + "shortcuts": {} + }`, + "command": `{ + "version": 1, + "service": "chat", + "default_availability": "available", + "shortcuts": { + "messages": { + "disposition": "semantic_adapter", + "semantic_delta": "reviewed", + "risk": "read", + "public": true, + "reviewed": true + } + } + }`, + "review": `{ + "version": 1, + "service": "chat", + "default_availability": "available", + "shortcuts": { + "+messages": { + "disposition": "semantic_adapter", + "semantic_delta": "", + "risk": "read", + "public": true, + "reviewed": false + } + } + }`, + "disposition": `{ + "version": 1, + "service": "chat", + "default_availability": "available", + "shortcuts": { + "+messages": { + "disposition": "unknown", + "semantic_delta": "reviewed", + "risk": "read", + "public": true, + "reviewed": true + } + } + }`, + "risk": `{ + "version": 1, + "service": "chat", + "default_availability": "available", + "shortcuts": { + "+messages": { + "disposition": "semantic_adapter", + "semantic_delta": "reviewed", + "risk": "unknown", + "public": true, + "reviewed": true + } + } + }`, + "availability": `{ + "version": 1, + "service": "chat", + "default_availability": "unknown", + "shortcuts": { + "+messages": { + "disposition": "semantic_adapter", + "semantic_delta": "reviewed", + "risk": "read", + "public": false, + "reviewed": true + } + } + }`, + "alias": `{ + "version": 1, + "service": "chat", + "default_availability": "available", + "shortcuts": { + "+messages": { + "disposition": "alias_internal", + "semantic_delta": "reviewed", + "risk": "read", + "public": false, + "reviewed": true + } + } + }`, + "public unavailable": `{ + "version": 1, + "service": "chat", + "default_availability": "available", + "shortcuts": { + "+messages": { + "disposition": "semantic_adapter", + "semantic_delta": "reviewed", + "risk": "read", + "availability": "unavailable", + "public": true, + "reviewed": true + } + } + }`, + } + original := semanticCatalogJSON + t.Cleanup(func() { semanticCatalogJSON = original }) + for name, payload := range cases { + t.Run(name, func(t *testing.T) { + semanticCatalogJSON = []byte(payload) + defer func() { + if recover() == nil { + t.Fatal("invalid semantic catalog did not panic") + } + }() + _ = mustLoadSemanticCatalog() + }) + } +} + +func TestCrossPlatformCoveragePublicCatalogSemanticAndGeneratedLookups(t *testing.T) { + if !InPublicCatalog("chat", "+messages-send") { + t.Fatal("reviewed public semantic shortcut is missing") + } + if InPublicCatalog("chat", "+conversation-mute-at-all") { + t.Fatal("reviewed unavailable semantic shortcut is public") + } + if InPublicCatalog("unknown", "+missing") { + t.Fatal("unknown shortcut is public") + } +} + +func TestCrossPlatformCoverageRuntimeReadDataBranches(t *testing.T) { + caller := &runtimeReadCoverageCaller{} + old := helpers.GetCaller() + t.Cleanup(func() { helpers.InitDeps(old) }) + helpers.InitDeps(caller) + rt := &RuntimeContext{} + + caller.text = "" + if got, err := rt.callMCPReadData("im", "search_groups", nil); err != nil || len(got) != 0 { + t.Fatalf("empty read = %#v, %v", got, err) + } + if caller.args == nil { + t.Fatal("nil read parameters were not normalized") + } + + caller.err = errors.New("read failed") + if _, err := rt.callMCPReadData("im", "search_groups", nil); err == nil { + t.Fatal("read error was swallowed") + } + caller.err = nil + + caller.text = `not-json` + if _, err := rt.callMCPReadData("im", "search_groups", map[string]any{"keyword": "x"}); err == nil { + t.Fatal("invalid read JSON was accepted") + } + + caller.text = `{"result":{"groups":[]}}` + got, err := rt.callMCPReadData("im", "search_groups", map[string]any{"keyword": "x"}) + if err != nil || !strings.Contains(caller.text, "groups") || got["result"] == nil { + t.Fatalf("valid read = %#v, %v", got, err) + } +} + +type runtimeReadCoverageCaller struct { + args map[string]any + text string + err error +} + +func (c *runtimeReadCoverageCaller) CallTool( + _ context.Context, + _ string, + _ string, + args map[string]any, +) (*edition.ToolResult, error) { + c.args = args + if c.err != nil { + return nil, c.err + } + return &edition.ToolResult{ + Content: []edition.ContentBlock{{Type: "text", Text: c.text}}, + }, nil +} + +func (c *runtimeReadCoverageCaller) Format() string { return "json" } +func (c *runtimeReadCoverageCaller) DryRun() bool { return false } +func (c *runtimeReadCoverageCaller) Fields() string { return "" } +func (c *runtimeReadCoverageCaller) JQ() string { return "" } diff --git a/internal/shortcut/shortcut_test.go b/internal/shortcut/shortcut_test.go index 6c980c71..6f3a55e9 100644 --- a/internal/shortcut/shortcut_test.go +++ b/internal/shortcut/shortcut_test.go @@ -371,7 +371,7 @@ func TestCrossPlatformCoverageCallMCPWriteDataRejectsDryRun(t *testing.T) { } } -func TestCrossPlatformCoverageCallMCPDataRejectsLikelyWriteUnderDryRun(t *testing.T) { +func TestCrossPlatformCoverageCallMCPDataDryRunReadNameFailsClosed(t *testing.T) { root := &cobra.Command{Use: "dws"} root.PersistentFlags().Bool("dry-run", false, "") cmd := &cobra.Command{Use: "x"} @@ -381,12 +381,26 @@ func TestCrossPlatformCoverageCallMCPDataRejectsLikelyWriteUnderDryRun(t *testin } rt := &RuntimeContext{cmd: cmd, shortcut: Shortcut{Service: "chat"}} - _, err := rt.CallMCPData("chat", "send_personal_message", map[string]any{"receiverUid": "u"}) - if err == nil { - t.Fatal("expected likely write guard error") + for _, tool := range []string{"send_personal_message", "custom_operation"} { + _, err := rt.CallMCPData("chat", tool, map[string]any{"receiverUid": "u"}) + if err == nil { + t.Fatalf("%s: expected dry-run read allowlist error", tool) + } + if !strings.Contains(err.Error(), "chat/"+tool) { + t.Fatalf("%s: error = %q, want tool name", tool, err.Error()) + } } - if !strings.Contains(err.Error(), "chat/send_personal_message") { - t.Fatalf("error = %q, want tool name", err.Error()) + + for _, tool := range []string{ + "get_conversation_info", + "list_topic_replies", + "query_busy_status", + "search_groups", + "unread_message_conversation_list", + } { + if !looksReadTool(tool) { + t.Errorf("%s: expected read-only classification", tool) + } } } diff --git a/internal/shortcut/smart/at_me.go b/internal/shortcut/smart/at_me.go index 93de75df..6f1a5d0b 100644 --- a/internal/shortcut/smart/at_me.go +++ b/internal/shortcut/smart/at_me.go @@ -55,6 +55,9 @@ var AtMe = shortcut.Shortcut{ Risk: shortcut.RiskRead, Flags: []shortcut.Flag{ {Name: "days", Type: shortcut.FlagInt, Desc: "回溯天数(可选,默认 7)", Default: "7", Required: false}, + {Name: "limit", Type: shortcut.FlagInt, Desc: "每页返回数量(默认 50)", Default: "50"}, + {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,翻页传上次的 nextCursor", Default: "0"}, + {Name: "no-reactions", Type: shortcut.FlagBool, Desc: "不输出消息 reaction(默认输出)"}, }, Tips: []string{ `dws chat +at-me`, @@ -77,8 +80,8 @@ var AtMe = shortcut.Shortcut{ data, err := rt.CallMCPData("chat", "search_at_me_message", map[string]any{ "startTime": startMs, "endTime": endMs, - "limit": 50, - "cursor": "0", + "limit": rt.Int("limit"), + "cursor": rt.Str("cursor"), }) if err != nil { return err @@ -92,9 +95,11 @@ var AtMe = shortcut.Shortcut{ } results := make([]map[string]any, 0, len(items)) for _, m := range items { - results = append(results, atMeProject(m)) + results = append(results, atMeProjectWithReactions(m, !rt.Bool("no-reactions"))) } - return rt.Output(map[string]any{"messages": results}) + payload := map[string]any{"messages": results} + chatmsg.ApplyPagination(payload, data) + return rt.Output(payload) }, } @@ -195,13 +200,46 @@ func atMeToMaps(arr []any) []map[string]any { // readable, ciphertext → marker) and recursively expanding any forwarded chat // record under "forwarded". func atMeProject(m map[string]any) map[string]any { + return atMeProjectWithReactions(m, true) +} + +func atMeProjectWithReactions(m map[string]any, includeReactions bool) map[string]any { row := map[string]any{ "sender": atMeSender(m), "time": atMeTime(m), "text": atMeCleanText(m), "conversation": atMeConversation(m), } - if forwarded := chatmsg.Forwarded(m, atMeProject); len(forwarded) > 0 { + if messageID := chatmsg.MessageID(m); messageID != nil { + row["messageId"] = messageID + } + if conversationID := chatmsg.ConversationID(m); conversationID != nil { + row["conversationId"] = conversationID + } + if threadID := chatmsg.ThreadID(m); threadID != nil { + row["threadId"] = threadID + } + if messageType := chatmsg.MessageType(m); messageType != nil { + row["messageType"] = messageType + } + if updateTime := chatmsg.UpdateTime(m); updateTime != nil { + row["updateTime"] = updateTime + } + if includeReactions { + if reactions := chatmsg.Reactions(m); len(reactions) > 0 { + row["reactions"] = reactions + } + } + if quoted := chatmsg.QuotedMessage(m); len(quoted) > 0 { + row["quotedMessage"] = quoted + } + if resources := chatmsg.Resources(m); len(resources) > 0 { + row["resourceRefs"] = resources + } + projectForwarded := func(item map[string]any) map[string]any { + return atMeProjectWithReactions(item, includeReactions) + } + if forwarded := chatmsg.Forwarded(m, projectForwarded); len(forwarded) > 0 { row["forwarded"] = forwarded } return row diff --git a/internal/shortcut/smart/at_me_search_msg_test.go b/internal/shortcut/smart/at_me_search_msg_test.go index b5f389f9..0b620796 100644 --- a/internal/shortcut/smart/at_me_search_msg_test.go +++ b/internal/shortcut/smart/at_me_search_msg_test.go @@ -26,12 +26,23 @@ func TestAtMeProject(t *testing.T) { "sender": map[string]any{"name": "念晨"}, "createTime": "2026-07-19 13:37:03", "content": "普通消息", + "openMessageId": "msg-1", + "msgType": "text", "conversationTitle": "群A", "openConversationId": "cid1", + "emotionReplyList": []any{ + map[string]any{"emoji": "赞", "replyUsers": []any{"D1"}}, + }, }) if row["sender"] != "念晨" || row["text"] != "普通消息" || row["conversation"] != "群A" { t.Fatalf("atMeProject nested = %#v", row) } + if row["messageId"] != "msg-1" || row["conversationId"] != "cid1" || row["messageType"] != "text" { + t.Errorf("atMeProject stable identity = %#v", row) + } + if reactions, ok := row["reactions"].(map[string]any); !ok || len(reactions) == 0 { + t.Errorf("atMeProject reactions = %#v", row["reactions"]) + } // encrypted content → marked (never leaked); id-only sender fallback; a // forwarded sub-message whose sender is the literal "null" must be nulled. @@ -65,6 +76,15 @@ func TestAtMeProject(t *testing.T) { if _, has := row["forwarded"]; has { t.Errorf("atMeProject plain unexpectedly has forwarded") } + + row = atMeProjectWithReactions(map[string]any{ + "emotionReplyList": []any{ + map[string]any{"emoji": "赞", "replyUsers": []any{"D1"}}, + }, + }, false) + if _, has := row["reactions"]; has { + t.Errorf("atMeProject no-reactions leaked reactions: %#v", row) + } } func TestSearchMsgProject(t *testing.T) { diff --git a/internal/shortcut/smart/broadcast.go b/internal/shortcut/smart/broadcast.go index e912602a..5efbe877 100644 --- a/internal/shortcut/smart/broadcast.go +++ b/internal/shortcut/smart/broadcast.go @@ -58,6 +58,7 @@ var Broadcast = shortcut.Shortcut{ var ( sent []string failed []string + plans []map[string]any ) for _, raw := range names { name := strings.TrimSpace(raw) @@ -81,6 +82,15 @@ var Broadcast = shortcut.Shortcut{ // --dry-run we still resolve names (a read) but never send: record the // resolved recipient as "would send" and move on. if rt.DryRun() { + plans = append(plans, map[string]any{ + "recipient": user.name, + "tool": "send_personal_message", + "arguments": rt.AddAIMessageTag(map[string]any{ + "receiverOpenDingTalkId": user.openDingTalkID, + "msgType": "markdown", + "content": string(content), + }), + }) sent = append(sent, user.name) continue } @@ -108,6 +118,14 @@ var Broadcast = shortcut.Shortcut{ if len(failed) > 0 { result["failed"] = failed } + if rt.DryRun() { + result["dry_run"] = true + result["executed"] = false + result["preview_kind"] = "plan" + result["tool"] = "send_personal_message" + result["actionCount"] = len(plans) + result["actions"] = plans + } return rt.Output(result) }, } diff --git a/internal/shortcut/smart/chat_members_list_test.go b/internal/shortcut/smart/chat_members_list_test.go new file mode 100644 index 00000000..7bf9c8aa --- /dev/null +++ b/internal/shortcut/smart/chat_members_list_test.go @@ -0,0 +1,59 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package smart + +import "testing" + +func TestPreferExactGroupMatches(t *testing.T) { + groups := []sendGroupMatch{ + {id: "exact", name: "DWS-SHORTCUT-AUDIT"}, + {id: "prefix", name: "DWS-SHORTCUT-AUDIT-THREAD"}, + {id: "exact", name: "DWS-SHORTCUT-AUDIT"}, + } + got := preferExactGroupMatches(groups, " dws-shortcut-audit ") + if len(got) != 1 || got[0].id != "exact" { + t.Fatalf("exact group resolution = %#v", got) + } + + got = preferExactGroupMatches(groups, "SHORTCUT") + if len(got) != 2 { + t.Fatalf("ambiguous substring resolution = %#v, want 2 unique groups", got) + } +} + +func TestResolveMemberTypes(t *testing.T) { + users, bots, err := resolveMemberTypes(nil) + if err != nil || !users || !bots { + t.Fatalf("default member types = users:%v bots:%v err:%v", users, bots, err) + } + users, bots, err = resolveMemberTypes([]string{"BOT", "user", "bot"}) + if err != nil || !users || !bots { + t.Fatalf("normalized member types = users:%v bots:%v err:%v", users, bots, err) + } + if _, _, err := resolveMemberTypes([]string{"service-account"}); err == nil { + t.Fatal("invalid member type was accepted") + } +} + +func TestGroupBotProject(t *testing.T) { + got := groupBotProject(map[string]any{ + "result": map[string]any{ + "bots": []any{ + map[string]any{ + "name": "日报机器人", + "botOpenDingTalkId": "DINGBOT", + "openBotId": "open-bot", + "robotCode": "robot-code", + "credentialsNotForAgent": "must-not-leak", + }, + }, + }, + }) + if len(got) != 1 || got[0]["name"] != "日报机器人" || got[0]["openBotId"] != "open-bot" { + t.Fatalf("bot projection = %#v", got) + } + if _, leaked := got[0]["credentialsNotForAgent"]; leaked { + t.Fatalf("bot projection leaked unrelated field: %#v", got[0]) + } +} diff --git a/internal/shortcut/smart/chat_messages.go b/internal/shortcut/smart/chat_messages.go index 3cac53ec..19720339 100644 --- a/internal/shortcut/smart/chat_messages.go +++ b/internal/shortcut/smart/chat_messages.go @@ -56,13 +56,15 @@ var ChatMessages = shortcut.Shortcut{ {Name: "conversation-id", Type: shortcut.FlagString, Desc: "--group 的别名", Hidden: true}, {Name: "id", Type: shortcut.FlagString, Desc: "--group 的别名", Hidden: true}, {Name: "user", Type: shortcut.FlagString, Desc: "单聊对方的 userId,与 --group 互斥"}, + {Name: "open-dingtalk-id", Type: shortcut.FlagString, Desc: "单聊对方的 openDingTalkId,与 --group/--user 互斥"}, {Name: "time", Type: shortcut.FlagString, Desc: "起始时间,如 \"2025-03-01 00:00:00\"(可选)"}, {Name: "limit", Type: shortcut.FlagInt, Desc: "每页拉取的消息条数(可选)"}, {Name: "size", Type: shortcut.FlagInt, Desc: "--limit 的旧版别名", Hidden: true}, {Name: "direction", Type: shortcut.FlagString, Desc: "时间方向 newer/older(可选,newer 从给定时间往现在拉,older 往以前拉)"}, + {Name: "no-reactions", Type: shortcut.FlagBool, Desc: "不输出消息 reaction(默认输出)"}, }, Constraints: []shortcut.Constraint{ - {Kind: shortcut.ConstraintExactlyOne, Flags: []string{"group", "conversation-id", "id", "user"}}, + {Kind: shortcut.ConstraintExactlyOne, Flags: []string{"group", "conversation-id", "id", "user", "open-dingtalk-id"}}, }, Tips: []string{ `dws chat +chat-messages --group --time "2025-03-01 00:00:00"`, @@ -97,6 +99,9 @@ var ChatMessages = shortcut.Shortcut{ if group := rt.StrFirst("group", "conversation-id", "id"); group != "" { tool = "list_conversation_message_v2" params["openconversation_id"] = group + } else if openID := strings.TrimSpace(rt.Str("open-dingtalk-id")); openID != "" { + tool = "list_individual_chat_message" + params["openDingTalkId"] = openID } else { tool = "list_individual_chat_message" params["userId"] = rt.Str("user") @@ -112,13 +117,16 @@ var ChatMessages = shortcut.Shortcut{ items := chatMessageItems(data) results := make([]map[string]any, 0, len(items)) for _, m := range items { - results = append(results, projectChatMessage(m)) + results = append(results, projectChatMessageWithReactions(m, !rt.Bool("no-reactions"))) } - return rt.Output(map[string]any{ + payload := map[string]any{ "messages": results, "count": len(results), - }) + } + direction := strings.TrimSpace(strings.ToLower(rt.Str("direction"))) + chatmsg.ApplyMessagePagination(payload, data, items, direction) + return rt.Output(payload) }, } @@ -158,12 +166,45 @@ func chatMessageItems(data map[string]any) []map[string]any { // marking encrypted messages via chatmsg, and recursively expanding forwarded // chat records under "forwarded". func projectChatMessage(m map[string]any) map[string]any { + return projectChatMessageWithReactions(m, true) +} + +func projectChatMessageWithReactions(m map[string]any, includeReactions bool) map[string]any { row := map[string]any{ "sender": chatmsg.Sender(m), "text": chatmsg.Text(m), "createTime": chatmsg.CreateTime(m), } - if forwarded := chatmsg.Forwarded(m, projectChatMessage); len(forwarded) > 0 { + if messageID := chatmsg.MessageID(m); messageID != nil { + row["messageId"] = messageID + } + if conversationID := chatmsg.ConversationID(m); conversationID != nil { + row["conversationId"] = conversationID + } + if threadID := chatmsg.ThreadID(m); threadID != nil { + row["threadId"] = threadID + } + if messageType := chatmsg.MessageType(m); messageType != nil { + row["messageType"] = messageType + } + if updateTime := chatmsg.UpdateTime(m); updateTime != nil { + row["updateTime"] = updateTime + } + if includeReactions { + if reactions := chatmsg.Reactions(m); len(reactions) > 0 { + row["reactions"] = reactions + } + } + if quoted := chatmsg.QuotedMessage(m); len(quoted) > 0 { + row["quotedMessage"] = quoted + } + if resources := chatmsg.Resources(m); len(resources) > 0 { + row["resourceRefs"] = resources + } + projectForwarded := func(item map[string]any) map[string]any { + return projectChatMessageWithReactions(item, includeReactions) + } + if forwarded := chatmsg.Forwarded(m, projectForwarded); len(forwarded) > 0 { row["forwarded"] = forwarded } return row diff --git a/internal/shortcut/smart/chat_messages_test.go b/internal/shortcut/smart/chat_messages_test.go index f0cb7614..21b40ad8 100644 --- a/internal/shortcut/smart/chat_messages_test.go +++ b/internal/shortcut/smart/chat_messages_test.go @@ -22,9 +22,16 @@ import "testing" // behaviour (sender/text/encryption) is covered in the chatmsg package tests. func TestProjectChatMessageExpandsForwarded(t *testing.T) { row := projectChatMessage(map[string]any{ - "sender": "hugozhu", - "content": "hugozhu与opencode-agent的聊天记录\nopencode-agent:[卡片]", - "createTime": "2026-07-20 21:41:21", + "sender": "hugozhu", + "openMessageId": "msg-top", + "openConversationId": "cid-top", + "openConvThreadId": "thread-top", + "msgType": "text", + "content": "hugozhu与opencode-agent的聊天记录\nopencode-agent:[卡片]", + "createTime": "2026-07-20 21:41:21", + "emotionReplyList": []any{ + map[string]any{"emoji": "赞", "replyUsers": []any{"D1"}}, + }, "forwardMessages": []any{ map[string]any{"sender": "null", "content": "读下冬翔发给我的最近两条消息", "createTime": "2026-07-20 09:30:33"}, map[string]any{"sender": "冬翔", "content": "W29 工作总结", "createTime": "2026-07-19 23:35:40", @@ -39,6 +46,15 @@ func TestProjectChatMessageExpandsForwarded(t *testing.T) { if row["sender"] != "hugozhu" { t.Fatalf("top sender = %v, want hugozhu", row["sender"]) } + if row["messageId"] != "msg-top" || row["conversationId"] != "cid-top" || row["messageType"] != "text" { + t.Errorf("stable identity = %#v", row) + } + if row["threadId"] != "thread-top" { + t.Errorf("thread identity = %#v", row) + } + if reactions, ok := row["reactions"].(map[string]any); !ok || len(reactions) == 0 { + t.Errorf("reactions = %#v", row["reactions"]) + } forwarded, ok := row["forwarded"].([]map[string]any) if !ok || len(forwarded) != 2 { t.Fatalf("forwarded = %#v, want 2 entries", row["forwarded"]) @@ -59,4 +75,14 @@ func TestProjectChatMessageExpandsForwarded(t *testing.T) { if _, has := plain["forwarded"]; has { t.Errorf("plain message unexpectedly has forwarded key: %#v", plain) } + + withoutReactions := projectChatMessageWithReactions(map[string]any{ + "content": "hi", + "emotionReplyList": []any{ + map[string]any{"emoji": "赞", "replyUsers": []any{"D1"}}, + }, + }, false) + if _, has := withoutReactions["reactions"]; has { + t.Errorf("no-reactions projection leaked reactions: %#v", withoutReactions) + } } diff --git a/internal/shortcut/smart/compatibility_coverage_test.go b/internal/shortcut/smart/compatibility_coverage_test.go index 8f2a0d1b..806662e2 100644 --- a/internal/shortcut/smart/compatibility_coverage_test.go +++ b/internal/shortcut/smart/compatibility_coverage_test.go @@ -14,8 +14,11 @@ package smart import ( + "bytes" "context" + "encoding/json" "io" + "reflect" "strings" "testing" @@ -33,6 +36,7 @@ type platformCoverageCall struct { type platformCoverageCaller struct { calls []platformCoverageCall + dry bool contactSearchResult string } @@ -53,6 +57,10 @@ func (f *platformCoverageCaller) CallTool(_ context.Context, product, tool strin return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil } +func (f *platformCoverageCaller) CallReadTool(ctx context.Context, product, tool string, args map[string]any) (*edition.ToolResult, error) { + return f.CallTool(ctx, product, tool, args) +} + func TestCrossPlatformCoverageExternalContactAmbiguity(t *testing.T) { fake := &platformCoverageCaller{ contactSearchResult: `{"result":[ @@ -75,7 +83,7 @@ func TestCrossPlatformCoverageExternalContactAmbiguity(t *testing.T) { } func (f *platformCoverageCaller) Format() string { return "json" } -func (f *platformCoverageCaller) DryRun() bool { return false } +func (f *platformCoverageCaller) DryRun() bool { return f.dry } func (f *platformCoverageCaller) Fields() string { return "" } func (f *platformCoverageCaller) JQ() string { return "" } @@ -138,26 +146,74 @@ func TestCrossPlatformCoverageAIMessageTag(t *testing.T) { }) } +func TestCrossPlatformCoverageBroadcastDryRunPublishesExecutablePlan(t *testing.T) { + fake := &platformCoverageCaller{dry: true} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + var output bytes.Buffer + root.SetOut(&output) + root.SetArgs([]string{ + "chat", "+broadcast", + "--to", "张三", + "--text", "你好", + "--dry-run", + "--yes", + }) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + if len(fake.calls) != 1 || + fake.calls[0].product != "contact" || + fake.calls[0].tool != "search_contact_by_key_word" { + t.Fatalf("dry-run calls = %#v, want one read-only contact lookup", fake.calls) + } + + var payload map[string]any + if err := json.Unmarshal(output.Bytes(), &payload); err != nil { + t.Fatalf("decode dry-run output: %v\n%s", err, output.String()) + } + if payload["dry_run"] != true || + payload["executed"] != false || + payload["preview_kind"] != "plan" || + payload["tool"] != "send_personal_message" { + t.Fatalf("dry-run payload = %#v", payload) + } + actions, _ := payload["actions"].([]any) + if len(actions) != 1 { + t.Fatalf("dry-run actions = %#v", payload["actions"]) + } + action, _ := actions[0].(map[string]any) + arguments, _ := action["arguments"].(map[string]any) + for _, key := range []string{"receiverOpenDingTalkId", "msgType", "content", "clawType"} { + if _, ok := arguments[key]; !ok { + t.Errorf("dry-run action arguments missing %q: %#v", key, arguments) + } + } +} + func TestCrossPlatformCoverageCompatibilityAliases(t *testing.T) { tests := []struct { - name string - argv []string - wantTool string - wantArgs map[string]any - wantAbsent []string + name string + argv []string + wantProduct string + wantTool string + wantArgs map[string]any + wantAbsent []string }{ { - name: "chat messages id and size", - argv: []string{"chat", "+chat-messages", "--id", "cid-1", "--size", "9", "--yes"}, - wantTool: "list_conversation_message_v2", - wantArgs: map[string]any{"openconversation_id": "cid-1", "limit": 9}, - wantAbsent: []string{"openCid", "cid"}, + name: "chat messages id and size", + argv: []string{"chat", "+chat-messages", "--id", "cid-1", "--size", "9", "--yes"}, + wantProduct: "chat", + wantTool: "list_conversation_message_v2", + wantArgs: map[string]any{"openconversation_id": "cid-1", "limit": 9}, + wantAbsent: []string{"openCid", "cid"}, }, { - name: "search message id and keyword", - argv: []string{"chat", "+search-msg", "--id", "cid-1", "--keyword", "树莓派", "--yes"}, - wantTool: "search_messages_by_keyword", - wantArgs: map[string]any{"openConversationId": "cid-1", "keyword": "树莓派"}, + name: "search message id and keyword", + argv: []string{"chat", "+search-msg", "--id", "cid-1", "--keyword", "树莓派", "--no-enrich", "--yes"}, + wantProduct: "im", + wantTool: "search_messages", + wantArgs: map[string]any{"openConversationIds": []string{"cid-1"}, "keyword": "树莓派"}, }, } @@ -171,11 +227,11 @@ func TestCrossPlatformCoverageCompatibilityAliases(t *testing.T) { t.Fatal(err) } call := fake.calls[len(fake.calls)-1] - if call.product != "chat" || call.tool != tc.wantTool { - t.Fatalf("call = %s/%s, want chat/%s", call.product, call.tool, tc.wantTool) + if call.product != tc.wantProduct || call.tool != tc.wantTool { + t.Fatalf("call = %s/%s, want %s/%s", call.product, call.tool, tc.wantProduct, tc.wantTool) } for key, want := range tc.wantArgs { - if got := call.args[key]; got != want { + if got := call.args[key]; !reflect.DeepEqual(got, want) { t.Errorf("%s = %#v, want %#v", key, got, want) } } diff --git a/internal/shortcut/smart/coverage_completion_test.go b/internal/shortcut/smart/coverage_completion_test.go new file mode 100644 index 00000000..426f474f --- /dev/null +++ b/internal/shortcut/smart/coverage_completion_test.go @@ -0,0 +1,266 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package smart + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" +) + +type smartCoverageCaller struct { + responses map[string][]string + failAt map[string]int + counts map[string]int +} + +func (c *smartCoverageCaller) CallTool( + _ context.Context, + product, tool string, + _ map[string]any, +) (*edition.ToolResult, error) { + if c.counts == nil { + c.counts = map[string]int{} + } + key := product + "/" + tool + c.counts[key]++ + if c.failAt[key] == c.counts[key] { + return nil, errors.New("fixture failure") + } + responses := c.responses[key] + text := `{"result":[]}` + if len(responses) > 0 { + index := c.counts[key] - 1 + if index >= len(responses) { + index = len(responses) - 1 + } + text = responses[index] + } + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil +} + +func (c *smartCoverageCaller) CallReadTool(ctx context.Context, product, tool string, args map[string]any) (*edition.ToolResult, error) { + return c.CallTool(ctx, product, tool, args) +} + +func (*smartCoverageCaller) Format() string { return "json" } +func (*smartCoverageCaller) DryRun() bool { return false } +func (*smartCoverageCaller) Fields() string { return "" } +func (*smartCoverageCaller) JQ() string { return "" } + +func TestCrossPlatformCoverageRichMessageProjections(t *testing.T) { + message := map[string]any{ + "openMessageId": "msg", + "openConversationId": "cid", + "threadId": "thread", + "msgType": "text", + "createTime": "1", + "updateTime": "2", + "content": `{"mediaId":"@image"}`, + "emotionReplyList": []any{map[string]any{"emojiName": "赞", "replyCount": 1}}, + "quotedMessage": map[string]any{ + "openMessageId": "quoted", + "content": "quoted body", + }, + } + for name, row := range map[string]map[string]any{ + "at-me": atMeProject(message), + "chat": projectChatMessage(message), + "search": searchMsgProject(message), + } { + for _, key := range []string{"threadId", "messageType", "updateTime", "quotedMessage", "resourceRefs"} { + if _, ok := row[key]; !ok { + t.Errorf("%s projection missing %s: %#v", name, key, row) + } + } + } +} + +func TestCrossPlatformCoverageChatMessagesOpenIDRoute(t *testing.T) { + caller := &smartCoverageCaller{responses: map[string][]string{ + "chat/list_individual_chat_message": {`{"result":{"messages":[]}}`}, + }} + helpers.InitDeps(caller) + root := newPlatformCoverageRoot() + root.SetArgs([]string{"chat", "+chat-messages", "--open-dingtalk-id", "D-user", "--limit", "1"}) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + if caller.counts["chat/list_individual_chat_message"] != 1 { + t.Fatalf("calls = %#v", caller.counts) + } +} + +func TestCrossPlatformCoverageChatMembersListOutcomes(t *testing.T) { + successResponses := map[string][]string{ + "chat/get_group_members": {`{"result":{"hasMore":false,"list":[{"memberEmpName":"成员","openDingtalkId":"D-user"}]}}`}, + "bot/list_group_bots": {`{"result":{"bots":[{"robotName":"机器人","robotCode":"robot"}]}}`}, + } + cases := []struct { + name string + memberArg string + failAt map[string]int + wantError bool + }{ + {name: "both success"}, + {name: "user only failure", memberArg: "user", failAt: map[string]int{"chat/get_group_members": 1}, wantError: true}, + {name: "bot only failure", memberArg: "bot", failAt: map[string]int{"bot/list_group_bots": 1}, wantError: true}, + {name: "both failure", failAt: map[string]int{"chat/get_group_members": 1, "bot/list_group_bots": 1}, wantError: true}, + {name: "partial user failure", failAt: map[string]int{"chat/get_group_members": 1}}, + {name: "partial bot failure", failAt: map[string]int{"bot/list_group_bots": 1}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + caller := &smartCoverageCaller{responses: successResponses, failAt: tc.failAt} + helpers.InitDeps(caller) + root := newPlatformCoverageRoot() + args := []string{"chat", "+chat-members-list", "--conversation-id", "cid"} + if tc.memberArg != "" { + args = append(args, "--member-types", tc.memberArg) + } + root.SetArgs(args) + err := root.Execute() + if (err != nil) != tc.wantError { + t.Fatalf("error = %v, wantError=%v", err, tc.wantError) + } + }) + } + + for _, raw := range [][]string{{""}, {"unknown"}} { + if _, _, err := resolveMemberTypes(raw); err == nil { + t.Fatalf("resolveMemberTypes(%q) succeeded", raw) + } + } + if users, bots, err := resolveMemberTypes(nil); err != nil || !users || !bots { + t.Fatalf("default member types = %v %v %v", users, bots, err) + } +} + +func TestCrossPlatformCoverageChatMembersGroupResolutionAndProjection(t *testing.T) { + cases := []struct { + name string + search string + fail bool + wantError bool + }{ + {name: "search error", fail: true, wantError: true}, + {name: "none", search: `{"result":[]}`, wantError: true}, + {name: "ambiguous", search: `{"result":[{"openConversationId":"c1","title":"群"},{"openConversationId":"c2","title":"群"}]}`, wantError: true}, + {name: "one", search: `{"result":[{"openConversationId":"c1","title":"群"}]}`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + failAt := map[string]int{} + if tc.fail { + failAt["im/search_groups"] = 1 + } + caller := &smartCoverageCaller{ + responses: map[string][]string{ + "im/search_groups": {tc.search}, + "chat/get_group_members": {`{"result":{"list":[]}}`}, + "bot/list_group_bots": {`{"result":{"bots":[]}}`}, + }, + failAt: failAt, + } + helpers.InitDeps(caller) + root := newPlatformCoverageRoot() + root.SetArgs([]string{"chat", "+chat-members-list", "--group", "群"}) + err := root.Execute() + if (err != nil) != tc.wantError { + t.Fatalf("error = %v, wantError=%v", err, tc.wantError) + } + }) + } + + bots := groupBotProject(map[string]any{"bots": []any{"invalid", map[string]any{"name": "bot"}}}) + if len(bots) != 1 || bots[0]["name"] != "bot" { + t.Fatalf("bots = %#v", bots) + } + if groupBotProject(map[string]any{"result": "invalid"}) == nil { + t.Fatal("empty bot projection should be a non-nil empty slice") + } +} + +func TestCrossPlatformCoverageSearchValidationAndTimeErrors(t *testing.T) { + cases := [][]string{ + {}, + {"--query", "x", "--start", "2026-07-01T00:00:00Z"}, + {"--query", "x", "--days", "0"}, + {"--query", "x", "--limit", "101"}, + {"--query", "x", "--page-limit", "0"}, + {"--query", "x", "--start", "bad", "--end", "2026-07-02T00:00:00Z"}, + {"--query", "x", "--start", "2026-07-01T00:00:00Z", "--end", "bad"}, + {"--query", "x", "--start", "2026-07-02T00:00:00Z", "--end", "2026-07-01T00:00:00Z"}, + } + for _, tail := range cases { + helpers.InitDeps(&smartCoverageCaller{}) + root := newPlatformCoverageRoot() + root.SetArgs(append([]string{"chat", "+search-msg"}, tail...)) + if err := root.Execute(); err == nil { + t.Errorf("invalid search args succeeded: %v", tail) + } + } +} + +func TestCrossPlatformCoverageSearchPaginationFailureModes(t *testing.T) { + cases := []struct { + name string + responses []string + failAt int + args []string + wantError bool + }{ + {name: "initial error", failAt: 1, args: []string{"--query", "x"}, wantError: true}, + { + name: "duplicate and inferred cursor", + responses: []string{ + `{"result":{"messages":[{"openMessageId":"m1"},{"openMessageId":"m1"}],"nextCursor":"c2"}}`, + `{"result":{"messages":[],"hasMore":false}}`, + }, + args: []string{"--query", "x", "--page-all", "--no-enrich"}, + }, + { + name: "stalled cursor", + responses: []string{`{"result":{"messages":[],"hasMore":true,"nextCursor":"same"}}`}, + args: []string{"--query", "x", "--cursor", "same", "--page-all", "--no-enrich"}, + }, + { + name: "page limit", + responses: []string{`{"result":{"messages":[],"hasMore":true,"nextCursor":"next"}}`}, + args: []string{"--query", "x", "--page-all", "--page-limit", "1", "--no-enrich"}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + caller := &smartCoverageCaller{ + responses: map[string][]string{"im/search_messages": tc.responses}, + failAt: map[string]int{"im/search_messages": tc.failAt}, + } + helpers.InitDeps(caller) + root := newPlatformCoverageRoot() + var output bytes.Buffer + root.SetOut(&output) + root.SetArgs(append([]string{"chat", "+search-msg"}, tc.args...)) + err := root.Execute() + if (err != nil) != tc.wantError { + t.Fatalf("error = %v, wantError=%v", err, tc.wantError) + } + if err == nil { + var payload map[string]any + if decodeErr := json.Unmarshal(output.Bytes(), &payload); decodeErr != nil { + t.Fatalf("decode %q: %v", strings.TrimSpace(output.String()), decodeErr) + } + if payload["complete"] != false && tc.name != "duplicate and inferred cursor" { + t.Fatalf("payload = %#v", payload) + } + } + }) + } +} diff --git a/internal/shortcut/smart/group_members.go b/internal/shortcut/smart/group_members.go index 1f43cdd0..1232086c 100644 --- a/internal/shortcut/smart/group_members.go +++ b/internal/shortcut/smart/group_members.go @@ -19,6 +19,7 @@ import ( apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg" ) // GroupMembers: list a group's members by its NAME, no openConversationId juggling. @@ -58,7 +59,7 @@ var GroupMembers = shortcut.Shortcut{ if err != nil { return err } - groups := extractGroupsForSend(data) + groups := preferExactGroupMatches(extractGroupsForSend(data), groupName) switch { case len(groups) == 0: return apperrors.NewValidation(fmt.Sprintf( @@ -85,10 +86,191 @@ var GroupMembers = shortcut.Shortcut{ // hiding data. return rt.Output(mdata) } - return rt.Output(map[string]any{"count": len(members), "members": members}) + payload := map[string]any{"count": len(members), "members": members} + chatmsg.ApplyPagination(payload, mdata) + return rt.Output(payload) }, } +// ChatMembersList aligns the recent lark-cli IM member-list experience with +// DingTalk's two lower tools: user members come from get_group_members and bots +// come from list_group_bots. Callers may provide a stable conversation ID or a +// human group name; name resolution remains ambiguity-safe. +var ChatMembersList = shortcut.Shortcut{ + Service: "chat", + Command: "+chat-members-list", + Product: "chat", + Description: "列出群成员并把用户与机器人分桶(支持群名语义解析)", + Intent: "当你要完整查看一个群的参与者,并需要区分真人用户和机器人时使用;" + + "可直接传 --conversation-id,或传 --group 群名让命令先做唯一匹配。" + + "默认同时返回 users/bots 两个桶,也可用 --member-types 只取 user 或 bot;" + + "用户桶会保留下层 hasMore/complete,避免把截断列表误判为完整。", + Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{ + {Name: "group", Type: shortcut.FlagString, Desc: "群名称(与 --conversation-id 二选一)"}, + {Name: "conversation-id", Type: shortcut.FlagString, Desc: "群 openConversationId(与 --group 二选一)"}, + {Name: "member-types", Type: shortcut.FlagStringSlice, Desc: "成员类型:user,bot;不传则同时返回"}, + }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintExactlyOne, Flags: []string{"group", "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")) + groupName := strings.TrimSpace(rt.Str("group")) + if groupID == "" { + resolved, err := resolveGroupName(rt, groupName) + if err != nil { + return err + } + groupID = resolved + } + + wantUsers, wantBots, err := resolveMemberTypes(rt.StrSlice("member-types")) + if err != nil { + return err + } + + payload := map[string]any{ + "conversationId": groupID, + "users": []map[string]any{}, + "bots": []map[string]any{}, + } + var userErr, botErr error + if wantUsers { + var data map[string]any + data, userErr = rt.CallMCPData("chat", "get_group_members", map[string]any{ + "openconversation_id": groupID, + }) + if userErr == nil { + payload["users"] = groupMemberProject(data) + chatmsg.ApplyPagination(payload, data) + } + } + if wantBots { + var data map[string]any + data, botErr = rt.CallMCPData("bot", "list_group_bots", map[string]any{ + "openConversationId": groupID, + }) + if botErr == nil { + payload["bots"] = groupBotProject(data) + } + } + if wantUsers && userErr != nil && !wantBots { + return userErr + } + if wantBots && botErr != nil && !wantUsers { + return botErr + } + if userErr != nil && botErr != nil { + return fmt.Errorf("读取用户成员失败: %v;读取机器人失败: %v", userErr, botErr) + } + errors := make([]map[string]any, 0, 1) + if userErr != nil { + errors = append(errors, map[string]any{"bucket": "users", "error": userErr.Error()}) + } + if botErr != nil { + errors = append(errors, map[string]any{"bucket": "bots", "error": botErr.Error()}) + } + if len(errors) > 0 { + payload["errors"] = errors + payload["partial"] = true + } + + users, _ := payload["users"].([]map[string]any) + bots, _ := payload["bots"].([]map[string]any) + payload["counts"] = map[string]any{ + "users": len(users), + "bots": len(bots), + "total": len(users) + len(bots), + } + return rt.Output(payload) + }, +} + +func resolveGroupName(rt *shortcut.RuntimeContext, groupName string) (string, error) { + data, err := rt.CallMCPData("im", "search_groups", map[string]any{ + "keyword": groupName, + "limit": 10, + "cursor": "0", + }) + if err != nil { + return "", err + } + groups := preferExactGroupMatches(extractGroupsForSend(data), groupName) + switch { + case len(groups) == 0: + return "", apperrors.NewValidation(fmt.Sprintf( + "没找到名字匹配 %q 的群;换用群名里连续的核心词再试。", groupName)) + case len(groups) > 1: + return "", apperrors.NewValidation(fmt.Sprintf( + "%q 匹配到 %d 个群:%s。请用更精确的群名,或传 --conversation-id 精确指定。", + groupName, len(groups), strings.Join(sendGroupLabels(groups), "、"))) + default: + return groups[0].id, nil + } +} + +func resolveMemberTypes(raw []string) (bool, bool, error) { + if len(raw) == 0 { + return true, true, nil + } + var users, bots bool + for _, value := range raw { + switch strings.ToLower(strings.TrimSpace(value)) { + case "user", "users": + users = true + case "bot", "bots": + bots = true + case "": + continue + default: + return false, false, apperrors.NewValidation( + fmt.Sprintf("--member-types 只支持 user,bot,收到 %q", value)) + } + } + if !users && !bots { + return false, false, apperrors.NewValidation("--member-types 至少包含 user 或 bot") + } + return users, bots, nil +} + +func groupBotProject(data map[string]any) []map[string]any { + var items []any + for _, root := range []map[string]any{data, groupMemberChildMap(data, "result")} { + if root == nil { + continue + } + for _, key := range []string{"bots", "robots", "list", "items"} { + if value, ok := root[key].([]any); ok { + items = value + break + } + } + if items != nil { + break + } + } + out := make([]map[string]any, 0, len(items)) + for _, item := range items { + bot, ok := item.(map[string]any) + if !ok { + continue + } + row := map[string]any{ + "name": groupMemberFirst(bot, "name", "robotName", "botName"), + "openDingtalkId": groupMemberFirst(bot, "botOpenDingTalkId", "openDingTalkId"), + "openBotId": groupMemberFirst(bot, "openBotId", "botId"), + "robotCode": groupMemberFirst(bot, "robotCode", "code"), + } + out = append(out, row) + } + return out +} + // groupMemberProject reshapes a get_group_members response into a clean // {name, nick, role, openDingtalkId} list. The real payload wraps members in // result.list[]; probe a few container shapes defensively. @@ -143,5 +325,5 @@ func groupMemberFirst(m map[string]any, keys ...string) string { } func init() { - shortcut.Register(GroupMembers) + shortcut.Register(GroupMembers, ChatMembersList) } diff --git a/internal/shortcut/smart/my_groups.go b/internal/shortcut/smart/my_groups.go index 89c18987..54fbeb51 100644 --- a/internal/shortcut/smart/my_groups.go +++ b/internal/shortcut/smart/my_groups.go @@ -18,6 +18,7 @@ import ( "strings" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg" ) // MyGroups: list the groups I've joined and project just the key fields @@ -47,6 +48,8 @@ var MyGroups = shortcut.Shortcut{ Risk: shortcut.RiskRead, Flags: []shortcut.Flag{ {Name: "type", Type: shortcut.FlagString, Desc: "按群类型过滤(可选,如返回中的 groupType/conversationType,大小写不敏感)", Required: false}, + {Name: "limit", Type: shortcut.FlagInt, Desc: "每页返回数量(默认 200)", Default: "200"}, + {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,翻页传上次的 nextCursor"}, }, Tips: []string{ `dws chat +my-groups`, @@ -55,9 +58,11 @@ var MyGroups = shortcut.Shortcut{ Execute: func(rt *shortcut.RuntimeContext) error { // Step 1 — list my groups. `limit` mirrors chat.go's list_my_groups_pagination // call site (chat group list-all); pass a generous page size. - data, err := rt.CallMCPData("im", "list_my_groups_pagination", map[string]any{ - "limit": 200, - }) + params := map[string]any{"limit": rt.Int("limit")} + if cursor := strings.TrimSpace(rt.Str("cursor")); cursor != "" && cursor != "0" { + params["cursor"] = cursor + } + data, err := rt.CallMCPData("im", "list_my_groups_pagination", params) if err != nil { return err } @@ -78,10 +83,12 @@ var MyGroups = shortcut.Shortcut{ projected = append(projected, row) } - return rt.Output(map[string]any{ + payload := map[string]any{ "count": len(projected), "groups": projected, - }) + } + chatmsg.ApplyPagination(payload, data) + return rt.Output(payload) }, } diff --git a/internal/shortcut/smart/search_msg.go b/internal/shortcut/smart/search_msg.go index 29e1c80b..61bba711 100644 --- a/internal/shortcut/smart/search_msg.go +++ b/internal/shortcut/smart/search_msg.go @@ -14,107 +14,377 @@ package smart import ( + "fmt" "strconv" "strings" "time" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg" ) -// SearchMsg: search messages inside a single group chat by keyword in one step. -// -// Steps: -// 1. compute the look-back window [now-Nd, now] in local time and express both -// bounds as epoch millis. N defaults to 7 days and is overridable via -// --days; mirroring `dws chat message search`, which feeds startTime/endTime -// as epoch millis to search_messages_by_keyword. -// 2. call search_messages_by_keyword on the chat server with keyword/startTime/ -// endTime/limit/cursor plus openConversationId — the exact parameter names -// and first-page defaults (limit 100, cursor "0") used by -// helpers.chatMessageSearchCmd. --query maps to keyword, --group maps to -// openConversationId. -// 3. defensively project each returned message down to {sender, time, text, -// messageId} (multiple candidate keys per field) and print via rt.Output so -// it honours --format/--jq/--fields. When the response carries no -// recognisable message list we fall back to printing the raw payload. -// -// This replaces manually working out the millisecond time window and copying the -// search incantation. Read-only: it only searches and reshapes, never sends, -// recalls or marks anything. -// -// dws chat +search-msg --group --query "changefree" -// dws chat +search-msg --group --query "周报" --days 3 +// SearchMsg is the semantic message-search entry point. It exposes the native +// IM search dimensions, can exhaust cursor pagination, and enriches sparse +// search hits through list_messages_by_ids in chunks of 50. A later-page or +// enrichment failure never turns a partial result into a false success: the +// output carries an explicit failure ledger and complete=false. var SearchMsg = shortcut.Shortcut{ Service: "chat", Command: "+search-msg", - Product: "chat", - Description: "在群内按关键词搜消息(默认近 7 天,投影发送人/时间/内容/messageId)", - Intent: "当你想在某个群里按关键词翻最近的聊天记录、但不想手动把起止时间换算成毫秒、也不想记 message search 的一堆参数时使用;" + - "用 --group 指定群的 openConversationId、--query 指定搜索关键词,内部按本地时区算出「最近 N 天」(默认 7 天,可用 --days 调整回溯天数)的时间窗," + - "搜索这段时间内该群里命中关键词的消息,再在本地把每条消息投影成发送人、时间、内容、messageId 四个关键字段。" + - "这是纯只读操作,只做搜索与本地投影,不会发送、撤回或标记任何消息。", + Product: "im", + Description: "多维搜索消息,可全量翻页并批量富化详情", + Intent: "当你要按关键词、发送者、@对象、会话、消息类型或机器人来源组合搜索 IM 消息时使用;默认查询近 7 天," + + "也可指定精确起止时间。--page-all 会连续拉取游标页,默认再按消息 ID 分批富化详情;任何续页或富化失败都会保留已取得结果并返回逐项失败 ledger,绝不把截断结果标成完整。", Risk: shortcut.RiskRead, Flags: []shortcut.Flag{ - {Name: "group", Type: shortcut.FlagString, Desc: "群会话的 openConversationId(必填)"}, + {Name: "query", Type: shortcut.FlagString, Desc: "搜索关键词"}, + {Name: "keyword", Type: shortcut.FlagString, Desc: "--query 的别名", Hidden: true}, + {Name: "group", Type: shortcut.FlagString, Desc: "单个会话 openConversationId"}, {Name: "conversation-id", Type: shortcut.FlagString, Desc: "--group 的别名", Hidden: true}, {Name: "id", Type: shortcut.FlagString, Desc: "--group 的别名", Hidden: true}, - {Name: "query", Type: shortcut.FlagString, Desc: "搜索关键词(必填)"}, - {Name: "keyword", Type: shortcut.FlagString, Desc: "--query 的别名", Hidden: true}, - {Name: "days", Type: shortcut.FlagInt, Desc: "回溯天数(可选,默认 7)", Default: "7", Required: false}, + {Name: "groups", Type: shortcut.FlagStringSlice, Desc: "多个会话 openConversationId"}, + {Name: "chat-id", Type: shortcut.FlagStringSlice, Desc: "--groups 的 lark-cli 对齐别名"}, + {Name: "senders", Type: shortcut.FlagStringSlice, Desc: "发送者 userId/openDingTalkId 列表"}, + {Name: "sender", Type: shortcut.FlagStringSlice, Desc: "--senders 的 lark-cli 对齐别名"}, + {Name: "at-me", Type: shortcut.FlagBool, Desc: "只搜索 @我 的消息"}, + {Name: "is-at-me", Type: shortcut.FlagBool, Desc: "--at-me 的 lark-cli 对齐别名"}, + {Name: "at-ids", Type: shortcut.FlagStringSlice, Desc: "@对象 userId/openDingTalkId 列表"}, + {Name: "message-type", Type: shortcut.FlagString, Desc: "下层消息类型过滤值(以当前 IM Schema 为准)"}, + {Name: "only-robot", Type: shortcut.FlagBool, Desc: "只搜索机器人消息"}, + {Name: "conversation-type", Type: shortcut.FlagString, Desc: "下层会话类型过滤值(以当前 IM Schema 为准)"}, + {Name: "chat-type", Type: shortcut.FlagString, Desc: "--conversation-type 的 lark-cli 对齐别名"}, + {Name: "days", Type: shortcut.FlagInt, Desc: "默认时间窗的回溯天数", Default: "7"}, + {Name: "start", Type: shortcut.FlagString, Desc: "精确开始时间(RFC3339,需与 --end 一起传)"}, + {Name: "end", Type: shortcut.FlagString, Desc: "精确结束时间(RFC3339,需与 --start 一起传)"}, + {Name: "limit", Type: shortcut.FlagInt, Desc: "每页返回数量(1-100)", Default: "100"}, + {Name: "page-size", Type: shortcut.FlagInt, Desc: "--limit 的 lark-cli 对齐别名(1-100)"}, + {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,翻页传上次的 nextCursor", Default: "0"}, + {Name: "page-token", Type: shortcut.FlagString, Desc: "--cursor 的 lark-cli 对齐别名"}, + {Name: "page-all", Type: shortcut.FlagBool, Desc: "自动连续拉取所有游标页"}, + {Name: "page-limit", Type: shortcut.FlagInt, Desc: "--page-all 的最大页数(1-40)", Default: "20"}, + {Name: "no-enrich", Type: shortcut.FlagBool, Desc: "不再按消息 ID 批量查询完整详情"}, + {Name: "no-reactions", Type: shortcut.FlagBool, Desc: "不输出命中消息的 reaction(默认输出)"}, }, Constraints: []shortcut.Constraint{ - {Kind: shortcut.ConstraintExactlyOne, Flags: []string{"group", "conversation-id", "id"}}, - {Kind: shortcut.ConstraintAtLeastOne, Flags: []string{"query", "keyword"}}, + { + Kind: shortcut.ConstraintCustom, + Flags: []string{"query", "group", "groups", "chat-id", "senders", "sender", "at-me", "is-at-me", "at-ids", "message-type", "only-robot", "conversation-type", "chat-type"}, + Description: "至少指定一个内容、身份、会话或消息类型过滤条件", + }, + { + Kind: shortcut.ConstraintCustom, + Flags: []string{"start", "end", "days"}, + Description: "--start 与 --end 必须同时指定;否则使用 --days 时间窗", + }, + {Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"groups", "chat-id"}}, + {Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"senders", "sender"}}, + {Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"at-me", "is-at-me"}}, + {Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"conversation-type", "chat-type"}}, + {Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"limit", "page-size"}}, + {Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"cursor", "page-token"}}, }, Tips: []string{ `dws chat +search-msg --group --query "changefree"`, - `dws chat +search-msg --group --query "周报" --days 3`, + `dws chat +search-msg --senders --at-me --days 3 --page-all`, }, + Validate: validateSearchMsg, Execute: func(rt *shortcut.RuntimeContext) error { - group := rt.StrFirst("group", "conversation-id", "id") - query := rt.StrFirst("query", "keyword") - - // Step 1 — look-back window [now-Nd, now] in epoch millis. days defaults - // to 7; guard against non-positive overrides so the window stays sane. - days := rt.Int("days") - if days <= 0 { - days = 7 - } - now := time.Now() - startMs := now.AddDate(0, 0, -days).UnixMilli() - endMs := now.UnixMilli() - - // Step 2 — search messages by keyword within the group. keyword/startTime/ - // endTime/limit/cursor + openConversationId and the first-page defaults - // (limit 100, cursor "0") mirror helpers.chatMessageSearchCmd's - // search_messages_by_keyword call. - data, err := rt.CallMCPData("chat", "search_messages_by_keyword", map[string]any{ - "keyword": query, - "startTime": startMs, - "endTime": endMs, - "limit": 100, - "cursor": "0", - "openConversationId": group, - }) + params, err := searchMsgParams(rt) if err != nil { return err } - // Step 3 — project matched messages; fall back to the raw payload when we - // cannot locate a recognisable message list. - items := searchMsgItems(data) - if len(items) == 0 { - return rt.Output(data) + pageLimit := 1 + if rt.Bool("page-all") { + pageLimit = rt.Int("page-limit") } - results := make([]map[string]any, 0, len(items)) - for _, m := range items { - results = append(results, searchMsgProject(m)) + cursor := rt.StrFirst("page-token", "cursor") + messages := make([]map[string]any, 0) + seen := map[string]bool{} + failures := make([]map[string]any, 0) + pagesFetched := 0 + complete := true + hasMore := false + nextCursor := "" + + for pagesFetched < pageLimit { + params["cursor"] = cursor + data, callErr := rt.CallMCPData("im", "search_messages", params) + if callErr != nil { + if pagesFetched == 0 { + return callErr + } + failures = append(failures, map[string]any{ + "stage": "search-page", + "cursor": cursor, + "error": callErr.Error(), + }) + complete = false + break + } + pagesFetched++ + for _, message := range searchMsgItems(data) { + messageID := strings.TrimSpace(fmt.Sprint(searchMsgMessageID(message))) + if messageID != "" && messageID != "" { + if seen[messageID] { + continue + } + seen[messageID] = true + } + messages = append(messages, message) + } + + page := chatmsg.Pagination(data) + hasMoreValue, hasMoreKnown := page["hasMore"].(bool) + nextCursor = strings.TrimSpace(fmt.Sprint(page["nextCursor"])) + if !hasMoreKnown { + if nextCursor != "" && nextCursor != "" { + hasMoreValue = true + } else { + failures = append(failures, map[string]any{ + "stage": "search-pagination", + "error": "下层未返回 hasMore 或 nextCursor,无法证明结果完整", + }) + complete = false + break + } + } + hasMore = hasMoreValue + if !rt.Bool("page-all") || !hasMore { + complete = !hasMore + break + } + if nextCursor == "" || nextCursor == "" || nextCursor == cursor { + failures = append(failures, map[string]any{ + "stage": "search-page", + "error": "下层返回 hasMore=true,但缺少可继续且会前进的 nextCursor", + }) + complete = false + break + } + cursor = nextCursor } - return rt.Output(map[string]any{"messages": results}) + if rt.Bool("page-all") && hasMore && pagesFetched == pageLimit { + failures = append(failures, map[string]any{ + "stage": "search-page-limit", + "error": fmt.Sprintf("达到 --page-limit=%d,仍有更多结果", pageLimit), + }) + complete = false + } + + enrichedCount := 0 + if !rt.Bool("no-enrich") && len(messages) > 0 { + var enrichFailures []map[string]any + messages, enrichedCount, enrichFailures = enrichSearchMessages(rt, messages) + failures = append(failures, enrichFailures...) + if len(enrichFailures) > 0 { + complete = false + } + } + + results := make([]map[string]any, 0, len(messages)) + for _, m := range messages { + results = append(results, searchMsgProjectWithReactions(m, !rt.Bool("no-reactions"))) + } + payload := map[string]any{ + "count": len(results), + "messages": results, + "pagesFetched": pagesFetched, + "enrichedCount": enrichedCount, + "complete": complete, + "hasMore": hasMore, + "failedCount": len(failures), + "failures": failures, + } + if hasMore && nextCursor != "" && nextCursor != "" { + payload["nextCursor"] = nextCursor + } + return rt.Output(payload) }, } +func validateSearchMsg(rt *shortcut.RuntimeContext) error { + hasFilter := rt.StrFirst("query", "keyword", "group", "conversation-id", "id", "message-type", "conversation-type", "chat-type") != "" || + len(rt.StrSlice("groups")) > 0 || + len(rt.StrSlice("chat-id")) > 0 || + len(rt.StrSlice("senders")) > 0 || + len(rt.StrSlice("sender")) > 0 || + len(rt.StrSlice("at-ids")) > 0 || + rt.Bool("at-me") || + rt.Bool("is-at-me") || + rt.Bool("only-robot") + if !hasFilter { + return apperrors.NewValidation("至少指定一个过滤条件,例如 --query、--group、--senders、--at-me 或 --message-type") + } + if rt.Changed("start") != rt.Changed("end") { + return apperrors.NewValidation("--start 与 --end 必须同时指定") + } + if days := rt.Int("days"); days < 1 || days > 3650 { + return apperrors.NewValidation("--days 必须在 1-3650 之间") + } + limit := rt.IntFirst("limit", "page-size") + if limit < 1 || limit > 100 { + return apperrors.NewValidation("--limit 必须在 1-100 之间") + } + if pageLimit := rt.Int("page-limit"); pageLimit < 1 || pageLimit > 40 { + return apperrors.NewValidation("--page-limit 必须在 1-40 之间") + } + return nil +} + +func searchMsgParams(rt *shortcut.RuntimeContext) (map[string]any, error) { + params := map[string]any{"limit": rt.IntFirst("limit", "page-size")} + if value := rt.StrFirst("query", "keyword"); value != "" { + params["keyword"] = value + } + conversationIDs := append([]string{}, rt.StrSlice("groups")...) + conversationIDs = append(conversationIDs, rt.StrSlice("chat-id")...) + if value := rt.StrFirst("group", "conversation-id", "id"); value != "" { + conversationIDs = append(conversationIDs, value) + } + if values := uniqueSearchStrings(conversationIDs); len(values) > 0 { + params["openConversationIds"] = values + } + senders := append([]string{}, rt.StrSlice("senders")...) + senders = append(senders, rt.StrSlice("sender")...) + appendSearchActorIDs(params, senders, "senderUserIds", "senderOpenDingTakIds") + appendSearchActorIDs(params, rt.StrSlice("at-ids"), "atUserIds", "atOpenDingTakIds") + if rt.Bool("at-me") || rt.Bool("is-at-me") { + params["atMe"] = true + } + if value := rt.Str("message-type"); value != "" { + params["messageType"] = value + } + if rt.Changed("only-robot") { + params["onlyRobotMessages"] = rt.Bool("only-robot") + } + if value := rt.StrFirst("conversation-type", "chat-type"); value != "" { + params["searchConvType"] = value + } + + if rt.Changed("start") && rt.Changed("end") { + start, err := time.Parse(time.RFC3339, rt.Str("start")) + if err != nil { + return nil, apperrors.NewValidation(fmt.Sprintf("--start 必须是 RFC3339 时间: %v", err)) + } + end, err := time.Parse(time.RFC3339, rt.Str("end")) + if err != nil { + return nil, apperrors.NewValidation(fmt.Sprintf("--end 必须是 RFC3339 时间: %v", err)) + } + if !end.After(start) { + return nil, apperrors.NewValidation("--end 必须晚于 --start") + } + params["startTime"] = start.UnixMilli() + params["endTime"] = end.UnixMilli() + } else { + now := time.Now() + params["startTime"] = now.AddDate(0, 0, -rt.Int("days")).UnixMilli() + params["endTime"] = now.UnixMilli() + } + return params, nil +} + +func appendSearchActorIDs(params map[string]any, values []string, userKey, openIDKey string) { + var userIDs, openIDs []string + for _, value := range uniqueSearchStrings(values) { + if len(value) > 0 && (value[0] == 'D' || value[0] == 'd') { + openIDs = append(openIDs, value) + } else { + userIDs = append(userIDs, value) + } + } + if len(userIDs) > 0 { + params[userKey] = userIDs + } + if len(openIDs) > 0 { + params[openIDKey] = openIDs + } +} + +func uniqueSearchStrings(values []string) []string { + out := make([]string, 0, len(values)) + seen := map[string]bool{} + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" || seen[value] { + continue + } + seen[value] = true + out = append(out, value) + } + return out +} + +func enrichSearchMessages(rt *shortcut.RuntimeContext, messages []map[string]any) ([]map[string]any, int, []map[string]any) { + detailsByID := map[string]map[string]any{} + failures := make([]map[string]any, 0) + ids := make([]string, 0, len(messages)) + for _, message := range messages { + if id := strings.TrimSpace(fmt.Sprint(searchMsgMessageID(message))); id != "" && id != "" { + ids = append(ids, id) + } + } + ids = uniqueSearchStrings(ids) + for start := 0; start < len(ids); start += 50 { + end := start + 50 + if end > len(ids) { + end = len(ids) + } + chunk := ids[start:end] + data, err := rt.CallMCPData("im", "list_messages_by_ids", map[string]any{"openMsgIds": chunk}) + if err != nil { + failures = append(failures, map[string]any{ + "stage": "message-enrichment", + "messageIds": chunk, + "error": err.Error(), + }) + continue + } + foundInChunk := map[string]bool{} + for _, detail := range searchMsgItems(data) { + if id := strings.TrimSpace(fmt.Sprint(searchMsgMessageID(detail))); id != "" && id != "" { + detailsByID[id] = detail + foundInChunk[id] = true + } + } + missing := make([]string, 0) + for _, id := range chunk { + if !foundInChunk[id] { + missing = append(missing, id) + } + } + if len(missing) > 0 { + failures = append(failures, map[string]any{ + "stage": "message-enrichment", + "missingMessageIds": missing, + "error": "mget 未返回全部请求消息", + }) + } + } + + enriched := 0 + out := make([]map[string]any, 0, len(messages)) + for _, message := range messages { + id := strings.TrimSpace(fmt.Sprint(searchMsgMessageID(message))) + detail := detailsByID[id] + if detail == nil { + out = append(out, message) + continue + } + merged := make(map[string]any, len(message)+len(detail)) + for key, value := range message { + merged[key] = value + } + for key, value := range detail { + merged[key] = value + } + out = append(out, merged) + enriched++ + } + return out, enriched, failures +} + // searchMsgItems locates the message list inside a search_messages_by_keyword // response, probing common container keys at the top level and nested under // "result". Returns nil when no list is found. @@ -153,13 +423,43 @@ func searchMsgToMaps(arr []any) []map[string]any { // JSON → readable, ciphertext → marker) and recursively expanding any forwarded // chat record under "forwarded". func searchMsgProject(m map[string]any) map[string]any { + return searchMsgProjectWithReactions(m, true) +} + +func searchMsgProjectWithReactions(m map[string]any, includeReactions bool) map[string]any { row := map[string]any{ "sender": searchMsgSender(m), "time": searchMsgTime(m), "text": searchMsgCleanText(m), "messageId": searchMsgMessageID(m), } - if forwarded := chatmsg.Forwarded(m, searchMsgProject); len(forwarded) > 0 { + if conversationID := chatmsg.ConversationID(m); conversationID != nil { + row["conversationId"] = conversationID + } + if threadID := chatmsg.ThreadID(m); threadID != nil { + row["threadId"] = threadID + } + if messageType := chatmsg.MessageType(m); messageType != nil { + row["messageType"] = messageType + } + if updateTime := chatmsg.UpdateTime(m); updateTime != nil { + row["updateTime"] = updateTime + } + if includeReactions { + if reactions := chatmsg.Reactions(m); len(reactions) > 0 { + row["reactions"] = reactions + } + } + if quoted := chatmsg.QuotedMessage(m); len(quoted) > 0 { + row["quotedMessage"] = quoted + } + if resources := chatmsg.Resources(m); len(resources) > 0 { + row["resourceRefs"] = resources + } + projectForwarded := func(item map[string]any) map[string]any { + return searchMsgProjectWithReactions(item, includeReactions) + } + if forwarded := chatmsg.Forwarded(m, projectForwarded); len(forwarded) > 0 { row["forwarded"] = forwarded } return row diff --git a/internal/shortcut/smart/search_msg_execution_test.go b/internal/shortcut/smart/search_msg_execution_test.go new file mode 100644 index 00000000..6cabbff6 --- /dev/null +++ b/internal/shortcut/smart/search_msg_execution_test.go @@ -0,0 +1,201 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. + +package smart + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "reflect" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" +) + +type searchMsgExecutionCaller struct { + calls []platformCoverageCall + failSecondPage bool + failEnrichment bool + omitPagination bool + omitMgetItem bool +} + +func (f *searchMsgExecutionCaller) CallTool(_ context.Context, product, tool string, args map[string]any) (*edition.ToolResult, error) { + f.calls = append(f.calls, platformCoverageCall{product: product, tool: tool, args: args}) + if product != "im" { + return nil, errors.New("unexpected product") + } + switch tool { + case "search_messages": + if f.omitPagination { + return searchMsgToolResult(`{"result":{"messages":[{"openMessageId":"m1","content":"sparse-1"}]}}`), nil + } + if args["cursor"] == "c2" { + if f.failSecondPage { + return nil, errors.New("second page unavailable") + } + return searchMsgToolResult(`{"result":{"messages":[{"openMessageId":"m2","content":"sparse-2"}],"hasMore":false}}`), nil + } + return searchMsgToolResult(`{"result":{"messages":[{"openMessageId":"m1","content":"sparse-1"}],"hasMore":true,"nextCursor":"c2"}}`), nil + case "list_messages_by_ids": + if f.failEnrichment { + return nil, errors.New("mget unavailable") + } + if f.omitMgetItem { + return searchMsgToolResult(`{"result":[{"openMessageId":"m1","content":"detail-1"}]}`), nil + } + return searchMsgToolResult(`{"result":[{"openMessageId":"m1","content":"detail-1"},{"openMessageId":"m2","content":"detail-2"}]}`), nil + default: + return nil, errors.New("unexpected tool") + } +} + +func (f *searchMsgExecutionCaller) CallReadTool(ctx context.Context, product, tool string, args map[string]any) (*edition.ToolResult, error) { + return f.CallTool(ctx, product, tool, args) +} + +func (*searchMsgExecutionCaller) Format() string { return "json" } +func (*searchMsgExecutionCaller) DryRun() bool { return false } +func (*searchMsgExecutionCaller) Fields() string { return "" } +func (*searchMsgExecutionCaller) JQ() string { return "" } + +func searchMsgToolResult(text string) *edition.ToolResult { + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}} +} + +func executeSearchMsg(t *testing.T, caller *searchMsgExecutionCaller, args ...string) map[string]any { + t.Helper() + helpers.InitDeps(caller) + root := newPlatformCoverageRoot() + var output bytes.Buffer + root.SetOut(&output) + root.SetArgs(append([]string{"chat", "+search-msg"}, args...)) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + var payload map[string]any + if err := json.Unmarshal(output.Bytes(), &payload); err != nil { + t.Fatalf("decode output: %v\n%s", err, output.String()) + } + return payload +} + +func TestSearchMsgPagesAndEnrichesWithAdvancedFilters(t *testing.T) { + caller := &searchMsgExecutionCaller{} + payload := executeSearchMsg(t, caller, + "--query", "周报", + "--chat-id", "cid-1,cid-2", + "--sender", "42,Dsender", + "--at-ids", "43,Dat", + "--is-at-me", + "--message-type", "text", + "--only-robot", + "--chat-type", "group", + "--start", "2026-07-01T00:00:00+08:00", + "--end", "2026-07-02T00:00:00+08:00", + "--page-size", "50", + "--page-token", "p0", + "--page-all", + "--page-limit", "3", + ) + + if len(caller.calls) != 3 { + t.Fatalf("calls = %#v", caller.calls) + } + first := caller.calls[0] + if first.product != "im" || first.tool != "search_messages" { + t.Fatalf("first call = %#v", first) + } + for key, want := range map[string]any{ + "openConversationIds": []string{"cid-1", "cid-2"}, + "senderUserIds": []string{"42"}, + "senderOpenDingTakIds": []string{"Dsender"}, + "atUserIds": []string{"43"}, + "atOpenDingTakIds": []string{"Dat"}, + "messageType": "text", + "onlyRobotMessages": true, + "searchConvType": "group", + } { + if !reflect.DeepEqual(first.args[key], want) { + t.Errorf("%s = %#v, want %#v", key, first.args[key], want) + } + } + if caller.calls[1].args["cursor"] != "c2" { + t.Fatalf("second cursor = %#v", caller.calls[1].args["cursor"]) + } + if ids := caller.calls[2].args["openMsgIds"]; !reflect.DeepEqual(ids, []string{"m1", "m2"}) { + t.Fatalf("mget ids = %#v", ids) + } + if payload["complete"] != true || payload["count"] != float64(2) || + payload["pagesFetched"] != float64(2) || payload["enrichedCount"] != float64(2) || + payload["failedCount"] != float64(0) { + t.Fatalf("payload = %#v", payload) + } + messages, _ := payload["messages"].([]any) + firstMessage, _ := messages[0].(map[string]any) + if firstMessage["text"] != "detail-1" { + t.Fatalf("enriched message = %#v", firstMessage) + } +} + +func TestSearchMsgLaterPageFailurePublishesPartialLedger(t *testing.T) { + caller := &searchMsgExecutionCaller{failSecondPage: true} + payload := executeSearchMsg(t, caller, + "--query", "周报", + "--page-all", + "--no-enrich", + ) + if payload["complete"] != false || payload["count"] != float64(1) || + payload["failedCount"] != float64(1) { + t.Fatalf("payload = %#v", payload) + } + failures, _ := payload["failures"].([]any) + failure, _ := failures[0].(map[string]any) + if failure["stage"] != "search-page" { + t.Fatalf("failure = %#v", failure) + } +} + +func TestSearchMsgEnrichmentFailureKeepsSearchHits(t *testing.T) { + caller := &searchMsgExecutionCaller{failEnrichment: true} + payload := executeSearchMsg(t, caller, "--query", "周报") + if payload["complete"] != false || payload["count"] != float64(1) || + payload["enrichedCount"] != float64(0) || payload["failedCount"] != float64(1) { + t.Fatalf("payload = %#v", payload) + } +} + +func TestSearchMsgMissingPaginationCannotClaimComplete(t *testing.T) { + caller := &searchMsgExecutionCaller{omitPagination: true} + payload := executeSearchMsg(t, caller, "--query", "周报", "--no-enrich") + if payload["complete"] != false || payload["count"] != float64(1) || + payload["failedCount"] != float64(1) { + t.Fatalf("payload = %#v", payload) + } + failures, _ := payload["failures"].([]any) + failure, _ := failures[0].(map[string]any) + if failure["stage"] != "search-pagination" { + t.Fatalf("failure = %#v", failure) + } +} + +func TestSearchMsgMissingMgetItemPublishesFailureLedger(t *testing.T) { + caller := &searchMsgExecutionCaller{omitMgetItem: true} + payload := executeSearchMsg(t, caller, "--query", "周报", "--page-all") + if payload["complete"] != false || payload["count"] != float64(2) || + payload["enrichedCount"] != float64(1) || payload["failedCount"] != float64(1) { + t.Fatalf("payload = %#v", payload) + } + failures, _ := payload["failures"].([]any) + failure, _ := failures[0].(map[string]any) + if failure["stage"] != "message-enrichment" { + t.Fatalf("failure = %#v", failure) + } + if missing, _ := failure["missingMessageIds"].([]any); len(missing) != 1 || missing[0] != "m2" { + t.Fatalf("failure = %#v", failure) + } +} diff --git a/internal/shortcut/smart/send_to_group.go b/internal/shortcut/smart/send_to_group.go index 99c82214..7fb2d9ee 100644 --- a/internal/shortcut/smart/send_to_group.go +++ b/internal/shortcut/smart/send_to_group.go @@ -61,7 +61,7 @@ var SendToGroup = shortcut.Shortcut{ if err != nil { return err } - groups := extractGroupsForSend(data) + groups := preferExactGroupMatches(extractGroupsForSend(data), groupName) switch { case len(groups) == 0: return apperrors.NewValidation(fmt.Sprintf( @@ -132,6 +132,34 @@ func extractGroupsForSend(data map[string]any) []sendGroupMatch { return out } +// preferExactGroupMatches keeps name-based routing ambiguity-safe while +// avoiding a common false ambiguity from substring search. If the server +// returns exactly one group whose title equals the requested name, that exact +// group wins over prefix/suffix matches. Duplicate rows for the same +// openConversationId are collapsed before selection. +func preferExactGroupMatches(groups []sendGroupMatch, query string) []sendGroupMatch { + unique := make([]sendGroupMatch, 0, len(groups)) + seen := make(map[string]struct{}, len(groups)) + for _, group := range groups { + if _, ok := seen[group.id]; ok { + continue + } + seen[group.id] = struct{}{} + unique = append(unique, group) + } + query = strings.TrimSpace(query) + exact := make([]sendGroupMatch, 0, 1) + for _, group := range unique { + if strings.EqualFold(strings.TrimSpace(group.name), query) { + exact = append(exact, group) + } + } + if len(exact) > 0 { + return exact + } + return unique +} + func sendGroupLabels(groups []sendGroupMatch) []string { out := make([]string, 0, len(groups)) for _, g := range groups { diff --git a/internal/shortcut/smart/thread_replies.go b/internal/shortcut/smart/thread_replies.go index 2dc1c25a..5d387c1f 100644 --- a/internal/shortcut/smart/thread_replies.go +++ b/internal/shortcut/smart/thread_replies.go @@ -15,6 +15,7 @@ package smart import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg" ) // ThreadReplies: fetch every reply under one topic ("话题") message and print a @@ -31,26 +32,35 @@ import ( // Read-only: it only reads a topic's replies and reshapes them locally, never // posts or mutates anything. // -// dws chat +thread-replies --group --topic-id +// dws chat +thread-replies --group --thread-id var ThreadReplies = shortcut.Shortcut{ Service: "chat", Command: "+thread-replies", Product: "chat", Description: "拉取某条话题消息的全部回复并投影出发言人/文本/时间", - Intent: "当你已经拿到某个群里一条「话题消息」的 topicId、想快速看这条话题下的全部回复(谁在什么时间回复了什么)," + - "而不想拿到一大坨原始消息字段时使用;内部按 --group(群会话 ID)和 --topic-id(话题 ID)拉取该话题的回复列表," + + Intent: "当你已经拿到某个群里一条「话题消息」的 threadId/topicId、想快速看这条话题下的全部回复(谁在什么时间回复了什么)," + + "而不想拿到一大坨原始消息字段时使用;内部按 --group(群会话 ID)和 --thread-id(兼容 --topic-id)拉取该话题的回复列表," + "可选 --time 指定起始时间、--limit 指定每页条数,再在本地投影出每条回复的发言人、文本和回复时间。" + "这是纯只读操作,只做拉取与本地投影,不会发送或修改任何消息。", Risk: shortcut.RiskRead, Flags: []shortcut.Flag{ {Name: "group", Type: shortcut.FlagString, Desc: "群会话 ID(openConversationId,必填)", Required: true}, - {Name: "topic-id", Type: shortcut.FlagString, Desc: "话题 ID(由 dws chat message list 返回,必填)", Required: true}, + {Name: "thread-id", Type: shortcut.FlagString, Desc: "话题/线程 ID(可直接使用消息列表返回的 threadId)"}, + {Name: "topic-id", Type: shortcut.FlagString, Desc: "--thread-id 的兼容别名"}, {Name: "time", Type: shortcut.FlagString, Desc: "起始时间,如 \"2025-03-01 00:00:00\"(可选)"}, {Name: "limit", Type: shortcut.FlagInt, Desc: "每页拉取的回复条数(可选)"}, + {Name: "no-reactions", Type: shortcut.FlagBool, Desc: "不输出回复 reaction(默认输出)"}, + }, + Constraints: []shortcut.Constraint{ + { + Kind: shortcut.ConstraintExactlyOne, + Flags: []string{"thread-id", "topic-id"}, + Description: "--thread-id 与兼容参数 --topic-id 必须且只能指定一个", + }, }, Tips: []string{ - `dws chat +thread-replies --group --topic-id `, - `dws chat +thread-replies --group --topic-id --time "2025-03-01 00:00:00" --limit 20`, + `dws chat +thread-replies --group --thread-id `, + `dws chat +thread-replies --group --thread-id --time "2025-03-01 00:00:00" --limit 20`, }, Execute: func(rt *shortcut.RuntimeContext) error { // Step 1 — fetch the topic replies. Param keys (openconversationId / @@ -58,7 +68,7 @@ var ThreadReplies = shortcut.Shortcut{ // list-topic-replies call site. params := map[string]any{ "openconversationId": rt.Str("group"), - "topicId": rt.Str("topic-id"), + "topicId": rt.StrFirst("thread-id", "topic-id"), "forward": false, } if rt.Changed("time") && rt.Str("time") != "" { @@ -78,13 +88,15 @@ var ThreadReplies = shortcut.Shortcut{ items := threadReplyItems(data) results := make([]map[string]any, 0, len(items)) for _, m := range items { - results = append(results, projectChatMessage(m)) + results = append(results, projectChatMessageWithReactions(m, !rt.Bool("no-reactions"))) } - return rt.Output(map[string]any{ + payload := map[string]any{ "replies": results, "count": len(results), - }) + } + chatmsg.ApplyMessagePagination(payload, data, items, "older") + return rt.Output(payload) }, } diff --git a/internal/shortcut/types.go b/internal/shortcut/types.go index 039d766c..f0ca09cf 100644 --- a/internal/shortcut/types.go +++ b/internal/shortcut/types.go @@ -39,6 +39,27 @@ const ( RiskHighWrite Risk = "high-risk-write" ) +// SemanticDisposition records the reviewed relationship between a Shortcut and +// the underlying Runtime Schema surface. +type SemanticDisposition string + +const ( + DispositionPrimarySmart SemanticDisposition = "primary_smart" + DispositionSemanticAdapter SemanticDisposition = "semantic_adapter" + DispositionSchemaLeaf SemanticDisposition = "schema_leaf" + DispositionAliasInternal SemanticDisposition = "alias_internal" +) + +// Availability is independent from live-account evidence. A missing fixture or +// permission does not make an implemented command unavailable. +type Availability string + +const ( + AvailabilityAvailable Availability = "available" + AvailabilityUnavailable Availability = "unavailable" + AvailabilityDeprecated Availability = "deprecated" +) + // FlagType is the pflag value type a Flag registers as. type FlagType string @@ -127,6 +148,21 @@ type Shortcut struct { Tips []string // Hidden hides the command from listings while keeping it invocable. Hidden bool + // Disposition is the reviewed semantic relation to the Runtime Schema leaf + // surface. It determines default Agent discovery independently from live + // fixture evidence. + Disposition SemanticDisposition + // SemanticDelta explains the concrete value added beyond renaming a leaf. + SemanticDelta string + // Availability describes whether the implementation is shipped and + // callable; it does not encode the current account's permissions. + Availability Availability + // PrimaryCommand links aliases/internal compatibility entries to their + // preferred semantic entry. + PrimaryCommand string + // SemanticReviewed is true only when the disposition and visibility came + // from the reviewed semantic catalog. + SemanticReviewed bool // UserDefined identifies shortcuts loaded from the user's config // directory. Distribution-owned Schema and interface snapshots exclude // these runtime extensions even if another root loaded them earlier. diff --git a/internal/shortcut/usage/command.go b/internal/shortcut/usage/command.go index 8ba5436c..e8f36358 100644 --- a/internal/shortcut/usage/command.go +++ b/internal/shortcut/usage/command.go @@ -50,12 +50,13 @@ func newListCommand() *cobra.Command { "leaf Schema 提供 Agent 选择、参数、约束、安全与接口契约,Cobra help 提供当前可执行 flags。", RunE: func(cmd *cobra.Command, _ []string) error { svc, _ := cmd.Flags().GetString("service") + includeHidden, _ := cmd.Flags().GetBool("all") rows := make([]shortcutListRow, 0) for _, s := range shortcut.All() { if svc != "" && s.Service != svc { continue } - if s.Hidden { + if s.Hidden && !includeHidden { continue } rows = append(rows, newShortcutListRow(s)) @@ -69,21 +70,28 @@ func newListCommand() *cobra.Command { }, } cmd.Flags().String("service", "", "只列出指定服务的 shortcut") + cmd.Flags().Bool("all", false, "包含当前未进入公开 Catalog、但仍可直接调用的 shortcut") return cmd } type shortcutListRow struct { - Service string `json:"service"` - Command string `json:"command"` - CLIPath string `json:"cli_path"` - Product string `json:"product"` - Risk string `json:"risk"` - Confirmation string `json:"confirmation"` - Description string `json:"description"` - Intent string `json:"intent,omitempty"` - Flags []shortcut.Flag `json:"flags"` - Constraints []shortcut.Constraint `json:"constraints"` - Examples []string `json:"examples"` + Service string `json:"service"` + Command string `json:"command"` + CLIPath string `json:"cli_path"` + Product string `json:"product"` + Risk string `json:"risk"` + Confirmation string `json:"confirmation"` + Description string `json:"description"` + Intent string `json:"intent,omitempty"` + Flags []shortcut.Flag `json:"flags"` + Constraints []shortcut.Constraint `json:"constraints"` + Examples []string `json:"examples"` + Public bool `json:"public"` + Disposition string `json:"disposition,omitempty"` + SemanticDelta string `json:"semantic_delta,omitempty"` + Availability string `json:"availability,omitempty"` + Primary string `json:"primary,omitempty"` + Reviewed bool `json:"reviewed"` } func newShortcutListRow(s shortcut.Shortcut) shortcutListRow { @@ -115,17 +123,23 @@ func newShortcutListRow(s shortcut.Shortcut) shortcutListRow { constraints := append([]shortcut.Constraint{}, s.Constraints...) examples := append([]string{}, s.Tips...) return shortcutListRow{ - Service: s.Service, - Command: s.Command, - CLIPath: s.Service + " " + s.Command, - Product: product, - Risk: risk, - Confirmation: confirmation, - Description: s.Description, - Intent: s.Intent, - Flags: flags, - Constraints: constraints, - Examples: examples, + Service: s.Service, + Command: s.Command, + CLIPath: s.Service + " " + s.Command, + Product: product, + Risk: risk, + Confirmation: confirmation, + Description: s.Description, + Intent: s.Intent, + Flags: flags, + Constraints: constraints, + Examples: examples, + Public: !s.Hidden, + Disposition: string(s.Disposition), + SemanticDelta: s.SemanticDelta, + Availability: string(s.Availability), + Primary: s.PrimaryCommand, + Reviewed: s.SemanticReviewed, } } diff --git a/internal/shortcut/usage/usage_test.go b/internal/shortcut/usage/usage_test.go index da9f56a3..3d1a103c 100644 --- a/internal/shortcut/usage/usage_test.go +++ b/internal/shortcut/usage/usage_test.go @@ -42,6 +42,39 @@ func TestCrossPlatformCoverageShortcutListDeclaresRuntimeSchemaDelivery(t *testi } } +func TestCrossPlatformCoverageShortcutListFiltersHiddenAndService(t *testing.T) { + shortcut.Register(shortcut.Shortcut{ + Service: "coverage-usage", + Command: "+hidden", + }) + + execute := func(args ...string) map[string]any { + t.Helper() + cmd := newListCommand() + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetArgs(args) + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + var payload map[string]any + if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil { + t.Fatal(err) + } + return payload + } + + publicRows := execute("--service", "coverage-usage") + allRows := execute("--service", "coverage-usage", "--all") + if publicRows["count"].(float64) != 0 || allRows["count"].(float64) != 1 { + t.Fatalf("hidden shortcuts were not filtered: public=%v all=%v", publicRows["count"], allRows["count"]) + } + missing := execute("--service", "__missing__") + if missing["count"].(float64) != 0 { + t.Fatalf("missing service returned shortcuts: %#v", missing) + } +} + func TestCrossPlatformCoverageShortcutListRowPublishesCompleteContract(t *testing.T) { row := newShortcutListRow(shortcut.Shortcut{ Service: "chat", diff --git a/pkg/edition/edition.go b/pkg/edition/edition.go index fbdf52f3..d5e77e6c 100644 --- a/pkg/edition/edition.go +++ b/pkg/edition/edition.go @@ -59,6 +59,19 @@ type ToolCaller interface { JQ() string } +// ReadToolCaller is an optional capability for a narrowly classified read +// lookup that must still execute while the outer command is rendering a +// dry-run plan. Implementations must fail closed unless they can bypass the +// global write barrier without weakening it for ordinary CallTool calls. +// +// The Shortcut runtime uses this only after accepting a narrow read-only tool +// name classification. +// Keeping it separate from ToolCaller means existing callers remain protected +// by the default "dry-run executes nothing" contract. +type ReadToolCaller interface { + CallReadTool(ctx context.Context, productID, toolName string, args map[string]any) (*ToolResult, error) +} + // RuntimeDefaultFn resolves a single runtimeDefault placeholder (e.g. // "$currentUserId") to a concrete string value. Called lazily at RunE time. // Returning (_, false) is equivalent to "not registered" and falls through diff --git a/scripts/gen_shortcut_public_catalog.py b/scripts/gen_shortcut_public_catalog.py index 3d7f748e..40012b48 100644 --- a/scripts/gen_shortcut_public_catalog.py +++ b/scripts/gen_shortcut_public_catalog.py @@ -18,6 +18,7 @@ GO_PATH = ROOT / "internal" / "shortcut" / "public_catalog_generated.go" CATALOG_PATH = ROOT / "docs" / "shortcut-public-catalog.json" FOLLOWUP_MD_PATH = ROOT / "docs" / "shortcut-real-test-followups.md" FOLLOWUP_JSON_PATH = ROOT / "docs" / "shortcut-real-test-followups.json" +SEMANTIC_PATH = ROOT / "internal" / "shortcut" / "semantic_catalog.json" def load(path: Path) -> dict[str, Any]: @@ -61,9 +62,15 @@ def evidence(row: dict[str, Any]) -> str: def collect() -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: - public: list[dict[str, Any]] = [] + evidence_public: list[dict[str, Any]] = [] followups: list[dict[str, Any]] = [] - for suite, path in (("read", READ_PATH), ("write", WRITE_PATH)): + evidence_by_key: dict[tuple[str, str], dict[str, Any]] = {} + result_paths = [ + (suite, path) + for suite, path in (("read", READ_PATH), ("write", WRITE_PATH)) + if path.exists() + ] + for suite, path in result_paths: data = load(path) for row in data.get("results", []): item = { @@ -73,8 +80,9 @@ def collect() -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: "risk": row.get("risk") or "", "status": row.get("status") or "", } + evidence_by_key[(item["service"], item["command"])] = item if row.get("status") == "real-ok": - public.append(item) + evidence_public.append(item) continue followups.append({ **item, @@ -83,6 +91,60 @@ def collect() -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: "diagnosis": row.get("diagnosis") or "", "evidence": evidence(row), }) + + # The raw real-run captures can be intentionally absent because they may + # contain account-specific business data. In that case retain the committed + # non-Chat evidence/follow-ups and only rebuild the reviewed Chat surface. + if not result_paths: + for row in load(CATALOG_PATH).get("results", []): + item = dict(row) + evidence_public.append(item) + evidence_by_key[(item.get("service") or "", item.get("command") or "")] = item + if FOLLOWUP_JSON_PATH.exists(): + followups = load(FOLLOWUP_JSON_PATH).get("results", []) + + # Chat visibility is a reviewed semantic decision, not a side effect of + # whether the current account happened to have a fixture for a real run. + # Keep the real-run rows as evidence/follow-ups, but publish Chat entries + # exclusively from the reviewed semantic catalog. + semantic = load(SEMANTIC_PATH) + service = semantic.get("service") or "" + if service != "chat": + raise ValueError(f"unexpected semantic catalog service: {service!r}") + public = [row for row in evidence_public if row["service"] != service] + for command, record in semantic.get("shortcuts", {}).items(): + if not record.get("public"): + continue + if not record.get("reviewed"): + raise ValueError(f"public semantic shortcut is not reviewed: {command}") + availability = ( + record.get("availability") + or semantic.get("default_availability") + or "" + ) + if availability != "available": + raise ValueError( + f"public semantic shortcut is not available: {command}={availability}" + ) + observed = evidence_by_key.get((service, command), {}) + risk = record.get("risk") or "" + if not risk: + raise ValueError(f"public semantic shortcut lacks reviewed risk: {command}") + if observed.get("risk") and observed["risk"] != risk: + raise ValueError( + f"semantic shortcut risk drift: {command}: " + f"reviewed={risk} observed={observed['risk']}" + ) + public.append({ + "suite": "semantic", + "service": service, + "command": command, + "risk": risk, + "status": "reviewed_available", + "disposition": record.get("disposition") or "", + "semantic_delta": record.get("semantic_delta") or "", + "availability": availability, + }) public.sort(key=lambda r: (r["service"], r["command"])) followups.sort(key=lambda r: (r["suite"], r["service"], r["command"])) return public, followups diff --git a/scripts/run_chat_shortcut_live_audit.py b/scripts/run_chat_shortcut_live_audit.py new file mode 100755 index 00000000..3047dca5 --- /dev/null +++ b/scripts/run_chat_shortcut_live_audit.py @@ -0,0 +1,1162 @@ +#!/usr/bin/env python3 +"""Run a PII-safe live audit of every read-only Chat Shortcut. + +The audit exercises the real Shortcut entry point and, when DWS_DUMP_RAW is +enabled by the locally built binary, captures the lower MCP text response from +the same invocation. Raw business responses are kept in memory only. The +written JSON/Markdown reports contain shapes, counts, tool names, and redacted +error facts, but no message text, user names, IDs, or invite/download URLs. + +This is deliberately a live-account audit, not a mock/Help contract check. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import signal +import subprocess +import sys +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +SCRIPT_DIR = Path(__file__).resolve().parent +sys.path.insert(0, str(SCRIPT_DIR)) + +from shortcut_real_result import count_projection_items, payload_indicates_error # noqa: E402 + + +READ_SHORTCUTS = ( + "+at-me", + "+bot-find", + "+bot-search", + "+category-list", + "+category-list-conversations", + "+chat-bots", + "+chat-get-by-id", + "+chat-invite-url", + "+chat-list-all", + "+chat-list-join-requests", + "+chat-list-mine", + "+chat-members-list", + "+chat-members-get", + "+chat-messages", + "+chat-role-list", + "+chat-role-query-user", + "+chat-search", + "+conversation-info", + "+conversation-list", + "+conversation-list-top", + "+group-members", + "+messages-list", + "+messages-list-direct", + "+messages-list-pin", + "+messages-list-unread-conversations", + "+messages-mget", + "+messages-query-send-status", + "+messages-read-status", + "+messages-resource-download", + "+messages-resource-url", + "+my-groups", + "+search-msg", + "+thread-replies", + "+unread-chats", +) + +LIST_PATHS_BY_TOOL = { + "search_at_me_message": ( + ("result", "conversationMessagesList", "*", "messages"), + ), + "search_bots": (("result", "bots"), ("result", "list")), + "search_my_robots": (("result", "robots"), ("result", "list")), + "list_user_define_conv_categories": ( + ("result", "categories"), + ("result", "categoryList"), + ), + "list_conversations_by_category": ( + ("result", "conversations"), + ("result", "conversationList"), + ), + "list_group_bots": (("result", "bots"), ("result", "robots")), + "list_my_groups_pagination": (("result", "groups"),), + "list_owned_or_admin_groups": (("result", "groups"),), + "list_group_member_by_ids": ( + ("result", "members"), + ("result", "list"), + ), + "list_custom_group_roles": (("result", "roles"), ("result", "list")), + "list_all_conversations": ( + ("result", "conversations"), + ("result", "conversationList"), + ), + "list_top_conversations": ( + ("result", "conversations"), + ("result", "conversationList"), + ), + "get_group_members": (("result", "list"), ("result", "members")), + "list_conversation_message_v2": ( + ("result", "messages"), + ("result", "messageList"), + ), + "list_individual_chat_message": ( + ("result", "messages"), + ("result", "messageList"), + ), + "list_pin_messages": ( + ("result", "messages"), + ("result", "pinMessages"), + ("result", "list"), + ), + "unread_message_conversation_list": ( + ("result", "conversations"), + ("result", "conversationList"), + ), + "list_messages_by_ids": (("result", "messages"), ("result", "list")), + "search_messages_by_keyword": ( + ("result", "messages"), + ("result", "messageList"), + ("result", "list"), + ), + "list_topic_replies": ( + ("result", "messages"), + ("result", "replies"), + ("result", "list"), + ), +} + +@dataclass +class Capture: + command: str + argv: list[str] + exit_code: int | None + stdout: str + stderr: str + raw_calls: list[tuple[str, str, Any]] + duration_ms: int + timed_out: bool = False + + def upper(self) -> Any: + try: + return json.loads(self.stdout) + except (json.JSONDecodeError, TypeError): + return None + + +def parse_raw_calls(stderr: str) -> tuple[list[tuple[str, str, Any]], str]: + raw_calls: list[tuple[str, str, Any]] = [] + clean_lines: list[str] = [] + for line in (stderr or "").splitlines(): + if not line.startswith("DWSRAW\t"): + clean_lines.append(line) + continue + fields = line.split("\t", 3) + if len(fields) != 4: + continue + try: + raw_calls.append((fields[1], fields[2], json.loads(fields[3]))) + except json.JSONDecodeError: + raw_calls.append((fields[1], fields[2], fields[3])) + return raw_calls, "\n".join(clean_lines) + + +def run_capture(binary: Path, command: str, args: list[str], timeout: int) -> Capture: + argv = [str(binary), "chat", command, *args, "--format", "json"] + env = os.environ.copy() + env["DWS_DUMP_RAW"] = "1" + started = time.monotonic() + proc = subprocess.Popen( + argv, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env=env, + start_new_session=True, + ) + try: + stdout, stderr = proc.communicate(timeout=timeout) + raw_calls, clean_stderr = parse_raw_calls(stderr) + return Capture( + command=command, + argv=argv, + exit_code=proc.returncode, + stdout=stdout, + stderr=clean_stderr, + raw_calls=raw_calls, + duration_ms=round((time.monotonic() - started) * 1000), + ) + except subprocess.TimeoutExpired as exc: + # dws may have transport children which inherit its output pipes. + # Killing only the immediate process leaves those descendants alive and + # communicate() can block forever despite the advertised timeout. + os.killpg(proc.pid, signal.SIGKILL) + stdout, stderr = proc.communicate() + raw_calls, clean_stderr = parse_raw_calls(stderr) + return Capture( + command=command, + argv=argv, + exit_code=None, + stdout=stdout, + stderr=clean_stderr, + raw_calls=raw_calls, + duration_ms=round((time.monotonic() - started) * 1000), + timed_out=True, + ) + + +def at_path(value: Any, path: tuple[str, ...]) -> list[Any] | None: + current = value + for index, part in enumerate(path): + if part == "*": + if not isinstance(current, list): + return None + remainder = path[index + 1 :] + combined: list[Any] = [] + for item in current: + found = at_path(item, remainder) + if isinstance(found, list): + combined.extend(found) + return combined + if not isinstance(current, dict) or part not in current: + return None + current = current[part] + return current if isinstance(current, list) else None + + +def lower_count(tool: str, raw: Any) -> int | None: + for path in LIST_PATHS_BY_TOOL.get(tool, ()): + found = at_path(raw, path) + if found is not None: + return len(found) + return None + + +def response_is_error(value: Any) -> bool: + if not isinstance(value, dict): + return False + if value.get("success") is False: + return True + if str(value.get("status") or "").lower() == "error": + return True + for key in ("error", "errorCode", "error_code", "code"): + item = value.get(key) + if item not in (None, False, "", 0, "0", "ok", "success", "succeed"): + return True + return False + + +def error_facts(stderr: str) -> dict[str, Any]: + facts: dict[str, Any] = {} + try: + value = json.loads(stderr) + error = value.get("error", value) if isinstance(value, dict) else {} + if isinstance(error, dict): + for key in ("category", "code", "reason", "server_error_code", "server_key"): + if key in error: + facts[key] = error[key] + message = str(error.get("message") or "") + else: + message = stderr + except json.JSONDecodeError: + message = stderr + lowered = message.lower() + for marker, label in ( + ("permission", "permission"), + ("not found", "not_found"), + ("不存在", "not_found"), + ("invalid", "invalid_argument"), + ("必填", "missing_argument"), + ("超时", "timeout"), + ): + if marker in lowered: + facts["message_class"] = label + break + return facts + + +def shape(value: Any) -> dict[str, Any]: + if isinstance(value, dict): + return {"type": "object", "keys": sorted(value)} + if isinstance(value, list): + return {"type": "array", "length": len(value)} + if value is None: + return {"type": "none"} + return {"type": type(value).__name__} + + +def reaction_message_count(value: Any, *, upper: bool) -> int: + """Count reaction-bearing message objects without retaining business values.""" + count = 0 + if isinstance(value, dict): + keys = ("reactions", "emotionReplyList", "reactionList") if upper else ( + "emotionReplyList", + "reactionList", + "reactions", + ) + if any(value.get(key) not in (None, [], {}) for key in keys): + count += 1 + for item in value.values(): + count += reaction_message_count(item, upper=upper) + elif isinstance(value, list): + for item in value: + count += reaction_message_count(item, upper=upper) + return count + + +def thread_message_count(value: Any, *, upper: bool) -> int: + keys = {"threadId"} if upper else { + "openConvThreadId", + "openConversationThreadId", + "threadId", + "topicId", + } + count = 0 + if isinstance(value, dict): + if any(value.get(key) not in (None, "") for key in keys): + count += 1 + for item in value.values(): + count += thread_message_count(item, upper=upper) + elif isinstance(value, list): + for item in value: + count += thread_message_count(item, upper=upper) + return count + + +def pagination_meta(value: Any) -> dict[str, Any]: + """Extract only completeness facts; never retain cursor values.""" + if isinstance(value, dict): + has_more = None + for key in ("hasMore", "has_more"): + if key in value and isinstance(value[key], bool): + has_more = value[key] + break + cursor_present = any( + value.get(key) not in (None, "", 0, "0") + for key in ("nextCursor", "next_cursor", "nextToken", "next_token", "pageToken", "page_token") + ) + next_page = value.get("nextPage") + resume_present = cursor_present or ( + isinstance(next_page, dict) + and next_page.get("time") not in (None, "") + ) + if has_more is not None or resume_present: + return { + "has_more": has_more, + "next_cursor_present": cursor_present, + "resume_present": resume_present, + } + for item in value.values(): + found = pagination_meta(item) + if found: + return found + elif isinstance(value, list): + for item in value: + found = pagination_meta(item) + if found: + return found + return {} + + +def summarize_capture(capture: Capture) -> dict[str, Any]: + upper = capture.upper() + raw = capture.raw_calls[-1][2] if capture.raw_calls else None + tool = capture.raw_calls[-1][1] if capture.raw_calls else None + upper_count = count_projection_items(capture.stdout) + expected_lower_count = lower_count(tool, raw) if tool else None + pagination_raw = raw + if capture.command == "+chat-members-list": + counts = upper.get("counts", {}) if isinstance(upper, dict) else {} + upper_count = counts.get("total") if isinstance(counts, dict) else None + expected_lower_count = 0 + for _, call_tool, call_raw in capture.raw_calls: + if call_tool in {"get_group_members", "list_group_bots"}: + item_count = lower_count(call_tool, call_raw) + if item_count is not None: + expected_lower_count += item_count + if call_tool == "get_group_members": + pagination_raw = call_raw + elif capture.command == "+messages-resource-download": + upper_count = ( + 1 + if isinstance(upper, dict) + and isinstance(upper.get("sizeBytes"), (int, float)) + and upper.get("sizeBytes") >= 0 + and bool(upper.get("localPath")) + else 0 + ) + expected_lower_count = ( + 1 + if first_string(raw, "resourceUrl", "downloadUrl") + else 0 + ) + exact_passthrough = bool(capture.raw_calls and upper == raw) + upper_reactions = reaction_message_count(upper, upper=True) + lower_reactions = reaction_message_count(raw, upper=False) + upper_threads = thread_message_count(upper, upper=True) + lower_threads = thread_message_count(raw, upper=False) + upper_pagination = pagination_meta(upper) + lower_pagination = pagination_meta(pagination_raw) + + if capture.timed_out: + status = "timeout" + elif capture.exit_code != 0: + status = "backend_error" if capture.stderr else "cli_error" + elif payload_indicates_error(capture.stdout) or response_is_error(raw): + status = "error_envelope" + elif not capture.raw_calls: + status = "no_lower_capture" + elif exact_passthrough: + status = "pass" + elif expected_lower_count is not None and upper_count != expected_lower_count: + status = "projection_mismatch" + elif lower_reactions > 0 and upper_reactions != lower_reactions: + status = "projection_mismatch" + elif lower_threads > 0 and upper_threads != lower_threads: + status = "projection_mismatch" + elif lower_pagination.get("has_more") is True and ( + upper_pagination.get("has_more") is not True + or not upper_pagination.get("resume_present") + ): + status = "pagination_mismatch" + elif expected_lower_count == 0 and upper_count == 0: + status = "pass_empty" + else: + status = "pass" + + return { + "command": capture.command, + "status": status, + "exit_code": capture.exit_code, + "duration_ms": capture.duration_ms, + "upper": { + **shape(upper), + "count": upper_count, + "reaction_message_count": upper_reactions, + "thread_message_count": upper_threads, + "pagination": upper_pagination, + }, + "lower": { + "call_count": len(capture.raw_calls), + "tools": [f"{server}/{name}" for server, name, _ in capture.raw_calls], + "shape": shape(raw), + "count": expected_lower_count, + "reaction_message_count": lower_reactions, + "thread_message_count": lower_threads, + "pagination": lower_pagination, + }, + "exact_passthrough": exact_passthrough, + "error": error_facts(capture.stderr) if capture.stderr else {}, + } + + +def blocked(command: str, requirement: str) -> dict[str, Any]: + return { + "command": command, + "status": "fixture_blocked", + "requirement": requirement, + } + + +def dig_values(value: Any, keys: set[str]) -> list[Any]: + found: list[Any] = [] + if isinstance(value, dict): + for key, item in value.items(): + if key in keys and item not in (None, "", [], {}): + found.append(item) + found.extend(dig_values(item, keys)) + elif isinstance(value, list): + for item in value: + found.extend(dig_values(item, keys)) + elif isinstance(value, str): + stripped = value.strip() + if stripped.startswith(("{", "[")): + try: + embedded = json.loads(stripped) + except json.JSONDecodeError: + embedded = None + if isinstance(embedded, (dict, list)): + found.extend(dig_values(embedded, keys)) + return found + + +def first_string(value: Any, *keys: str) -> str: + for item in dig_values(value, set(keys)): + if isinstance(item, str) and item.strip(): + return item.strip() + if isinstance(item, (int, float)) and not isinstance(item, bool): + return str(int(item)) + return "" + + +def first_list(value: Any, *keys: str) -> list[Any]: + for item in dig_values(value, set(keys)): + if isinstance(item, list): + return item + return [] + + +def safe_keyword(messages: list[Any]) -> str: + for message in messages: + if not isinstance(message, dict): + continue + text = message.get("text") + if not isinstance(text, str): + continue + for token in re.findall(r"[\u4e00-\u9fff]{2,6}|[A-Za-z0-9]{3,12}", text): + if token: + return token + return "工作" + + +def group_candidates(capture: Capture) -> list[dict[str, Any]]: + raw = capture.raw_calls[-1][2] if capture.raw_calls else {} + groups = first_list(raw, "groups") + valid = [item for item in groups if isinstance(item, dict)] + priority = {"OWNER": 0, "ADMIN": 1} + return sorted(valid, key=lambda item: priority.get(str(item.get("myRole")), 2)) + + +def merge_group_candidates( + groups_all: Capture, groups_mine: Capture +) -> list[dict[str, Any]]: + all_groups = group_candidates(groups_all) + mine_groups = group_candidates(groups_mine) + ordered: list[dict[str, Any]] = [] + seen: set[str] = set() + for item in mine_groups: + group_id = str(item.get("openConversationId") or "") + if not group_id or group_id in seen: + continue + # The owned/admin endpoint carries myRole and ownerOpenDingtalkId, + # which are needed to locate a message sent by the current user. Prefer + # that richer record; the all-groups page is only a fallback. + ordered.append(item) + seen.add(group_id) + for item in all_groups: + group_id = str(item.get("openConversationId") or "") + if group_id and group_id not in seen: + ordered.append(item) + seen.add(group_id) + return ordered + + +def live_audit( + binary: Path, timeout: int, max_group_probes: int, max_member_probes: int +) -> list[dict[str, Any]]: + results: dict[str, dict[str, Any]] = {} + captures: dict[str, Capture] = {} + group_override = os.environ.get("DWS_AUDIT_GROUP_ID", "").strip() + numeric_group_override = os.environ.get("DWS_AUDIT_NUMERIC_GROUP_ID", "").strip() + open_task_override = os.environ.get("DWS_AUDIT_OPEN_TASK_ID", "").strip() + topic_group_override = os.environ.get("DWS_AUDIT_TOPIC_GROUP_ID", "").strip() + topic_id_override = os.environ.get("DWS_AUDIT_TOPIC_ID", "").strip() + media_group_override = os.environ.get("DWS_AUDIT_MEDIA_GROUP_ID", "").strip() + media_message_override = os.environ.get("DWS_AUDIT_MEDIA_MESSAGE_ID", "").strip() + media_resource_override = os.environ.get("DWS_AUDIT_MEDIA_RESOURCE_ID", "").strip() + + def run(command: str, *args: str, retries: int = 0) -> Capture: + capture = run_capture(binary, command, list(args), timeout) + for _ in range(retries): + if summarize_capture(capture)["status"] not in { + "backend_error", + "cli_error", + "error_envelope", + "timeout", + "no_lower_capture", + }: + break + capture = run_capture(binary, command, list(args), timeout) + captures[command] = capture + results[command] = summarize_capture(capture) + return capture + + category = run("+category-list") + groups_all = run("+chat-list-all", "--limit", "200") + groups_mine = run("+chat-list-mine", "--limit", "200") + run("+conversation-list", "--limit", "100") + run("+conversation-list-top", "--limit", "100") + run("+messages-list-unread-conversations", "--count", "100") + run("+unread-chats", "--count", "100") + run("+chat-list-join-requests", "--limit", "50") + run("+at-me", "--days", "30") + run("+bot-search", "--page", "1", "--size", "100") + run("+bot-find", "--query", "机器人", "--limit", "20") + run("+my-groups") + + category_id = first_string(category.upper(), "categoryId", "category_id") + if category_id: + run("+category-list-conversations", "--category-id", category_id) + else: + results["+category-list-conversations"] = blocked( + "+category-list-conversations", + "账号下需要至少一个真实会话分组 categoryId", + ) + + candidates = merge_group_candidates(groups_all, groups_mine) + group = candidates[0] if candidates else {} + messages: Capture | None = None + best_group: dict[str, Any] = {} + best_score = -1 + # A valid but quiet first group is not sufficient evidence for the + # message-dependent Shortcuts. Walk real joined groups until one produces a + # non-empty message page. Prefer a page that also supplies downstream + # fixtures (a message sent by the current user, media, or a thread ID). + # This prevents the audit from stopping at the first technically valid but + # fixture-poor group. + probe_candidates = candidates[:max_group_probes] + if group_override: + forced = next( + ( + candidate + for candidate in candidates + if str(candidate.get("openConversationId") or "") == group_override + ), + {"openConversationId": group_override}, + ) + probe_candidates = [forced] + for candidate in probe_candidates: + candidate_id = str(candidate.get("openConversationId") or "") + if not candidate_id: + continue + probe = run_capture( + binary, + "+messages-list", + [ + "--group", + candidate_id, + "--time", + time.strftime("%Y-%m-%d %H:%M:%S"), + "--forward=false", + "--limit", + "50", + ], + timeout, + ) + probe_summary = summarize_capture(probe) + if probe_summary["status"] not in {"pass", "pass_empty"}: + continue + rows = first_list(probe.upper(), "messages") + own_open_id = ( + str(candidate.get("ownerOpenDingtalkId") or "") + if str(candidate.get("myRole") or "") == "OWNER" + else "" + ) + own_message = any( + isinstance(item, dict) and item.get("senderId") == own_open_id + for item in rows + ) + probe_raw = probe.raw_calls[-1][2] if probe.raw_calls else {} + has_resource = bool( + first_string(probe_raw, "mediaId", "resourceId", "downloadCode") + ) + has_thread = bool( + first_string( + probe_raw, + "openConvThreadId", + "openConversationThreadId", + "threadId", + "topicId", + ) + ) + score = ( + int(probe_summary.get("upper", {}).get("count") or 0) + + (1000 if own_message else 0) + + (500 if has_resource else 0) + + (500 if has_thread else 0) + ) + if score > best_score: + best_score = score + best_group = candidate + messages = probe + if messages is not None: + group = best_group + + group_id = str(group.get("openConversationId") or "") + group_name = str(group.get("title") or group.get("name") or "") + + if group_id: + conversation = run("+conversation-info", "--group", group_id) + run("+chat-invite-url", "--group", group_id, "--expires-seconds", "60") + run("+chat-bots", "--group", group_id) + run("+chat-role-list", "--group", group_id) + run("+messages-list-pin", "--open-conversation-id", group_id, "--size", "100") + if messages is None: + messages = run( + "+messages-list", + "--group", + group_id, + "--time", + time.strftime("%Y-%m-%d %H:%M:%S"), + "--forward=false", + "--limit", + "50", + ) + else: + captures["+messages-list"] = messages + results["+messages-list"] = summarize_capture(messages) + run( + "+chat-messages", + "--group", + group_id, + "--time", + time.strftime("%Y-%m-%d %H:%M:%S"), + "--direction", + "older", + "--limit", + "50", + retries=2, + ) + if group_name: + run("+chat-search", "--query", group_name, "--limit", "20") + members = run("+group-members", "--group", group_name) + run("+chat-members-list", "--group", group_name) + else: + results["+chat-search"] = blocked("+chat-search", "真实群需要有可搜索标题") + results["+group-members"] = blocked("+group-members", "真实群需要有可搜索标题") + results["+chat-members-list"] = blocked( + "+chat-members-list", "真实群需要有可搜索标题" + ) + members = None + + numeric_group_id = numeric_group_override or first_string( + conversation.raw_calls[-1][2] if conversation.raw_calls else {}, + "groupId", + "group_id", + ) + if numeric_group_id and numeric_group_id.isdigit(): + run("+chat-get-by-id", "--group-id", numeric_group_id) + else: + results["+chat-get-by-id"] = blocked( + "+chat-get-by-id", + "需要一个真实数字群号 groupId;openConversationId 不能替代", + ) + + member_rows = ( + first_list(members.upper(), "members") if members is not None else [] + ) + member_open_id = first_string( + member_rows, + "openDingtalkId", + "openDingTalkId", + "memberDingtalkId", + ) + if member_open_id: + run( + "+chat-members-get", + "--id", + group_id, + "--users", + member_open_id, + ) + role_query_capture: Capture | None = None + for member in member_rows[:max_member_probes]: + candidate_open_id = first_string( + member, + "openDingtalkId", + "openDingTalkId", + "memberDingtalkId", + ) + if not candidate_open_id: + continue + probe = run_capture( + binary, + "+chat-role-query-user", + ["--group", group_id, "--user", candidate_open_id], + timeout, + ) + role_query_capture = probe + if summarize_capture(probe)["status"] in {"pass", "pass_empty"}: + break + if role_query_capture is not None: + captures["+chat-role-query-user"] = role_query_capture + results["+chat-role-query-user"] = summarize_capture( + role_query_capture + ) + direct_capture: Capture | None = None + cross_org_only = True + for member in member_rows[:max_member_probes]: + candidate_open_id = first_string( + member, + "openDingtalkId", + "openDingTalkId", + "memberDingtalkId", + ) + if not candidate_open_id: + continue + probe = run_capture( + binary, + "+messages-list-direct", + [ + "--open-dingtalk-id", + candidate_open_id, + "--time", + time.strftime("%Y-%m-%d %H:%M:%S"), + "--forward=false", + "--limit", + "20", + ], + timeout, + ) + summary = summarize_capture(probe) + if summary["status"] in {"pass", "pass_empty"}: + direct_capture = probe + break + if summary.get("error", {}).get("server_error_code") != ( + "CrossOrgPermissionDenied" + ): + cross_org_only = False + direct_capture = probe + break + if direct_capture is not None: + captures["+messages-list-direct"] = direct_capture + results["+messages-list-direct"] = summarize_capture(direct_capture) + elif cross_org_only: + results["+messages-list-direct"] = blocked( + "+messages-list-direct", + "需要一个当前账号有权读取单聊记录的同组织成员", + ) + else: + requirement = "需要一个可读取 openDingTalkId 的真实群成员" + for command in ( + "+chat-members-get", + "+chat-role-query-user", + "+messages-list-direct", + ): + results[command] = blocked(command, requirement) + + message_rows = first_list(messages.upper(), "messages") + own_open_id = ( + str(group.get("ownerOpenDingtalkId") or "") + if str(group.get("myRole") or "") == "OWNER" + else "" + ) + own_message_rows = [ + item + for item in message_rows + if isinstance(item, dict) and item.get("senderId") == own_open_id + ] + message_id = first_string( + own_message_rows or message_rows, + "messageId", + "openMessageId", + "openMsgId", + ) + if message_id: + run("+messages-mget", "--msg-ids", message_id) + if own_message_rows: + run( + "+messages-read-status", + "--conversation-id", + group_id, + "--message-id", + message_id, + ) + else: + results["+messages-read-status"] = blocked( + "+messages-read-status", + "需要当前账号自己发送的一条真实消息 openMessageId", + ) + else: + requirement = "需要该群内至少一条可读取的真实消息 openMessageId" + results["+messages-mget"] = blocked("+messages-mget", requirement) + results["+messages-read-status"] = blocked( + "+messages-read-status", requirement + ) + + keyword = safe_keyword(message_rows) + run( + "+search-msg", + "--group", + group_id, + "--query", + keyword, + "--days", + "30", + ) + + raw_messages = messages.raw_calls[-1][2] if messages.raw_calls else {} + topic_id = topic_id_override or first_string( + raw_messages, + "openConvThreadId", + "openConversationThreadId", + "threadId", + "topicId", + ) + if topic_id: + run( + "+thread-replies", + "--group", + topic_group_override or group_id, + "--topic-id", + topic_id, + "--time", + time.strftime("%Y-%m-%d %H:%M:%S"), + "--limit", + "20", + ) + else: + results["+thread-replies"] = blocked( + "+thread-replies", + "需要一个真实话题群及其 topic/thread ID", + ) + + resource_id = media_resource_override + resource_message_id = media_message_override + resource_group_id = media_group_override or group_id + for raw_message in first_list(raw_messages, "messages", "messageList"): + if resource_id and resource_message_id: + break + if not isinstance(raw_message, dict): + continue + candidate_resource_id = first_string( + raw_message, + "mediaId", + "resourceId", + "downloadCode", + ) + candidate_message_id = first_string( + raw_message, + "openMessageId", + "openMsgId", + ) + if candidate_resource_id and candidate_message_id: + resource_id = candidate_resource_id + resource_message_id = candidate_message_id + break + if resource_id and resource_message_id: + run( + "+messages-resource-url", + "--type", + "mediaId", + "--resource-id", + resource_id, + "--message-id", + resource_message_id, + "--open-conversation-id", + resource_group_id, + ) + download_output = ( + Path("tmp") + / "im-shortcut-live-audit" + / f".read-resource-{os.getpid()}-{time.time_ns()}.bin" + ) + download_output.parent.mkdir(parents=True, exist_ok=True) + try: + download_capture = run( + "+messages-resource-download", + "--type", + "mediaId", + "--resource-id", + resource_id, + "--message-id", + resource_message_id, + "--open-conversation-id", + resource_group_id, + "--output", + str(download_output), + ) + download_summary = results["+messages-resource-download"] + upper_download = download_capture.upper() + expected_size = ( + upper_download.get("sizeBytes") + if isinstance(upper_download, dict) + else None + ) + if ( + download_summary["status"] == "pass" + and ( + not download_output.is_file() + or not isinstance(expected_size, (int, float)) + or download_output.stat().st_size != expected_size + ) + ): + download_summary["status"] = "local_artifact_mismatch" + finally: + download_output.unlink(missing_ok=True) + else: + results["+messages-resource-url"] = blocked( + "+messages-resource-url", + "需要一条含 mediaId 的真实图片/视频/语音消息", + ) + results["+messages-resource-download"] = blocked( + "+messages-resource-download", + "需要一条含 mediaId 的真实图片/视频/语音消息", + ) + else: + requirement = "当前账号需要至少加入一个可读真实群" + for command in ( + "+conversation-info", + "+chat-invite-url", + "+chat-bots", + "+chat-role-list", + "+messages-list-pin", + "+messages-list", + "+chat-messages", + "+chat-search", + "+group-members", + "+chat-members-list", + "+chat-get-by-id", + "+chat-members-get", + "+chat-role-query-user", + "+messages-list-direct", + "+messages-mget", + "+messages-read-status", + "+search-msg", + "+thread-replies", + "+messages-resource-download", + "+messages-resource-url", + ): + results[command] = blocked(command, requirement) + + open_task_id = open_task_override + for capture in captures.values(): + if open_task_id: + break + for _, _, raw in capture.raw_calls: + open_task_id = first_string(raw, "openTaskId", "taskId") + if open_task_id: + break + if open_task_id: + break + if open_task_id: + run("+messages-query-send-status", "--open-task-id", open_task_id) + else: + results["+messages-query-send-status"] = blocked( + "+messages-query-send-status", + "需要一次真实消息发送返回的 openTaskId", + ) + + return [results.get(command, blocked(command, "审计器未覆盖")) for command in READ_SHORTCUTS] + + +def report_markdown(results: list[dict[str, Any]]) -> str: + counts: dict[str, int] = {} + for result in results: + counts[result["status"]] = counts.get(result["status"], 0) + 1 + lines = [ + "# Chat Shortcut live read audit", + "", + "This report was produced through the real Shortcut entry point and the", + "lower MCP response captured from the same invocation. It intentionally", + "contains no business payload values.", + "", + f"- Total: {len(results)}", + ] + for status in sorted(counts): + lines.append(f"- {status}: {counts[status]}") + lines.extend( + [ + "", + "| Shortcut | Status | Upper | Lower | Reactions U/L | Threads U/L | MCP calls | Evidence / requirement |", + "|---|---|---:|---:|---:|---:|---:|---|", + ] + ) + for result in results: + upper = result.get("upper", {}).get("count") + lower = result.get("lower", {}).get("count") + calls = result.get("lower", {}).get("call_count") + upper_reactions = result.get("upper", {}).get("reaction_message_count") + lower_reactions = result.get("lower", {}).get("reaction_message_count") + upper_threads = result.get("upper", {}).get("thread_message_count") + lower_threads = result.get("lower", {}).get("thread_message_count") + tools = ", ".join(result.get("lower", {}).get("tools", [])) + evidence = result.get("requirement") or tools + lines.append( + f"| `{result['command']}` | {result['status']} | " + f"{'' if upper is None else upper} | " + f"{'' if lower is None else lower} | " + f"{'' if upper_reactions is None else upper_reactions}/" + f"{'' if lower_reactions is None else lower_reactions} | " + f"{'' if upper_threads is None else upper_threads}/" + f"{'' if lower_threads is None else lower_threads} | " + f"{'' if calls is None else calls} | {evidence} |" + ) + lines.append("") + return "\n".join(lines) + + +def validate_report(results: list[dict[str, Any]]) -> None: + commands = [result["command"] for result in results] + if tuple(commands) != READ_SHORTCUTS: + raise RuntimeError("read Shortcut audit coverage drifted from the expected set") + encoded = json.dumps(results, ensure_ascii=False) + for forbidden in ("stdout", "stderr", "backend_raw", "raw_backend", "inviteUrl"): + if f'"{forbidden}"' in encoded: + raise RuntimeError(f"sanitized report contains forbidden field {forbidden!r}") + if "https://" in encoded or "http://" in encoded: + raise RuntimeError("sanitized report appears to contain a URL") + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--dws", required=True, type=Path, help="locally built dws binary") + parser.add_argument("--out-dir", required=True, type=Path) + parser.add_argument("--timeout", type=int, default=60) + parser.add_argument("--max-group-probes", type=int, default=8) + parser.add_argument("--max-member-probes", type=int, default=8) + args = parser.parse_args() + + binary = args.dws.resolve() + if not binary.is_file(): + parser.error(f"--dws is not a file: {binary}") + numeric_group_id = os.environ.get("DWS_AUDIT_NUMERIC_GROUP_ID", "").strip() + if numeric_group_id and not numeric_group_id.isdigit(): + parser.error("DWS_AUDIT_NUMERIC_GROUP_ID must contain only digits") + topic_override = [ + os.environ.get("DWS_AUDIT_TOPIC_GROUP_ID", "").strip(), + os.environ.get("DWS_AUDIT_TOPIC_ID", "").strip(), + ] + if any(topic_override) and not all(topic_override): + parser.error("topic fixture override requires both group and topic IDs") + media_override = [ + os.environ.get("DWS_AUDIT_MEDIA_GROUP_ID", "").strip(), + os.environ.get("DWS_AUDIT_MEDIA_MESSAGE_ID", "").strip(), + os.environ.get("DWS_AUDIT_MEDIA_RESOURCE_ID", "").strip(), + ] + if any(media_override) and not all(media_override): + parser.error("media fixture override requires group, message, and resource IDs") + results = live_audit( + binary, + args.timeout, + args.max_group_probes, + args.max_member_probes, + ) + validate_report(results) + + args.out_dir.mkdir(parents=True, exist_ok=True) + json_path = args.out_dir / "chat-read-live-audit.json" + markdown_path = args.out_dir / "chat-read-live-audit.md" + json_path.write_text( + json.dumps( + { + "scope": "chat-read-shortcuts", + "count": len(results), + "results": results, + }, + ensure_ascii=False, + indent=2, + ) + + "\n", + encoding="utf-8", + ) + markdown_path.write_text(report_markdown(results), encoding="utf-8") + print(markdown_path) + + failures = { + "projection_mismatch", + "pagination_mismatch", + "backend_error", + "cli_error", + "error_envelope", + "timeout", + "no_lower_capture", + "local_artifact_mismatch", + } + return 1 if any(result["status"] in failures for result in results) else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run_chat_shortcut_live_write_audit.py b/scripts/run_chat_shortcut_live_write_audit.py new file mode 100644 index 00000000..87731fb9 --- /dev/null +++ b/scripts/run_chat_shortcut_live_write_audit.py @@ -0,0 +1,865 @@ +#!/usr/bin/env python3 +"""Run reversible Chat Shortcut writes against disposable DWS fixtures. + +The script creates single-user groups whose names start with +``DWS-SHORTCUT-AUDIT``. It invokes the real Shortcut entry point, captures the +lower MCP response from the same call, rolls back toggles where supported, and +dismisses the groups in ``finally``. The persisted report contains only command +names, lower tool names, counts, and sanitized error classes. + +This is intentionally separate from the exhaustive 57-command dry-run audit: +commands requiring another human actor, a configured robot/Webhook, or a +pending join request are not fabricated and remain listed as external-fixture +requirements. +""" + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from run_chat_shortcut_live_audit import ( + Capture, + error_facts, + first_list, + first_string, + response_is_error, + run_capture, +) + + +EXTERNAL_FIXTURE_COMMANDS = { + "+chat-add-bot": "requires_configured_robot", + "+chat-audit-join": "requires_pending_join_request_and_second_actor", + "+chat-mute-member": "requires_second_test_member", + "+chat-remove-bot": "requires_configured_robot", + "+chat-set-admin": "requires_second_test_member", + "+chat-transfer-owner": "requires_second_test_member", + "+messages-batch-recall-by-bot": "requires_robot_code_and_process_key", + "+messages-batch-send-by-bot": "requires_robot_code", + "+messages-recall-by-bot": "requires_robot_code_and_process_key", + "+messages-send-by-bot": "requires_robot_code", + "+messages-send-by-webhook": "requires_webhook_token", +} + + +@dataclass +class NativeResult: + exit_code: int | None + payload: Any + + +def native( + binary: Path, + args: list[str], + timeout: int, + *, + accept_error: bool = False, +) -> NativeResult: + try: + proc = subprocess.run( + [str(binary), *args, "--format", "json"], + capture_output=True, + text=True, + timeout=timeout, + check=False, + ) + except subprocess.TimeoutExpired: + return NativeResult(None, {}) + try: + payload = json.loads(proc.stdout or proc.stderr) + except json.JSONDecodeError: + payload = {} + if not accept_error and ( + proc.returncode != 0 or not isinstance(payload, dict) or response_is_error(payload) + ): + raise RuntimeError(f"native DWS fixture command failed: {' '.join(args[:3])}") + return NativeResult(proc.returncode, payload) + + +def objects(value: Any) -> list[dict[str, Any]]: + found: list[dict[str, Any]] = [] + if isinstance(value, dict): + found.append(value) + for item in value.values(): + found.extend(objects(item)) + elif isinstance(value, list): + for item in value: + found.extend(objects(item)) + return found + + +def latest_message(binary: Path, group_id: str, marker: str, timeout: int) -> dict[str, Any]: + for _ in range(5): + result = native( + binary, + [ + "chat", + "message", + "list", + "--group", + group_id, + "--time", + time.strftime("%Y-%m-%d 00:00:00"), + "--direction", + "newer", + "--limit", + "50", + ], + timeout, + accept_error=True, + ) + payload = result.payload + if result.exit_code != 0 or response_is_error(payload): + time.sleep(1) + continue + rows = [ + item + for item in objects(payload) + if item.get("openMessageId") and marker in str(item.get("content") or "") + ] + if rows: + return rows[-1] + time.sleep(1) + raise RuntimeError("sent fixture message did not become readable") + + +def find_media_fixture(binary: Path, timeout: int) -> tuple[str, str, str]: + payload = native( + binary, + [ + "chat", + "message", + "list-all", + "--start", + time.strftime("%Y-%m-%d 00:00:00", time.localtime(time.time() - 31 * 86400)), + "--end", + time.strftime("%Y-%m-%d 23:59:59"), + "--limit", + "100", + "--cursor", + "0", + ], + timeout, + ).payload + for item in objects(payload): + content = str(item.get("content") or "") + match = re.search(r"mediaId=([^\s)]+)", content) + if match and item.get("openMessageId") and item.get("openConversationId"): + return ( + str(item["openConversationId"]), + str(item["openMessageId"]), + match.group(1), + ) + return "", "", "" + + +def create_group( + binary: Path, name: str, user_id: str, timeout: int, *, thread: bool = False +) -> str: + args = [ + "chat", + "group", + "create", + "--name", + name, + "--users", + user_id, + "--type", + "NORMAL", + "--yes", + ] + if thread: + args.append("--thread") + payload = native(binary, args, timeout).payload + group_id = first_string(payload, "openConversationId") + if not group_id: + raise RuntimeError("created group response lacks openConversationId") + return group_id + + +def dismiss_group(binary: Path, group_id: str, timeout: int) -> None: + if not group_id: + return + native( + binary, + ["chat", "group", "dismiss", "--group", group_id, "--yes"], + timeout, + accept_error=True, + ) + + +def summarize_action( + command: str, expected_tool: str, captures: list[Capture] +) -> dict[str, Any]: + tools = [ + f"{server}/{tool}" + for capture in captures + for server, tool, _ in capture.raw_calls + ] + lower_errors = [ + raw + for capture in captures + for _, _, raw in capture.raw_calls + if response_is_error(raw) + ] + failed = next( + ( + capture + for capture in captures + if capture.timed_out + or capture.exit_code != 0 + or capture.upper() is None + or response_is_error(capture.upper()) + ), + None, + ) + expected_seen = any(tool.endswith("/" + expected_tool) for tool in tools) + if failed is not None: + status = "timeout" if failed.timed_out else "error" + elif lower_errors: + status = "lower_error" + elif not expected_seen: + status = "wrong_tool" + else: + status = "pass" + return { + "command": command, + "status": status, + "expected_tool": expected_tool, + "invocation_count": len(captures), + "lower_call_count": len(tools), + "lower_tools": tools, + "error": error_facts(failed.stderr) if failed is not None else {}, + } + + +def run_live(binary: Path, timeout: int) -> list[dict[str, Any]]: + auth = native(binary, ["auth", "status"], timeout).payload + user_id = str(auth.get("user_id") or "") + user_name = str(auth.get("user_name") or "") + if not user_id or not user_name: + raise RuntimeError("auth status lacks user identity") + + suffix = time.strftime("%Y%m%d-%H%M%S") + group_name = f"DWS-SHORTCUT-AUDIT-LIVE-{suffix}" + thread_name = f"DWS-SHORTCUT-AUDIT-THREAD-LIVE-{suffix}" + quit_name = f"DWS-SHORTCUT-AUDIT-QUIT-LIVE-{suffix}" + group_id = "" + thread_group_id = "" + quit_group_id = "" + category_id = "" + role_id = "" + results: list[dict[str, Any]] = [] + + def shortcut_case( + command: str, + expected_tool: str, + actions: list[list[str]], + ) -> list[Capture]: + captures = [ + run_capture(binary, command, [*args, "--yes"], timeout) for args in actions + ] + results.append(summarize_action(command, expected_tool, captures)) + return captures + + try: + group_id = create_group(binary, group_name, user_id, timeout) + thread_group_id = create_group( + binary, thread_name, user_id, timeout, thread=True + ) + quit_group_id = create_group(binary, quit_name, user_id, timeout) + + members = run_capture( + binary, + "+chat-members-list", + ["--conversation-id", group_id, "--member-types", "user"], + timeout, + ) + member_open_id = first_string( + members.upper(), "openDingtalkId", "openDingTalkId" + ) + if not member_open_id: + raise RuntimeError("single-user fixture group lacks member identity") + + marker = f"DWS-SHORTCUT-AUDIT-WRITE-{suffix}" + native( + binary, + [ + "chat", + "message", + "send", + "--group", + group_id, + "--text", + marker, + "--uuid", + f"dws-shortcut-audit-{suffix}-seed", + "--yes", + ], + timeout, + ) + message = latest_message(binary, group_id, marker, timeout) + message_id = str(message["openMessageId"]) + + combine_marker = f"{marker}-COMBINE-SECOND" + native( + binary, + [ + "chat", + "message", + "send", + "--group", + group_id, + "--text", + combine_marker, + "--uuid", + f"dws-shortcut-audit-{suffix}-combine-second", + "--yes", + ], + timeout, + ) + combine_message = latest_message( + binary, group_id, combine_marker, timeout + ) + combine_message_id = str(combine_message["openMessageId"]) + + thread_marker = f"DWS-SHORTCUT-AUDIT-THREAD-{suffix}" + native( + binary, + [ + "chat", + "message", + "send", + "--group", + thread_group_id, + "--text", + thread_marker, + "--uuid", + f"dws-shortcut-audit-{suffix}-thread", + "--yes", + ], + timeout, + ) + topic = latest_message(binary, thread_group_id, thread_marker, timeout) + topic_message_id = str(topic["openMessageId"]) + topic_id = str(topic.get("openConvThreadId") or "") + + category_create = shortcut_case( + "+category-create", + "create_conv_category", + [["--title", f"DWS审计{suffix[-6:]}"]], + ) + category_id = first_string(category_create[-1].upper(), "categoryId") + if category_id: + shortcut_case( + "+category-rename", + "rename_conv_category", + [["--category-id", category_id, "--title", f"DWS改名{suffix[-6:]}"]], + ) + shortcut_case( + "+category-add-conversation", + "add_conv_to_categories", + [["--group", group_id, "--category-ids", category_id]], + ) + shortcut_case( + "+category-remove-conversation", + "remove_conv_from_categories", + [["--group", group_id, "--category-ids", category_id]], + ) + shortcut_case( + "+category-delete", + "delete_conv_category", + [["--category-id", category_id]], + ) + category_id = "" + + shortcut_case( + "+chat-mute", + "set_group_mute", + [["--group", group_id], ["--group", group_id, "--off"]], + ) + shortcut_case( + "+chat-set-history", + "update_show_history_msg_option", + [ + ["--group", group_id, "--option", "RECENT_100"], + ["--group", group_id, "--option", "FORBIDDEN"], + ], + ) + shortcut_case( + "+chat-update-alias", + "update_user_group_alias", + [["--group", group_id, "--alias-title", f"DWS审计备注-{suffix}"]], + ) + _, _, media_id = find_media_fixture(binary, timeout) + if media_id: + shortcut_case( + "+chat-update-icon", + "update_group_icon", + [["--group", group_id, "--icon-media-id", media_id]], + ) + shortcut_case( + "+chat-update-nick", + "update_group_nick", + [["--group", group_id, "--nick", f"DWS审计昵称-{suffix[-6:]}"]], + ) + shortcut_case( + "+chat-update-settings", + "update_group_settings", + [ + ["--group", group_id, "--setting-key", "searchable", "--status", "1"], + ["--group", group_id, "--setting-key", "searchable", "--status", "0"], + ], + ) + role_create = shortcut_case( + "+chat-role-add", + "add_custom_group_role", + [["--group", group_id, "--name", f"DWS审计身份-{suffix[-6:]}"]], + ) + role_id = first_string(role_create[-1].upper(), "openRoleId", "roleId") + if role_id: + shortcut_case( + "+chat-role-update", + "update_custom_group_role", + [ + [ + "--group", + group_id, + "--role-id", + role_id, + "--name", + f"DWS审计身份-已更新-{suffix[-6:]}", + ] + ], + ) + shortcut_case( + "+chat-role-set-user", + "set_custom_user_roles", + [ + [ + "--group", + group_id, + "--user", + member_open_id, + "--role-ids", + role_id, + ] + ], + ) + shortcut_case( + "+chat-role-remove-user", + "remove_custom_user_roles", + [ + [ + "--group", + group_id, + "--user", + member_open_id, + "--role-ids", + role_id, + ] + ], + ) + shortcut_case( + "+chat-role-remove", + "remove_custom_group_role", + [["--group", group_id, "--role-id", role_id]], + ) + role_id = "" + + shortcut_case( + "+conversation-mute", + "update_notification_off", + [ + ["--conversation-id", group_id], + ["--conversation-id", group_id, "--off"], + ], + ) + shortcut_case( + "+conversation-mute-at-all", + "update_at_all_notification_off", + [ + ["--conversation-id", group_id], + ["--conversation-id", group_id, "--off"], + ], + ) + shortcut_case( + "+conversation-mute-red-envelope", + "update_red_env_notification_off", + [ + ["--conversation-id", group_id], + ["--conversation-id", group_id, "--off"], + ], + ) + shortcut_case( + "+conversation-set-top", + "set_top_conversation", + [ + ["--conversation-id", group_id], + ["--conversation-id", group_id, "--off"], + ], + ) + shortcut_case( + "+conversation-mark-unread", + "mark_conversation_unread", + [["--conversation-id", group_id]], + ) + shortcut_case( + "+conversation-clear-red-point", + "clear_conversation_red_point", + [["--conversation-id", group_id]], + ) + shortcut_case( + "+conversation-mark-read", + "mark_message_read", + [["--conversation-id", group_id, "--message-id", message_id]], + ) + shortcut_case( + "+conversation-clear-all-red-point", + "clear_all_red_point", + [[]], + ) + + shortcut_case( + "+send-to-group", + "send_personal_message", + [["--group", group_name, "--text", f"{marker}-send-to-group"]], + ) + shortcut_case( + "+dm", + "send_personal_message", + [["--to", user_name, "--text", f"{marker}-dm-self"]], + ) + shortcut_case( + "+broadcast", + "send_personal_message", + [["--to", user_name, "--text", f"{marker}-broadcast-self"]], + ) + + shortcut_case( + "+messages-add-emoji", + "add_emoji_reaction", + [ + [ + "--conversation-id", + group_id, + "--msg-id", + message_id, + "--emoji", + "赞", + ] + ], + ) + shortcut_case( + "+messages-remove-emoji", + "remove_emoji_reaction", + [ + [ + "--conversation-id", + group_id, + "--msg-id", + message_id, + "--emoji", + "赞", + ] + ], + ) + shortcut_case( + "+messages-set-pin", + "set_pin_message", + [["--open-conversation-id", group_id, "--msg-id", message_id]], + ) + shortcut_case( + "+messages-unset-pin", + "unset_pin_message", + [["--open-conversation-id", group_id, "--msg-id", message_id]], + ) + shortcut_case( + "+messages-set-top", + "set_top_message", + [["--open-conversation-id", group_id, "--msg-id", message_id]], + ) + shortcut_case( + "+messages-unset-top", + "unset_top_message", + [["--open-conversation-id", group_id, "--msg-id", message_id]], + ) + shortcut_case( + "+messages-forward", + "forward_message", + [ + [ + "--src-conversation-id", + group_id, + "--msg-id", + message_id, + "--dest-conversation-id", + group_id, + "--uuid", + f"dws-forward-{suffix}", + ] + ], + ) + shortcut_case( + "+messages-combine-forward", + "combine_forward_messages", + [ + [ + "--src-conversation-id", + group_id, + "--msg-ids", + f"{message_id},{combine_message_id}", + "--dest-conversation-id", + group_id, + "--uuid", + f"dws-combine-{suffix}", + ] + ], + ) + if topic_id: + shortcut_case( + "+messages-forward-topic", + "forward_topic", + [ + [ + "--src-msg-id", + topic_message_id, + "--src-conversation-id", + thread_group_id, + "--src-thread-id", + topic_id, + "--dest-conversation-id", + group_id, + ] + ], + ) + + emotion_create = shortcut_case( + "+messages-create-text-emotion", + "create_text_emotion", + [ + [ + "--emotion-name", + f"DWS审计-{suffix[-6:]}", + "--text", + "audit", + "--background-id", + "im_bg_5", + ] + ], + ) + emotion_id = first_string(emotion_create[-1].upper(), "emotionId") + if emotion_id: + emotion_args = [ + "--conversation-id", + group_id, + "--msg-id", + message_id, + "--emotion-id", + emotion_id, + "--emotion-name", + f"DWS审计-{suffix[-6:]}", + "--text", + "audit", + "--background-id", + "im_bg_5", + ] + shortcut_case( + "+messages-add-text-emotion", + "add_text_emotion", + [emotion_args], + ) + shortcut_case( + "+messages-remove-text-emotion", + "remove_text_emotion", + [emotion_args], + ) + + card_create = shortcut_case( + "+messages-send-card", + "create_and_send_card", + [["--group", group_id]], + ) + biz_id = first_string(card_create[-1].upper(), "bizId") + if biz_id: + shortcut_case( + "+messages-update-card", + "update_streaming_card", + [ + [ + "--biz-id", + biz_id, + "--content", + f"{marker}-card-finished", + "--flow-status", + "3", + ] + ], + ) + + recall_marker = f"{marker}-recall" + native( + binary, + [ + "chat", + "message", + "send", + "--group", + group_id, + "--text", + recall_marker, + "--uuid", + f"dws-recall-{suffix}", + "--yes", + ], + timeout, + ) + recall_message = latest_message(binary, group_id, recall_marker, timeout) + shortcut_case( + "+messages-recall", + "recall_message", + [ + [ + "--conversation-id", + group_id, + "--msg-id", + str(recall_message["openMessageId"]), + ] + ], + ) + + shortcut_case( + "+conversation-hide", + "hide_conversation", + [["--conversation-id", group_id]], + ) + shortcut_case( + "+conversation-clear-messages", + "clear_conversation_messages", + [["--conversation-id", group_id]], + ) + quit_result = shortcut_case( + "+chat-quit", + "quit_group", + [["--group", quit_group_id]], + ) + if summarize_action("+chat-quit", "quit_group", quit_result)["status"] == "pass": + quit_group_id = "" + + dismiss_capture = shortcut_case( + "+chat-dismiss", + "dismiss_group", + [["--group", group_id]], + ) + if summarize_action("+chat-dismiss", "dismiss_group", dismiss_capture)["status"] == "pass": + group_id = "" + + for command, requirement in sorted(EXTERNAL_FIXTURE_COMMANDS.items()): + results.append( + { + "command": command, + "status": "external_fixture_required", + "requirement": requirement, + } + ) + return sorted(results, key=lambda item: item["command"]) + finally: + if category_id: + run_capture( + binary, + "+category-delete", + ["--category-id", category_id, "--yes"], + timeout, + ) + if role_id: + run_capture( + binary, + "+chat-role-remove", + ["--group", group_id, "--role-id", role_id, "--yes"], + timeout, + ) + dismiss_group(binary, quit_group_id, timeout) + dismiss_group(binary, thread_group_id, timeout) + dismiss_group(binary, group_id, timeout) + + +def markdown(results: list[dict[str, Any]]) -> str: + counts: dict[str, int] = {} + for item in results: + counts[item["status"]] = counts.get(item["status"], 0) + 1 + lines = [ + "# Chat Shortcut live write audit", + "", + "Disposable DWS-created fixtures were used. Reversible toggles were rolled", + "back and all temporary groups were dismissed. No business values are stored.", + "", + f"- Total covered: {len(results)}", + ] + for status in sorted(counts): + lines.append(f"- {status}: {counts[status]}") + lines.extend( + [ + "", + "| Shortcut | Status | Invocations | Lower tools / requirement |", + "|---|---|---:|---|", + ] + ) + for item in results: + evidence = ", ".join(item.get("lower_tools", [])) or item.get( + "requirement", "" + ) + lines.append( + f"| `{item['command']}` | {item['status']} | " + f"{item.get('invocation_count', '')} | {evidence} |" + ) + lines.append("") + return "\n".join(lines) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--dws", required=True, type=Path) + parser.add_argument("--out-dir", required=True, type=Path) + parser.add_argument("--timeout", type=int, default=60) + parser.add_argument( + "--yes-live", + action="store_true", + help="required acknowledgement that real temporary writes will occur", + ) + args = parser.parse_args() + if not args.yes_live: + parser.error("--yes-live is required") + binary = args.dws.resolve() + if not binary.is_file(): + parser.error(f"--dws is not a file: {binary}") + + results = run_live(binary, args.timeout) + args.out_dir.mkdir(parents=True, exist_ok=True) + payload = { + "scope": "chat-write-shortcuts-live-disposable", + "count": len(results), + "results": results, + } + json_path = args.out_dir / "chat-write-live-audit.json" + md_path = args.out_dir / "chat-write-live-audit.md" + json_path.write_text( + json.dumps(payload, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + md_path.write_text(markdown(results), encoding="utf-8") + print(md_path) + return 1 if any( + item["status"] not in {"pass", "external_fixture_required"} + for item in results + ) else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run_chat_shortcut_write_dry_run_audit.py b/scripts/run_chat_shortcut_write_dry_run_audit.py new file mode 100644 index 00000000..669b7b53 --- /dev/null +++ b/scripts/run_chat_shortcut_write_dry_run_audit.py @@ -0,0 +1,1101 @@ +#!/usr/bin/env python3 +"""Audit all Chat write Shortcuts with real fixtures and --dry-run. + +The script discovers reusable IDs through real read calls, then invokes every +write/high-risk Chat Shortcut with --dry-run. It verifies that no write MCP +response was observed and that each ordinary Shortcut rendered the expected +tool plus required lower-layer argument keys. Reports contain fixture kinds +and argument keys only, never fixture values or business payloads. +""" + +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +SCRIPT_DIR = Path(__file__).resolve().parent +sys.path.insert(0, str(SCRIPT_DIR)) + +from run_chat_shortcut_live_audit import ( # noqa: E402 + Capture, + first_list, + first_string, + run_capture, +) + + +@dataclass(frozen=True) +class DryRunCase: + command: str + args: tuple[str, ...] + tool: str | None + required_argument_keys: frozenset[str] = frozenset() + semantic_preview: bool = False + + +WRITE_COMMANDS = ( + "+broadcast", + "+category-add-conversation", + "+category-create", + "+category-delete", + "+category-remove-conversation", + "+category-rename", + "+chat-add-bot", + "+chat-audit-join", + "+chat-create", + "+chat-dismiss", + "+chat-mute", + "+chat-mute-member", + "+chat-quit", + "+chat-remove-bot", + "+chat-role-add", + "+chat-role-remove", + "+chat-role-remove-user", + "+chat-role-set-user", + "+chat-role-update", + "+chat-set-admin", + "+chat-set-history", + "+chat-transfer-owner", + "+chat-update", + "+chat-update-alias", + "+chat-update-icon", + "+chat-update-nick", + "+chat-update-settings", + "+conversation-clear-all-red-point", + "+conversation-clear-messages", + "+conversation-clear-red-point", + "+conversation-hide", + "+conversation-mark-read", + "+conversation-mark-unread", + "+conversation-mute", + "+conversation-mute-at-all", + "+conversation-mute-red-envelope", + "+conversation-set-top", + "+dm", + "+flag-cancel", + "+flag-create", + "+messages-add-emoji", + "+messages-add-text-emotion", + "+messages-batch-recall-by-bot", + "+messages-batch-send-by-bot", + "+messages-combine-forward", + "+messages-create-text-emotion", + "+messages-forward", + "+messages-forward-topic", + "+messages-recall", + "+messages-recall-by-bot", + "+messages-remove-emoji", + "+messages-remove-text-emotion", + "+messages-reply", + "+messages-send", + "+messages-send-by-bot", + "+messages-send-by-webhook", + "+messages-send-card", + "+messages-set-pin", + "+messages-set-top", + "+messages-unset-pin", + "+messages-unset-top", + "+messages-update-card", + "+send-to-group", +) + +READ_TOOLS_ALLOWED_DURING_DRY_RUN = { + ("contact", "get_current_user_profile"), + ("contact", "search_contact_by_key_word"), + ("im", "search_groups"), +} + + +class FixtureSet: + def __init__(self) -> None: + self.values: dict[str, str] = {} + self.sources: dict[str, str] = {} + + def put(self, name: str, value: str, fallback: str) -> str: + clean = str(value or "").strip() + if clean: + self.values[name] = clean + self.sources[name] = "real" + else: + self.values[name] = fallback + self.sources[name] = "synthetic" + return self.values[name] + + def __getitem__(self, name: str) -> str: + return self.values[name] + + +def read_json_command(binary: Path, args: list[str], timeout: int) -> Any: + try: + proc = subprocess.run( + [str(binary), *args, "--format", "json"], + capture_output=True, + text=True, + timeout=timeout, + check=False, + ) + return json.loads(proc.stdout) if proc.returncode == 0 else {} + except (subprocess.TimeoutExpired, json.JSONDecodeError): + return {} + + +def discover_fixtures(binary: Path, timeout: int) -> FixtureSet: + fx = FixtureSet() + auth = read_json_command(binary, ["auth", "status"], timeout) + user_name = fx.put("user_name", str(auth.get("user_name") or ""), "DWS审计用户") + user_id = fx.put("user_id", str(auth.get("user_id") or ""), "dws_audit_user") + + category = run_capture(binary, "+category-list", [], timeout) + category_id = fx.put( + "category_id", + first_string(category.upper(), "categoryId", "category_id"), + "1", + ) + + mine = run_capture(binary, "+chat-list-mine", ["--limit", "200"], timeout) + group_rows = first_list(mine.upper(), "groups") + groups = [item for item in group_rows if isinstance(item, dict)] + group = groups[0] if groups else {} + messages: Capture | None = None + for candidate in groups[:8]: + candidate_id = str(candidate.get("openConversationId") or "") + if not candidate_id: + continue + probe = run_capture( + binary, + "+messages-list", + [ + "--group", + candidate_id, + "--time", + time.strftime("%Y-%m-%d %H:%M:%S"), + "--forward=false", + "--limit", + "50", + ], + timeout, + ) + if first_string( + first_list(probe.upper(), "messages"), + "messageId", + "openMessageId", + "openMsgId", + ): + group = candidate + messages = probe + break + group_id = fx.put( + "group_id", + str(group.get("openConversationId") or ""), + "cid_dws_shortcut_audit", + ) + group_name = fx.put( + "group_name", + str(group.get("name") or group.get("title") or ""), + "DWS Shortcut 审计群", + ) + + members = run_capture(binary, "+group-members", ["--group", group_name], timeout) + member_rows = first_list(members.upper(), "members") + member_open_id = fx.put( + "member_open_id", + first_string( + member_rows, + "openDingtalkId", + "openDingTalkId", + "memberDingtalkId", + ), + "did_dws_shortcut_audit", + ) + + if messages is None: + messages = run_capture( + binary, + "+messages-list", + [ + "--group", + group_id, + "--time", + time.strftime("%Y-%m-%d %H:%M:%S"), + "--forward=false", + "--limit", + "50", + ], + timeout, + ) + message_rows = first_list(messages.upper(), "messages") + message_id = fx.put( + "message_id", + first_string(message_rows, "messageId", "openMessageId", "openMsgId"), + "msg_dws_shortcut_audit", + ) + + roles = run_capture(binary, "+chat-role-list", ["--group", group_id], timeout) + role_id = fx.put( + "role_id", + first_string(roles.upper(), "openRoleId", "roleId"), + "role_dws_shortcut_audit", + ) + + group_bots = run_capture(binary, "+chat-bots", ["--group", group_id], timeout) + bot_id = fx.put( + "bot_id", + first_string(group_bots.upper(), "openBotId", "botId"), + "bot_dws_shortcut_audit", + ) + + bots = run_capture( + binary, + "+bot-find", + ["--query", "机器人", "--limit", "20"], + timeout, + ) + robot_code = fx.put( + "robot_code", + first_string(bots.upper(), "robotCode", "code"), + "robot_dws_shortcut_audit", + ) + + joins = run_capture( + binary, + "+chat-list-join-requests", + ["--limit", "50"], + timeout, + ) + join_raw = joins.raw_calls[-1][2] if joins.raw_calls else joins.upper() + fx.put( + "join_record_id", + first_string(join_raw, "applyRecordId", "recordId"), + "1", + ) + fx.put( + "join_applicant", + first_string(join_raw, "applicantUid", "applicantUserId"), + user_id, + ) + fx.put( + "join_inviter", + first_string(join_raw, "inviterUid", "inviterUserId"), + user_id, + ) + + # The current read surface does not guarantee these resource types. They + # remain syntactically valid dry-run values and are explicitly reported as + # synthetic until a disposable live fixture supplies real values. + fx.put("media_id", "", "@dws_shortcut_audit_media") + fx.put("topic_id", "", "thread_dws_shortcut_audit") + fx.put("process_key", "", "process_dws_shortcut_audit") + fx.put("emotion_id", "", "emotion_dws_shortcut_audit") + fx.put("biz_id", "", "biz_dws_shortcut_audit") + fx.put("webhook_token", "", "token_dws_shortcut_audit") + fx.put("uuid", "", "dws-shortcut-audit-uuid") + fx.put("message_text", "", "[DWS Shortcut dry-run audit: not sent]") + # The IM category backend accepts at most 15 Unicode code points. + fx.put("category_title", "", "DWS dry-run") + fx.put("role_name", "", "DWS审计身份") + fx.put("alias_title", "", "DWS审计群备注") + fx.put("nick", "", "DWS审计昵称") + fx.put("emotion_name", "", "审计") + fx.put("emotion_text", "", "dry-run") + fx.put("emotion_background", "", "im_bg_5") + return fx + + +def case( + command: str, + args: list[str], + tool: str | None, + *required_keys: str, + semantic_preview: bool = False, +) -> DryRunCase: + return DryRunCase( + command=command, + args=tuple(args), + tool=tool, + required_argument_keys=frozenset(required_keys), + semantic_preview=semantic_preview, + ) + + +def build_cases(fx: FixtureSet) -> list[DryRunCase]: + g = fx["group_id"] + u = fx["member_open_id"] + m = fx["message_id"] + r = fx["role_id"] + rc = fx["robot_code"] + text = fx["message_text"] + cid = fx["category_id"] + return [ + case( + "+broadcast", + ["--to", fx["user_name"], "--text", text, "--ai-tag"], + "send_personal_message", + "receiverOpenDingTalkId", + "msgType", + "content", + semantic_preview=True, + ), + case( + "+category-add-conversation", + ["--group", g, "--category-ids", cid], + "add_conv_to_categories", + "openConversationId", + "categoryIds", + ), + case("+category-create", ["--title", fx["category_title"]], "create_conv_category", "title"), + case("+category-delete", ["--category-id", cid], "delete_conv_category", "categoryId"), + case( + "+category-remove-conversation", + ["--group", g, "--category-ids", cid], + "remove_conv_from_categories", + "openConversationId", + "categoryIds", + ), + case( + "+category-rename", + ["--category-id", cid, "--title", fx["category_title"]], + "rename_conv_category", + "categoryId", + "title", + ), + case( + "+chat-add-bot", + ["--robot-code", rc, "--id", g], + "add_robot_to_group", + "robotCode", + "openConversationId", + ), + case( + "+chat-audit-join", + [ + "--group", + g, + "--record-id", + fx["join_record_id"], + "--applicant", + fx["join_applicant"], + "--inviter", + fx["join_inviter"], + "--status", + "AuditApprove", + "--description", + "DWS dry-run", + ], + "audit_join_group", + "openConversationId", + "applyRecordId", + "applicantUid", + "inviterUid", + "status", + "auditDescription", + ), + case( + "+chat-create", + [ + "--name", + "DWS dry-run group", + "--users", + fx["user_id"], + "--type", + "INTERNAL", + ], + "create_group_conversation", + "groupName", + "groupMembers", + "groupType", + ), + case("+chat-dismiss", ["--group", g], "dismiss_group", "openConversationId"), + case("+chat-mute", ["--group", g], "set_group_mute", "openConversationId", "mute"), + case( + "+chat-mute-member", + ["--group", g, "--users", u, "--mute-time", "300000"], + "set_group_member_mute_list", + "openConversationId", + "cid", + "openDingTalkIds", + "mute", + "muteTime", + ), + case("+chat-quit", ["--group", g], "quit_group", "openConversationId"), + case( + "+chat-remove-bot", + ["--id", g, "--bot-id", fx["bot_id"]], + "remove_robot_in_group", + "openConversationId", + "openBotId", + ), + case( + "+chat-role-add", + ["--group", g, "--name", fx["role_name"]], + "add_custom_group_role", + "openConversationId", + "name", + ), + case( + "+chat-role-remove", + ["--group", g, "--role-id", r], + "remove_custom_group_role", + "openConversationId", + "openRoleId", + ), + case( + "+chat-role-remove-user", + ["--group", g, "--user", u, "--role-ids", r], + "remove_custom_user_roles", + "openConversationId", + "openDingTalkId", + "openRoleIds", + ), + case( + "+chat-role-set-user", + ["--group", g, "--user", u, "--role-ids", r], + "set_custom_user_roles", + "openConversationId", + "openDingTalkId", + "openRoleIds", + ), + case( + "+chat-role-update", + ["--group", g, "--role-id", r, "--name", fx["role_name"]], + "update_custom_group_role", + "openConversationId", + "openRoleId", + "name", + ), + case( + "+chat-set-admin", + ["--group", g, "--users", u], + "update_conv_member_roles", + "openConversationId", + "openDingTalkIds", + "admin", + ), + case( + "+chat-set-history", + ["--group", g, "--option", "RECENT_100"], + "update_show_history_msg_option", + "openConversationId", + "option", + ), + case( + "+chat-transfer-owner", + ["--group", g, "--new-owner", u], + "transfer_group_owner", + "openConversationId", + "cid", + "newOwnerOpenDingTalkId", + ), + case( + "+chat-update", + ["--group", g, "--name", "DWS dry-run group"], + "update_group_name", + "openconversation_id", + "group_name", + ), + case( + "+chat-update-alias", + ["--group", g, "--alias-title", fx["alias_title"]], + "update_user_group_alias", + "openConversationId", + "aliasTitle", + ), + case( + "+chat-update-icon", + ["--group", g, "--icon-media-id", fx["media_id"]], + "update_group_icon", + "openConversationId", + "iconMediaId", + ), + case( + "+chat-update-nick", + ["--group", g, "--nick", fx["nick"]], + "update_group_nick", + "openConversationId", + "nick", + ), + case( + "+chat-update-settings", + ["--group", g, "--setting-key", "searchable", "--status", "1"], + "update_group_settings", + "openConversationId", + "settingKey", + "status", + ), + case("+conversation-clear-all-red-point", [], "clear_all_red_point"), + case( + "+conversation-clear-messages", + ["--conversation-id", g], + "clear_conversation_messages", + "openConversationId", + "cid", + ), + case( + "+conversation-clear-red-point", + ["--conversation-id", g], + "clear_conversation_red_point", + "openConversationId", + "cid", + ), + case( + "+conversation-hide", + ["--conversation-id", g], + "hide_conversation", + "openConversationId", + "cid", + ), + case( + "+conversation-mark-read", + ["--conversation-id", g, "--message-id", m], + "mark_message_read", + "openConversationId", + "openMessageId", + ), + case( + "+conversation-mark-unread", + ["--conversation-id", g], + "mark_conversation_unread", + "openConversationId", + "cid", + ), + case( + "+conversation-mute", + ["--conversation-id", g], + "update_notification_off", + "openConversationId", + "cid", + "mute", + ), + case( + "+conversation-mute-at-all", + ["--conversation-id", g], + "update_at_all_notification_off", + "openConversationId", + "mute", + ), + case( + "+conversation-mute-red-envelope", + ["--conversation-id", g], + "update_red_env_notification_off", + "openConversationId", + "mute", + ), + case( + "+conversation-set-top", + ["--conversation-id", g], + "set_top_conversation", + "openConversationId", + "cid", + "top", + semantic_preview=True, + ), + case( + "+dm", + ["--to", fx["user_name"], "--text", text, "--ai-tag"], + "send_personal_message", + "receiverOpenDingTalkId", + "msgType", + "content", + ), + case( + "+flag-cancel", + ["--message-id", m, "--conversation-id", g], + "remove_message_favorite", + "openMessageId", + "openConversationId", + semantic_preview=True, + ), + case( + "+flag-create", + ["--message-id", m, "--conversation-id", g], + "add_message_favorite", + "openMessageId", + "openConversationId", + semantic_preview=True, + ), + case( + "+messages-add-emoji", + ["--conversation-id", g, "--msg-id", m, "--emoji", "赞"], + "add_emoji_reaction", + "openConversationId", + "openMsgId", + "emojiName", + ), + case( + "+messages-add-text-emotion", + [ + "--conversation-id", + g, + "--msg-id", + m, + "--emotion-id", + fx["emotion_id"], + "--emotion-name", + fx["emotion_name"], + "--text", + fx["emotion_text"], + "--background-id", + fx["emotion_background"], + ], + "add_text_emotion", + "openConversationId", + "openMsgId", + "emotionId", + "emotionName", + "text", + "backgroundId", + ), + case( + "+messages-batch-recall-by-bot", + ["--robot-code", rc, "--keys", fx["process_key"]], + "batch_recall_robot_users_msg", + "robotCode", + "processQueryKeys", + ), + case( + "+messages-batch-send-by-bot", + [ + "--robot-code", + rc, + "--title", + "DWS dry-run", + "--text", + text, + "--open-dingtalk-ids", + u, + "--at-all", + ], + "batch_send_robot_msg_to_users", + "robotCode", + "title", + "markdown", + "openDingtalkIds", + "isAtAll", + ), + case( + "+messages-combine-forward", + [ + "--src-conversation-id", + g, + "--msg-ids", + m, + "--dest-conversation-id", + g, + "--uuid", + fx["uuid"], + ], + "combine_forward_messages", + "srcOpenCid", + "srcOpenMessageIds", + "destOpenCid", + "uuid", + ), + case( + "+messages-create-text-emotion", + [ + "--emotion-name", + fx["emotion_name"], + "--text", + fx["emotion_text"], + "--background-id", + fx["emotion_background"], + ], + "create_text_emotion", + "emotionName", + "text", + "backgroundId", + ), + case( + "+messages-forward", + [ + "--src-conversation-id", + g, + "--msg-id", + m, + "--dest-conversation-id", + g, + "--uuid", + fx["uuid"], + ], + "forward_message", + "srcOpenCid", + "srcOpenMessageId", + "destOpenCid", + "uuid", + ), + case( + "+messages-forward-topic", + [ + "--src-msg-id", + m, + "--src-conversation-id", + g, + "--src-thread-id", + fx["topic_id"], + "--dest-conversation-id", + g, + ], + "forward_topic", + "srcOpenMessageId", + "srcOpenConversationId", + "srcOpenConvThreadId", + "destOpenConversationId", + ), + case( + "+messages-recall", + ["--conversation-id", g, "--msg-id", m], + "recall_message", + "openConversationId", + "openMessageId", + ), + case( + "+messages-recall-by-bot", + ["--robot-code", rc, "--group", g, "--keys", fx["process_key"]], + "recall_robot_group_message", + "robotCode", + "openConversationId", + "processQueryKeys", + ), + case( + "+messages-remove-emoji", + ["--conversation-id", g, "--msg-id", m, "--emoji", "赞"], + "remove_emoji_reaction", + "openConversationId", + "openMsgId", + "emojiName", + ), + case( + "+messages-remove-text-emotion", + [ + "--conversation-id", + g, + "--msg-id", + m, + "--emotion-id", + fx["emotion_id"], + "--emotion-name", + fx["emotion_name"], + "--text", + fx["emotion_text"], + "--background-id", + fx["emotion_background"], + ], + "remove_text_emotion", + "openConversationId", + "openMsgId", + "emotionId", + "emotionName", + "text", + "backgroundId", + ), + case( + "+messages-reply", + [ + "--conversation-id", + g, + "--ref-msg-id", + m, + "--ref-sender", + u, + "--text", + text, + "--uuid", + fx["uuid"], + ], + "send_personal_message", + "openConversationId", + "msgType", + "content", + "uuid", + ), + case( + "+messages-send", + [ + "--identity", + "user", + "--group", + g, + "--text", + text, + "--uuid", + fx["uuid"], + ], + "send_personal_message", + "openConversationId", + "msgType", + "content", + "uuid", + semantic_preview=True, + ), + case( + "+messages-send-by-bot", + [ + "--robot-code", + rc, + "--group", + g, + "--title", + "DWS dry-run", + "--text", + text, + "--at-open-dingtalk-ids", + u, + "--at-all", + ], + "send_robot_group_message", + "robotCode", + "openConversationId", + "title", + "markdown", + "atOpendingtalkIds", + "isAtAll", + ), + case( + "+messages-send-by-webhook", + [ + "--token", + fx["webhook_token"], + "--title", + "DWS dry-run", + "--text", + text, + "--at-all", + "--at-users", + fx["user_id"], + ], + "send_message_by_custom_robot", + "robotToken", + "title", + "text", + "isAtAll", + "atUserIds", + ), + case( + "+messages-send-card", + ["--group", g], + "create_and_send_card", + "openConversationId", + ), + case( + "+messages-set-pin", + ["--open-conversation-id", g, "--msg-id", m], + "set_pin_message", + "openConversationId", + "cid", + "openMessageId", + ), + case( + "+messages-set-top", + ["--open-conversation-id", g, "--msg-id", m], + "set_top_message", + "openConversationId", + "openMessageId", + ), + case( + "+messages-unset-pin", + ["--open-conversation-id", g, "--msg-id", m], + "unset_pin_message", + "openConversationId", + "cid", + "openMessageId", + ), + case( + "+messages-unset-top", + ["--open-conversation-id", g, "--msg-id", m], + "unset_top_message", + "openConversationId", + "openMessageId", + ), + case( + "+messages-update-card", + [ + "--biz-id", + fx["biz_id"], + "--content", + text, + "--flow-status", + "3", + ], + "update_streaming_card", + "bizId", + "msgContent", + "flowStatus", + ), + case( + "+send-to-group", + ["--group", fx["group_name"], "--text", text, "--ai-tag"], + "send_personal_message", + "openConversationId", + "msgType", + "content", + ), + ] + + +def summarize(case_item: DryRunCase, capture: Capture) -> dict[str, Any]: + upper = capture.upper() + raw_tools = [(server, tool) for server, tool, _ in capture.raw_calls] + unexpected_calls = [ + f"{server}/{tool}" + for server, tool in raw_tools + if (server, tool) not in READ_TOOLS_ALLOWED_DURING_DRY_RUN + ] + argument_keys: list[str] = [] + missing: list[str] = [] + actual_tool = None + + if capture.timed_out: + status = "timeout" + elif capture.exit_code != 0: + status = "error" + elif unexpected_calls: + status = "write_call_observed" + elif not isinstance(upper, dict): + status = "invalid_preview" + elif case_item.semantic_preview: + actual_tool = upper.get("tool") + actions = upper.get("actions") + if isinstance(actions, list): + key_union: set[str] = set() + for action in actions: + if not isinstance(action, dict): + continue + arguments = action.get("arguments") + if isinstance(arguments, dict): + key_union.update(arguments) + argument_keys = sorted(key_union) + missing = sorted(case_item.required_argument_keys - set(argument_keys)) + status = ( + "pass" + if upper.get("dry_run") is True + and upper.get("executed") is False + and upper.get("preview_kind") == "plan" + and actual_tool == case_item.tool + and int(upper.get("actionCount") or 0) > 0 + and int(upper.get("failedCount") or 0) == 0 + and not missing + else "invalid_preview" + ) + else: + actual_tool = upper.get("tool") + arguments = upper.get("arguments") + if isinstance(arguments, dict): + argument_keys = sorted(arguments) + missing = sorted(case_item.required_argument_keys - set(argument_keys)) + if ( + upper.get("dry_run") is True + and upper.get("executed") is False + and actual_tool == case_item.tool + and not missing + ): + status = "pass" + else: + status = "invalid_preview" + + return { + "command": case_item.command, + "status": status, + "exit_code": capture.exit_code, + "duration_ms": capture.duration_ms, + "expected_tool": case_item.tool, + "actual_tool": actual_tool, + "argument_keys": argument_keys, + "missing_argument_keys": missing, + "read_calls": [f"{server}/{tool}" for server, tool in raw_tools], + "unexpected_calls": unexpected_calls, + "semantic_preview": case_item.semantic_preview, + } + + +def run_audit(binary: Path, timeout: int) -> tuple[FixtureSet, list[dict[str, Any]]]: + fixtures = discover_fixtures(binary, timeout) + cases = build_cases(fixtures) + if tuple(item.command for item in cases) != WRITE_COMMANDS: + raise RuntimeError("write Shortcut dry-run coverage drifted from expected 63") + results: list[dict[str, Any]] = [] + for item in cases: + capture = run_capture( + binary, + item.command, + [*item.args, "--dry-run", "--yes"], + timeout, + ) + results.append(summarize(item, capture)) + return fixtures, results + + +def markdown(fixtures: FixtureSet, results: list[dict[str, Any]]) -> str: + counts: dict[str, int] = {} + for result in results: + counts[result["status"]] = counts.get(result["status"], 0) + 1 + real = sorted(name for name, source in fixtures.sources.items() if source == "real") + synthetic = sorted( + name for name, source in fixtures.sources.items() if source == "synthetic" + ) + lines = [ + "# Chat Shortcut write dry-run audit", + "", + "All commands used `--dry-run --yes`. Real read calls were allowed only", + "for semantic name resolution; no write MCP response was observed.", + "", + f"- Total: {len(results)}", + ] + for status in sorted(counts): + lines.append(f"- {status}: {counts[status]}") + lines.extend( + [ + f"- Real fixture kinds: {', '.join(real)}", + f"- Synthetic fixture kinds: {', '.join(synthetic)}", + "", + "| Shortcut | Status | Tool | Argument keys | Read-only resolution |", + "|---|---|---|---|---|", + ] + ) + for result in results: + lines.append( + f"| `{result['command']}` | {result['status']} | " + f"{result.get('actual_tool') or result.get('expected_tool') or 'semantic preview'} | " + f"{', '.join(result.get('argument_keys', []))} | " + f"{', '.join(result.get('read_calls', []))} |" + ) + lines.append("") + return "\n".join(lines) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--dws", required=True, type=Path) + parser.add_argument("--out-dir", required=True, type=Path) + parser.add_argument("--timeout", type=int, default=30) + args = parser.parse_args() + + binary = args.dws.resolve() + if not binary.is_file(): + parser.error(f"--dws is not a file: {binary}") + fixtures, results = run_audit(binary, args.timeout) + + args.out_dir.mkdir(parents=True, exist_ok=True) + payload = { + "scope": "chat-write-shortcuts-dry-run", + "count": len(results), + "fixture_sources": fixtures.sources, + "results": results, + } + json_path = args.out_dir / "chat-write-dry-run-audit.json" + markdown_path = args.out_dir / "chat-write-dry-run-audit.md" + json_path.write_text( + json.dumps(payload, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + markdown_path.write_text(markdown(fixtures, results), encoding="utf-8") + print(markdown_path) + return 1 if any(result["status"] != "pass" for result in results) else 0 + + +if __name__ == "__main__": + raise SystemExit(main())