From 8bf6c15fad473d2e232f7dead08dc681506a3e7b Mon Sep 17 00:00:00 2001 From: wxianfeng Date: Fri, 7 Aug 2026 16:38:13 +0800 Subject: [PATCH] feat(event): restore standalone event skill --- README.md | 42 +- README_zh.md | 42 +- internal/app/event_command.go | 21 +- internal/app/event_listen_im.go | 6 +- internal/app/event_personal_command.go | 7 +- internal/app/event_selection_routing_test.go | 74 ++++ internal/app/skill_setup.go | 122 ++++++ internal/app/skill_setup_embed_test.go | 12 +- internal/app/skill_setup_test.go | 222 +++++++++++ internal/helpers/chat.go | 2 +- internal/helpers/devapp.go | 8 +- internal/helpers/oa.go | 2 +- scripts/install-event.sh | 35 +- scripts/policy/check-skill-context-budget.sh | 2 +- scripts/policy/multi-im-skill-chain/main.go | 5 +- .../policy/multi-im-skill-chain/main_test.go | 8 +- .../testdata/intent_routes.json | 19 +- skills/content-qa/mono-multi-coverage.yaml | 5 +- skills/mono/references/products/event.md | 7 +- skills/multi/dingtalk-chat/SKILL.md | 2 +- .../event.md => dingtalk-event/SKILL.md} | 41 +- .../references/event-im-keys.md | 0 .../references/event-im-lifecycle.md | 0 .../references/event-im-operations.md | 0 .../references/event-im-output.md | 0 .../references/event-im.md | 2 +- .../references/event-oa.md | 4 +- skills/multi/dingtalk-misc/SKILL.md | 5 +- .../dingtalk-shared/references/routing.md | 12 +- test/run_skill_tests.py | 2 + test/scripts/install_script_test.go | 133 +++++-- test/scripts/package_script_test.go | 27 ++ test/skill_tests.md | 173 +++++--- test/skill_tests_results.md | 374 +++++++++++++++--- test/unit/skill_docs_policy_test.go | 162 +++++++- 35 files changed, 1342 insertions(+), 236 deletions(-) create mode 100644 internal/app/event_selection_routing_test.go rename skills/multi/{dingtalk-misc/references/event.md => dingtalk-event/SKILL.md} (77%) rename skills/multi/{dingtalk-misc => dingtalk-event}/references/event-im-keys.md (100%) rename skills/multi/{dingtalk-misc => dingtalk-event}/references/event-im-lifecycle.md (100%) rename skills/multi/{dingtalk-misc => dingtalk-event}/references/event-im-operations.md (100%) rename skills/multi/{dingtalk-misc => dingtalk-event}/references/event-im-output.md (100%) rename skills/multi/{dingtalk-misc => dingtalk-event}/references/event-im.md (97%) rename skills/multi/{dingtalk-misc => dingtalk-event}/references/event-oa.md (94%) diff --git a/README.md b/README.md index 1687fbe2..562a5707 100644 --- a/README.md +++ b/README.md @@ -471,7 +471,7 @@ Env vars: `DWS_SKILL_MODE=mono|multi` (also honored by `install.sh` / `install.p
Personal Event Subscription — real-time DingTalk messages for event-driven agents -`dws event consume` subscribes as the currently logged-in user over a managed Stream WebSocket and emits each event as one NDJSON line on stdout. The public catalog covers scoped and all one-to-one/group messages, specified senders, read/recall/reaction events, and group title/disband lifecycle events. +`dws event consume` subscribes as the currently logged-in user over a managed Stream WebSocket and emits each event as one NDJSON line on stdout. The public catalog covers scoped and all one-to-one/group messages, specified senders, read/recall/reaction events, group lifecycle events, and six OA approval task/instance events. The default `ndjson`, `json`, and `pretty` output preserves the transport envelope (`type`, `event_type`, string `data`, and `headers`) for existing scripts; `compact` retains its existing processor. Add `--flatten` to emit the stable top-level business fields used by Agent workflows. `--format` controls JSON serialization; `--flatten` controls the data structure and cannot be combined with `-f raw` or `--debug-raw-events`. @@ -481,28 +481,33 @@ For an event-focused installation, use the official convenience installer: ```bash curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-event.sh | sh + +# Or install the standalone multi skill from an existing dws installation +dws skill setup --mode multi -s event ``` ```bash # Inspect the public personal event catalog and schema dws event list dws event schema user_im_message_receive_o2o --flatten +dws event list --category oa +dws event schema user_oa_approval_task_created --flatten # Listen for messages that mention the current user -dws event consume user_im_message_receive_at --flatten -f ndjson +dws event +listen-im --kind at-me -f ndjson -# Listen for one-to-one messages with a specified user -dws event consume user_im_message_receive_o2o --user --flatten -f ndjson +# Listen for messages from a specified sender +dws event +listen-im --kind sender --user -f ndjson # Listen by openDingtalkId (external contact, bot, or cross-organization identity) -dws event consume user_im_message_receive_o2o --open-dingtalk-id --flatten -f ndjson +dws event +listen-im --kind sender --open-dingtalk-id -f ndjson # Listen for messages in a specified group -dws event consume user_im_message_receive_group --group --flatten -f ndjson +dws event +listen-im --kind group --chat-id -f ndjson # Listen for all one-to-one or all group messages -dws event consume user_im_message_receive_o2o_all --flatten -f ndjson -dws event consume user_im_message_receive_group_all --flatten -f ndjson +dws event +listen-im --kind all-direct -f ndjson +dws event +listen-im --kind all-group -f ndjson # Listen for a specified group's title changes, member changes, or disband event dws event consume user_im_group_updated --group --flatten -f ndjson @@ -510,14 +515,19 @@ dws event consume user_im_group_member_added --group --flat dws event consume user_im_group_member_exited --group --flatten -f ndjson dws event consume user_im_group_disbanded --group --flatten -f ndjson -# Listen for multiple events for the same user in one process +# Listen for messages, reads, and recalls from the same sender in one process +dws event +listen-im --kind sender --user \ + --events message,read,recall -f ndjson + +# Listen for all six public OA approval events in one process dws event consume \ - user_im_message_receive_o2o \ - user_im_message_read_o2o \ - user_im_message_recall_o2o \ - --user \ - --flatten \ - -f ndjson + user_oa_approval_task_created \ + user_oa_approval_task_finished \ + user_oa_approval_task_redirected \ + user_oa_approval_instance_started \ + user_oa_approval_instance_terminated \ + user_oa_approval_instance_finished \ + --flatten -f ndjson # Inspect local consumers and cancel a subscription dws event status @@ -536,7 +546,7 @@ For one-to-one and specified-sender events, use exactly one target identity: `-- | Observability | `status` shows remote subscriptions, the personal bus, and local consumers | | Cross-platform | Unix Socket on macOS/Linux, Windows Named Pipe on Windows | -See `skills/multi/dingtalk-misc/references/event.md` for the Agent workflow and supported event parameters. +See `skills/multi/dingtalk-event/SKILL.md` for the Agent workflow and supported event parameters.
diff --git a/README_zh.md b/README_zh.md index b6dfb58b..207b97e9 100644 --- a/README_zh.md +++ b/README_zh.md @@ -465,7 +465,7 @@ DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi
个人事件订阅 — 实时接收钉钉消息,驱动事件触发的 Agent -`dws event consume` 使用当前 OAuth 登录用户建立托管的 Stream WebSocket 长连接,并把每条事件以 NDJSON 一行输出到 stdout。当前公开目录覆盖指定范围和全量单聊/群消息、指定发送人、已读/撤回/表情回应,以及群标题变更和群解散事件。 +`dws event consume` 使用当前 OAuth 登录用户建立托管的 Stream WebSocket 长连接,并把每条事件以 NDJSON 一行输出到 stdout。当前公开目录覆盖指定范围和全量单聊/群消息、指定发送人、已读/撤回/表情回应、群生命周期,以及六个 OA 审批任务/实例事件。 默认 `ndjson`、`json`、`pretty` 输出保留兼容 transport envelope(`type`、`event_type`、字符串 `data`、`headers`),`compact` 继续沿用原 processor。Agent 或新脚本显式加 `--flatten` 后,输出稳定的顶层业务字段。`--format` 控制 JSON 序列化,`--flatten` 控制数据结构,且不能与 `-f raw` 或 `--debug-raw-events` 同时使用。 @@ -475,28 +475,33 @@ DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi ```bash curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-event.sh | sh + +# 或在已有 dws 环境中安装独立的 multi skill +dws skill setup --mode multi -s event ``` ```bash # 查看公开个人事件目录和 schema dws event list dws event schema user_im_message_receive_o2o --flatten +dws event list --category oa +dws event schema user_oa_approval_task_created --flatten # 监听当前用户被 @ 的消息 -dws event consume user_im_message_receive_at --flatten -f ndjson +dws event +listen-im --kind at-me -f ndjson -# 监听与指定用户的单聊消息 -dws event consume user_im_message_receive_o2o --user --flatten -f ndjson +# 监听指定发送人的消息 +dws event +listen-im --kind sender --user -f ndjson # 使用 openDingtalkId 监听外部联系人、机器人或跨组织身份 -dws event consume user_im_message_receive_o2o --open-dingtalk-id --flatten -f ndjson +dws event +listen-im --kind sender --open-dingtalk-id -f ndjson # 监听指定群的消息 -dws event consume user_im_message_receive_group --group --flatten -f ndjson +dws event +listen-im --kind group --chat-id -f ndjson # 监听所有单聊或所有群消息 -dws event consume user_im_message_receive_o2o_all --flatten -f ndjson -dws event consume user_im_message_receive_group_all --flatten -f ndjson +dws event +listen-im --kind all-direct -f ndjson +dws event +listen-im --kind all-group -f ndjson # 监听指定群标题变更、成员进退群或群解散 dws event consume user_im_group_updated --group --flatten -f ndjson @@ -504,14 +509,19 @@ dws event consume user_im_group_member_added --group --flat dws event consume user_im_group_member_exited --group --flatten -f ndjson dws event consume user_im_group_disbanded --group --flatten -f ndjson -# 一个进程监听同一用户的多个事件 +# 一个进程监听同一发送人的消息、已读和撤回 +dws event +listen-im --kind sender --user \ + --events message,read,recall -f ndjson + +# 一个进程监听全部六个公开 OA 审批事件 dws event consume \ - user_im_message_receive_o2o \ - user_im_message_read_o2o \ - user_im_message_recall_o2o \ - --user \ - --flatten \ - -f ndjson + user_oa_approval_task_created \ + user_oa_approval_task_finished \ + user_oa_approval_task_redirected \ + user_oa_approval_instance_started \ + user_oa_approval_instance_terminated \ + user_oa_approval_instance_finished \ + --flatten -f ndjson # 查看本地 consume,并取消指定订阅 dws event status @@ -530,7 +540,7 @@ dws event stop | 状态可观测 | `status` 同时显示服务端订阅、personal bus 和本地 consumers | | 跨平台 | macOS/Linux 使用 Unix Socket,Windows 使用 Named Pipe | -Agent 工作流和事件参数详见 `skills/multi/dingtalk-misc/references/event.md`。 +Agent 工作流和事件参数详见 `skills/multi/dingtalk-event/SKILL.md`。
diff --git a/internal/app/event_command.go b/internal/app/event_command.go index 94fc6e10..9e1ef8ba 100644 --- a/internal/app/event_command.go +++ b/internal/app/event_command.go @@ -80,12 +80,12 @@ func newEventCommand() *cobra.Command { contract.RegisterProductDecl(contract.ProductDecl{ ID: "event", Selection: contract.ProductSelectionDecl{ - AgentSummary: "订阅/消费个人消息、动作与群生命周期事件,并管理订阅生命周期", + AgentSummary: "实时监听当前用户相关的个人 IM 与 OA 审批事件,并管理订阅生命周期", UseWhen: []string{ - "需要实时监听个人消息接收、全量消息、已读、撤回、表情回应或群生命周期事件,或管理个人事件订阅生命周期", + "需要实时监听未来发生的个人消息、消息动作、群生命周期或 OA 审批任务/实例事件,或管理个人事件订阅生命周期", }, AvoidWhen: []string{ - "查历史聊天或主动发消息分别用 chat 查询/发送命令", + "查历史聊天或主动发消息用 chat;查询或处理审批实例/任务用 oa;配置开放平台应用事件回调用 dev app event", }, }, }) @@ -398,22 +398,21 @@ SIGTERM、关 stdin,或先用 dws event stop --dry-run 预览 Reason: "Reviewed composite workflow: the command creates or reuses a remote personal-event subscription and coordinates the local event bus and Stream consumer; no single pinned RPC represents the workflow.", }, Selection: contract.SelectionSpec{ - AgentSummary: "订阅并持续消费一个或多个兼容的个人事件;Agent 使用 --flatten 输出顶层业务 NDJSON", + AgentSummary: "消费 OA、群生命周期或需要底层控制的个人事件流;Agent 通常使用 --flatten 输出 NDJSON", UseWhen: []string{ - "需要实时监听 @我、指定单聊、指定群或指定发送人的后续消息事件", - "用户明确要求监听当前身份的所有单聊或所有群消息", - "需要监听指定单聊或群聊中的消息已读、撤回或表情回应事件", + "需要监听六个公开 OA 审批任务/实例 EventKey 中的一个或多个事件", "需要监听指定群的标题变更、成员进退群或群解散事件", - "监听机器人、外部联系人等以 openDingtalkId 标识的单聊目标", - "同一目标、同一过滤条件需要同时监听多个兼容事件", + "用户显式给出原始 EventKey、Filter DSL、subscribe_id,要求原始 transport envelope,或需要普通 IM facade 不提供的高级多事件控制", }, AvoidWhen: []string{ + "普通 @我、指定发送人/群、全部单聊/群聊及 message/reaction/read/recall 监听优先使用 event +listen-im", "只查历史聊天记录时用 chat 查询命令", + "查询、同意、拒绝、转交、撤销或发起审批时用 oa;配置应用事件回调时用 dev app event", "只看事件目录/字段时用 event list / event schema", }, Examples: []string{ - "dws event consume user_im_message_receive_user --open-dingtalk-id open-example --flatten --max-events 1 --format ndjson", - "dws event consume user_im_message_receive_o2o user_im_message_read_o2o --user test-user-001 --flatten --max-events 2 --format ndjson", + "dws event consume user_oa_approval_task_created user_oa_approval_instance_finished --flatten --duration 10m --format ndjson", + "dws event consume user_im_group_member_added --group cid-example --flatten --max-events 1 --format ndjson", }, }, }, diff --git a/internal/app/event_listen_im.go b/internal/app/event_listen_im.go index c99d0edd..7731ac3c 100644 --- a/internal/app/event_listen_im.go +++ b/internal/app/event_listen_im.go @@ -71,7 +71,7 @@ func newEventListenIMCommand() *cobra.Command { Use: "+listen-im", Short: "按 IM 意图解析目标并监听一个或多个个人消息事件", Long: "把 @我、指定发送人、指定群、全部单聊或全部群聊等用户意图确定性编译为个人 EventKey," + - "自然姓名/群名会先唯一解析,再复用 event consume 的订阅、ready marker、NDJSON、取消、回滚和清理生命周期。", + "自然姓名/群名会先唯一解析,再复用 event consume 的订阅、ready marker、NDJSON、取消、回滚和清理生命周期;本命令只处理 IM,不接收 OA 审批事件。", Args: cobra.NoArgs, DisableAutoGenTag: true, RunE: func(c *cobra.Command, _ []string) error { @@ -143,12 +143,12 @@ func newEventListenIMCommand() *cobra.Command { Reason: "Reviewed IM event facade: it deterministically maps kind/events to public personal EventKeys, resolves one natural user/chat target with the shared typed resolver, then delegates one single- or multi-event invocation to the existing subscription, bus, ready-marker, NDJSON, rollback, cancellation, and cleanup lifecycle.", }, Selection: contract.SelectionSpec{ - AgentSummary: "按 @我、姓名、群名或全量范围监听一个或多个 IM 消息事件", + AgentSummary: "按 @我、发送人、群或全量范围监听普通 IM message/reaction/read/recall 事件", UseWhen: []string{ "已知要监听 @我、指定发送人、指定群、全部单聊或全部群聊的 message/reaction/read/recall 事件时使用;姓名用 --user-query、群名用 --chat-query,CLI 会唯一解析目标并把多个兼容事件合并到一个消费生命周期。", }, AvoidWhen: []string{ - "需要群标题/成员/解散等生命周期事件、显式 EventKey、复用 subscribe_id、Filter DSL、原始 transport envelope 或其它底层 consume 控制时使用 event consume;只查历史消息时使用 chat 查询入口", + "OA 审批事件、群标题/成员/解散等生命周期事件、显式 EventKey、复用 subscribe_id、Filter DSL、原始 transport envelope 或其它底层控制使用 event consume;只查历史消息使用 chat 查询入口", }, Examples: []string{ "dws event +listen-im --kind at-me --max-events 1", diff --git a/internal/app/event_personal_command.go b/internal/app/event_personal_command.go index 909ddcaa..8828aeb4 100644 --- a/internal/app/event_personal_command.go +++ b/internal/app/event_personal_command.go @@ -201,12 +201,15 @@ func newEventSchemaCommand() *cobra.Command { }, Selection: contract.SelectionSpec{ AgentSummary: "查询指定个人事件码的输出字段结构;Agent 应查询 --flatten 模式", - UseWhen: []string{"已知任一公开个人 IM event_key,消费前需要理解输出字段或保守 payload 契约"}, + UseWhen: []string{"已知任一公开个人 IM 或 OA event_key,消费前需要理解 --flatten 输出字段或 payload 契约"}, AvoidWhen: []string{ "查询 CLI 命令参数契约时用顶层 dws schema", "要实际收事件时用 event consume", }, - Examples: []string{"dws event schema user_im_message_receive_at --flatten --format json"}, + Examples: []string{ + "dws event schema user_im_message_receive_at --flatten --format json", + "dws event schema user_oa_approval_task_created --flatten --format json", + }, }, }, }) diff --git a/internal/app/event_selection_routing_test.go b/internal/app/event_selection_routing_test.go new file mode 100644 index 00000000..f152311d --- /dev/null +++ b/internal/app/event_selection_routing_test.go @@ -0,0 +1,74 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package app + +import ( + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" +) + +func TestCrossPlatformCoverageEventAgentSelectionBoundaries(t *testing.T) { + _ = NewRootCommand() + + eventProduct, ok := contract.LookupProductDecl("event") + if !ok { + t.Fatal("event ProductDecl is not registered") + } + assertSelectionContains(t, "event product", eventProduct.Selection.AgentSummary, + []string{"IM", "OA"}) + assertSelectionContains(t, "event product use_when", strings.Join(eventProduct.Selection.UseWhen, "\n"), + []string{"消息", "群生命周期", "OA"}) + assertSelectionContains(t, "event product avoid_when", strings.Join(eventProduct.Selection.AvoidWhen, "\n"), + []string{"chat", "oa", "dev app event"}) + + listenMeta, ok := cli.ResolveMeta("event +listen-im") + if !ok { + t.Fatal("event +listen-im metadata is not registered") + } + assertSelectionContains(t, "event.listen_im use_when", strings.Join(listenMeta.Selection.UseWhen, "\n"), + []string{"@我", "message/reaction/read/recall"}) + assertSelectionContains(t, "event.listen_im avoid_when", strings.Join(listenMeta.Selection.AvoidWhen, "\n"), + []string{"OA 审批事件", "群标题", "Filter DSL", "event consume", "历史消息"}) + + consumeMeta, ok := cli.ResolveMeta("event consume") + if !ok { + t.Fatal("event consume metadata is not registered") + } + consumeUse := strings.Join(consumeMeta.Selection.UseWhen, "\n") + assertSelectionContains(t, "event.consume use_when", consumeUse, + []string{"OA", "群", "EventKey", "Filter DSL", "subscribe_id", "transport envelope", "高级多事件"}) + consumeAvoid := strings.Join(consumeMeta.Selection.AvoidWhen, "\n") + assertSelectionContains(t, "event.consume avoid_when", consumeAvoid, + []string{"event +listen-im", "历史聊天", "oa", "dev app event"}) + + schemaMeta, ok := cli.ResolveMeta("event schema") + if !ok { + t.Fatal("event schema metadata is not registered") + } + assertSelectionContains(t, "event.schema use_when", strings.Join(schemaMeta.Selection.UseWhen, "\n"), + []string{"IM", "OA", "--flatten"}) + + for productID, want := range map[string]string{ + "chat": "event +listen-im", + "oa": "event consume", + } { + decl, found := contract.LookupProductDecl(productID) + if !found { + t.Fatalf("%s ProductDecl is not registered", productID) + } + assertSelectionContains(t, productID+" avoid_when", strings.Join(decl.Selection.AvoidWhen, "\n"), []string{want}) + } +} + +func assertSelectionContains(t *testing.T, label, text string, fragments []string) { + t.Helper() + for _, fragment := range fragments { + if !strings.Contains(text, fragment) { + t.Errorf("%s = %q, want fragment %q", label, text, fragment) + } + } +} diff --git a/internal/app/skill_setup.go b/internal/app/skill_setup.go index e7bbcf9a..d1de4d68 100644 --- a/internal/app/skill_setup.go +++ b/internal/app/skill_setup.go @@ -136,6 +136,8 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error { // multi 模式枚举 src 下的子 skill 名,供确认信息与安装步骤共用 var multiSkillNames []string + var foldedEventMiscTargets []string + var eventMiscCompanionTargets []string if mode == skillSetupModeMulti { allMultiSkillNames, listErr := skillSetupListMulti(skillSrc) if listErr != nil { @@ -151,6 +153,24 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error { // dingtalk-shared carries the global rules every product skill declares as a // PREREQUISITE; it must ship even when --skill / --exclude narrows the set. multiSkillNames = ensureMandatorySharedSkill(filtered, allMultiSkillNames) + + if containsSkillName(multiSkillNames, multiEventSkill) { + foldedEventMiscTargets = findFoldedEventMiscTargets(dests) + if len(foldedEventMiscTargets) > 0 { + if normalizedSkillListContains(excludeRaw, multiMiscSkill) { + return fmt.Errorf("检测到已有 dingtalk-misc 仍承载个人 Event 路由;本次安装 dingtalk-event 必须同时迁移 dingtalk-misc,不能显式 --exclude misc") + } + if !containsSkillName(allMultiSkillNames, multiMiscSkill) { + return fmt.Errorf("检测到已有 dingtalk-misc 仍承载个人 Event 路由,但当前 multi 源缺少迁移所需的 %s", multiMiscSkill) + } + // A selective `-s event` remains additive for fresh targets. Only + // destinations that actually contain the folded misc receive the clean + // misc companion; a full setup already installs misc everywhere. + if !containsSkillName(multiSkillNames, multiMiscSkill) { + eventMiscCompanionTargets = append(eventMiscCompanionTargets, foldedEventMiscTargets...) + } + } + } } // --dry-run:仅预览将安装的内容与目标目录,不写入任何文件、不弹确认。 @@ -162,11 +182,15 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error { } if mode == skillSetupModeMulti && len(multiSkillNames) > 0 { fmt.Fprintf(out, "子 skill:%s\n", strings.Join(multiSkillNames, ", ")) + printEventMiscMigrationPreview(out, foldedEventMiscTargets, len(eventMiscCompanionTargets) > 0) } return nil } if !autoYes { + if mode == skillSetupModeMulti { + printEventMiscMigrationPreview(out, foldedEventMiscTargets, len(eventMiscCompanionTargets) > 0) + } ok, err := skillSetupConfirm(out, mode, skillSrc, dests, multiSkillNames) if err != nil { return err @@ -183,6 +207,29 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error { installed, skipped, err = skillSetupInstallMono(skillSrc, dests, out, errOut) case skillSetupModeMulti: installed, skipped, err = skillSetupInstallMulti(skillSrc, multiSkillNames, dests, out, errOut) + if err == nil && len(eventMiscCompanionTargets) > 0 { + // installMultiSkillToHomes intentionally preserves the existing + // best-effort contract and reports per-target failures as skipped. + // Do not clean the folded misc fallback unless every primary Event + // installation succeeded; otherwise a failed upgrade could remove the + // only Event entry point that target still has. + if skipped > 0 { + return fmt.Errorf("dingtalk-event 安装不完整(skipped=%d);已保留旧 dingtalk-misc,未执行 Event 迁移", skipped) + } + var migrated, migrationSkipped int + migrated, migrationSkipped, err = skillSetupInstallMulti( + skillSrc, + []string{multiMiscSkill}, + eventMiscCompanionTargets, + out, + errOut, + ) + installed += migrated + skipped += migrationSkipped + if err == nil && migrationSkipped > 0 { + return fmt.Errorf("dingtalk-misc 迁移不完整(skipped=%d);请修复目标目录后重试", migrationSkipped) + } + } default: return fmt.Errorf("内部错误:未知 mode %q", mode) } @@ -191,6 +238,7 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error { } fmt.Fprintf(out, "\n✅ Skill 安装完成(mode=%s, installed=%d, skipped=%d)\n", mode, installed, skipped) + fmt.Fprintln(out, "ℹ️ 若 Agent 会话已打开,请重启 Agent 或重新加载 Skills 后再验证路由。") return nil } @@ -204,6 +252,80 @@ const multiSkillPrefix = "dingtalk-" // dingtalk-shared that was never installed. const multiSharedSkill = "dingtalk-shared" +const ( + multiEventSkill = "dingtalk-event" + multiMiscSkill = "dingtalk-misc" +) + +func containsSkillName(names []string, want string) bool { + for _, name := range names { + if name == want { + return true + } + } + return false +} + +func normalizedSkillListContains(raw []string, want string) bool { + for _, name := range raw { + if normalizeMultiSkillName(name) == want { + return true + } + } + return false +} + +// findFoldedEventMiscTargets identifies the short-lived multi-skill layout in +// which personal Event routing lived inside dingtalk-misc. Both markers are +// required so an unrelated misc install is never treated as a migration target. +func findFoldedEventMiscTargets(dests []string) []string { + var targets []string + for _, dest := range dests { + miscRoot := filepath.Join(dest, multiMiscSkill) + skillBody, err := os.ReadFile(filepath.Join(miscRoot, "SKILL.md")) + if err != nil || !containsPersonalEventRoute(skillBody) { + continue + } + eventRef, err := skillSetupStat(filepath.Join(miscRoot, "references", "event.md")) + if err != nil || eventRef.IsDir() { + continue + } + targets = append(targets, dest) + } + sort.Strings(targets) + return targets +} + +func containsPersonalEventRoute(skillBody []byte) bool { + body := strings.ToLower(string(skillBody)) + for _, marker := range []string{ + "dws event", + "个人 event", + "个人 im 事件", + "个人 im/oa", + "personal event", + } { + if strings.Contains(body, marker) { + return true + } + } + return false +} + +func printEventMiscMigrationPreview(out io.Writer, targets []string, installsCompanion bool) { + if len(targets) == 0 { + return + } + action := "本次已选择的 dingtalk-misc 将覆盖折叠版" + if installsCompanion { + action = "将额外安装干净的 dingtalk-misc 作为迁移伴侣(仅限以下目标)" + } + fmt.Fprintf(out, "Event Skill 迁移:%s:\n", action) + for _, target := range targets { + fmt.Fprintf(out, " - %s\n", target) + } +} + // ensureMandatorySharedSkill guarantees the shared dependency skill is included // whenever it exists in the source, even if --skill / --exclude narrowed it out. func ensureMandatorySharedSkill(selected, all []string) []string { diff --git a/internal/app/skill_setup_embed_test.go b/internal/app/skill_setup_embed_test.go index 1d4a2f95..69a53ccb 100644 --- a/internal/app/skill_setup_embed_test.go +++ b/internal/app/skill_setup_embed_test.go @@ -58,8 +58,9 @@ func TestMaterializeEmbeddedSkillSourceMono(t *testing.T) { } // TestMaterializeEmbeddedSkillSourceMulti verifies that the peer multi bundle -// contains both the shared routing skill and misc (including folded PAT docs). Structured -// Schema hints are build inputs and must not become a third installable mode. +// contains the standalone Event skill, shared routing skill, and clean misc +// (including PAT docs). Structured Schema hints are build inputs and must not +// become a third installable mode. func TestMaterializeEmbeddedSkillSourceMulti(t *testing.T) { dir, cleanup, err := materializeEmbeddedSkillSource(skillSetupModeMulti) if err != nil { @@ -71,6 +72,8 @@ func TestMaterializeEmbeddedSkillSourceMulti(t *testing.T) { t.Fatalf("extracted dir %s is not a valid multi skill source root", dir) } for _, rel := range []string{ + filepath.Join("dingtalk-event", "SKILL.md"), + filepath.Join("dingtalk-event", "references", "event-oa.md"), filepath.Join("dingtalk-shared", "SKILL.md"), filepath.Join("dingtalk-misc", "SKILL.md"), filepath.Join("dingtalk-misc", "references", "pat.md"), @@ -79,6 +82,11 @@ func TestMaterializeEmbeddedSkillSourceMulti(t *testing.T) { t.Errorf("expected embedded multi skill to contain %s: %v", rel, err) } } + if _, err := os.Stat(filepath.Join(dir, "dingtalk-misc", "references", "event.md")); err == nil { + t.Fatal("embedded misc must not retain the folded personal Event reference") + } else if !os.IsNotExist(err) { + t.Fatalf("stat embedded misc event reference: %v", err) + } if _, err := os.Stat(filepath.Join(dir, "schema-hints")); err == nil { t.Fatal("embedded multi skill must not contain build-only schema-hints") } else if !os.IsNotExist(err) { diff --git a/internal/app/skill_setup_test.go b/internal/app/skill_setup_test.go index aa8a6ed8..9e73f9fe 100644 --- a/internal/app/skill_setup_test.go +++ b/internal/app/skill_setup_test.go @@ -2,6 +2,7 @@ package app import ( "bytes" + "io" "os" "path/filepath" "strings" @@ -549,3 +550,224 @@ func TestResolveSkillSetupSourceMultiFinds(t *testing.T) { t.Fatalf("expected %s, got %s", multiDir, got) } } + +func executeMultiSkillSetupTest(t *testing.T, src string, dests []string, args ...string) (string, string, error) { + t.Helper() + originalTargets := skillSetupResolveTargets + skillSetupResolveTargets = func(string, string) ([]string, error) { + return append([]string(nil), dests...), nil + } + t.Cleanup(func() { skillSetupResolveTargets = originalTargets }) + + cmd := newSkillSetupCommand() + cmd.Flags().Bool("dry-run", false, "") + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + baseArgs := []string{"--mode", "multi", "--source", src} + cmd.SetArgs(append(baseArgs, args...)) + err := cmd.Execute() + return stdout.String(), stderr.String(), err +} + +func writeFoldedEventMisc(t *testing.T, agentHome string) { + t.Helper() + miscRoot := filepath.Join(agentHome, multiMiscSkill) + if err := os.MkdirAll(filepath.Join(miscRoot, "references"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(miscRoot, "SKILL.md"), []byte("personal IM route: dws event consume\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(miscRoot, "references", "event.md"), []byte("folded event docs\n"), 0o644); err != nil { + t.Fatal(err) + } +} + +func TestSkillSetupSelectiveEventMigratesOnlyFoldedTargets(t *testing.T) { + src := writeMultiSkillSource(t, []string{ + multiEventSkill, multiSharedSkill, multiMiscSkill, "dingtalk-doc", + }) + foldedHome := filepath.Join(t.TempDir(), "folded", "skills") + freshHome := filepath.Join(t.TempDir(), "fresh", "skills") + writeFoldedEventMisc(t, foldedHome) + if err := os.MkdirAll(filepath.Join(foldedHome, multiEventSkill), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(foldedHome, multiEventSkill, "SKILL.md"), []byte("old standalone event\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(foldedHome, "dingtalk-chat"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(foldedHome, "dingtalk-chat", "SKILL.md"), []byte("keep sibling\n"), 0o644); err != nil { + t.Fatal(err) + } + + stdout, stderr, err := executeMultiSkillSetupTest(t, src, []string{freshHome, foldedHome}, "--skill", "event") + if err != nil { + t.Fatalf("selective event setup failed: %v\nstderr=%s\nstdout=%s", err, stderr, stdout) + } + if !strings.Contains(stdout, "迁移伴侣") || !strings.Contains(stdout, foldedHome) { + t.Fatalf("confirmation output should expose folded misc migration: %s", stdout) + } + if !strings.Contains(stdout, "重新加载 Skills") { + t.Fatalf("completion should tell the user to reload skills: %s", stdout) + } + + for _, home := range []string{freshHome, foldedHome} { + for _, name := range []string{multiSharedSkill, multiEventSkill} { + if _, err := os.Stat(filepath.Join(home, name, "SKILL.md")); err != nil { + t.Errorf("%s missing from %s: %v", name, home, err) + } + } + if _, err := os.Stat(filepath.Join(home, "dingtalk-doc")); !os.IsNotExist(err) { + t.Errorf("unselected doc appeared in %s: %v", home, err) + } + } + if _, err := os.Stat(filepath.Join(freshHome, multiMiscSkill)); !os.IsNotExist(err) { + t.Fatalf("fresh selective target must not receive misc, stat err=%v", err) + } + if _, err := os.Stat(filepath.Join(foldedHome, multiMiscSkill, "references", "event.md")); !os.IsNotExist(err) { + t.Fatalf("folded event reference survived clean misc replacement, stat err=%v", err) + } + eventBody, err := os.ReadFile(filepath.Join(foldedHome, multiEventSkill, "SKILL.md")) + if err != nil || strings.Contains(string(eventBody), "old standalone") { + t.Fatalf("old standalone event was not replaced: body=%q err=%v", eventBody, err) + } + siblingBody, err := os.ReadFile(filepath.Join(foldedHome, "dingtalk-chat", "SKILL.md")) + if err != nil || string(siblingBody) != "keep sibling\n" { + t.Fatalf("unrelated sibling changed: body=%q err=%v", siblingBody, err) + } + + // A second selective run sees the already-clean misc, does not plan another + // migration, and leaves that unselected sibling in place. + stdout, stderr, err = executeMultiSkillSetupTest(t, src, []string{freshHome, foldedHome}, "--skill", "event", "--yes") + if err != nil { + t.Fatalf("idempotent event setup failed: %v\nstderr=%s", err, stderr) + } + if strings.Contains(stdout, "迁移伴侣") { + t.Fatalf("clean second run should not re-detect folded misc: %s", stdout) + } + if _, err := os.Stat(filepath.Join(foldedHome, multiMiscSkill, "SKILL.md")); err != nil { + t.Fatalf("second selective run removed clean misc: %v", err) + } +} + +func TestSkillSetupEventMigrationDryRunAndExplicitExclude(t *testing.T) { + src := writeMultiSkillSource(t, []string{ + multiEventSkill, multiSharedSkill, multiMiscSkill, "dingtalk-doc", + }) + + t.Run("dry run reports companion without writes", func(t *testing.T) { + home := filepath.Join(t.TempDir(), "skills") + writeFoldedEventMisc(t, home) + stdout, stderr, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--dry-run", "--yes") + if err != nil { + t.Fatalf("dry run failed: %v\nstderr=%s", err, stderr) + } + if !strings.Contains(stdout, "DRY-RUN") || !strings.Contains(stdout, "迁移伴侣") { + t.Fatalf("dry run did not expose migration: %s", stdout) + } + body, readErr := os.ReadFile(filepath.Join(home, multiMiscSkill, "SKILL.md")) + if readErr != nil || !strings.Contains(string(body), "dws event") { + t.Fatalf("dry run changed folded misc: body=%q err=%v", body, readErr) + } + if _, statErr := os.Stat(filepath.Join(home, multiEventSkill)); !os.IsNotExist(statErr) { + t.Fatalf("dry run installed event, stat err=%v", statErr) + } + }) + + t.Run("excluding required misc fails before writes", func(t *testing.T) { + home := filepath.Join(t.TempDir(), "skills") + writeFoldedEventMisc(t, home) + _, _, err := executeMultiSkillSetupTest(t, src, []string{home}, "--exclude", "misc", "--yes") + if err == nil || !strings.Contains(err.Error(), "不能显式 --exclude misc") { + t.Fatalf("expected clear migration exclusion error, got %v", err) + } + if _, statErr := os.Stat(filepath.Join(home, multiEventSkill)); !os.IsNotExist(statErr) { + t.Fatalf("failed migration installed event, stat err=%v", statErr) + } + body, readErr := os.ReadFile(filepath.Join(home, multiMiscSkill, "SKILL.md")) + if readErr != nil || !strings.Contains(string(body), "dws event") { + t.Fatalf("failed migration changed misc: body=%q err=%v", body, readErr) + } + }) +} + +func TestSkillSetupEventMigrationRequiresCleanMiscInSource(t *testing.T) { + src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill}) + home := filepath.Join(t.TempDir(), "skills") + writeFoldedEventMisc(t, home) + + _, _, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--yes") + if err == nil || !strings.Contains(err.Error(), "当前 multi 源缺少") { + t.Fatalf("expected missing migration companion error, got %v", err) + } + if _, statErr := os.Stat(filepath.Join(home, multiEventSkill)); !os.IsNotExist(statErr) { + t.Fatalf("failed preflight installed event, stat err=%v", statErr) + } +} + +func TestSkillSetupSelectiveEventPreservesFoldedMiscAfterPrimarySkip(t *testing.T) { + src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill}) + home := filepath.Join(t.TempDir(), "skills") + writeFoldedEventMisc(t, home) + + originalInstallMulti := skillSetupInstallMulti + t.Cleanup(func() { skillSetupInstallMulti = originalInstallMulti }) + calls := 0 + skillSetupInstallMulti = func(string, []string, []string, io.Writer, io.Writer) (int, int, error) { + calls++ + if calls > 1 { + t.Fatal("misc migration companion ran after a primary install skip") + } + return 1, 1, nil + } + + stdout, _, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--yes") + if err == nil || !strings.Contains(err.Error(), "已保留旧 dingtalk-misc") { + t.Fatalf("expected preserved-fallback error, got %v", err) + } + if calls != 1 { + t.Fatalf("install calls = %d, want 1", calls) + } + if strings.Contains(stdout, "Skill 安装完成") { + t.Fatalf("partial migration reported success: %s", stdout) + } + body, readErr := os.ReadFile(filepath.Join(home, multiMiscSkill, "SKILL.md")) + if readErr != nil || !strings.Contains(string(body), "dws event") { + t.Fatalf("primary skip changed folded misc: body=%q err=%v", body, readErr) + } + if _, statErr := os.Stat(filepath.Join(home, multiMiscSkill, "references", "event.md")); statErr != nil { + t.Fatalf("primary skip removed folded Event reference: %v", statErr) + } +} + +func TestSkillSetupSelectiveEventCompanionSkipFails(t *testing.T) { + src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill}) + home := filepath.Join(t.TempDir(), "skills") + writeFoldedEventMisc(t, home) + + originalInstallMulti := skillSetupInstallMulti + t.Cleanup(func() { skillSetupInstallMulti = originalInstallMulti }) + calls := 0 + skillSetupInstallMulti = func(string, []string, []string, io.Writer, io.Writer) (int, int, error) { + calls++ + if calls == 1 { + return 2, 0, nil + } + return 0, 1, nil + } + + stdout, _, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--yes") + if err == nil || !strings.Contains(err.Error(), "dingtalk-misc 迁移不完整") { + t.Fatalf("expected companion migration error, got %v", err) + } + if calls != 2 { + t.Fatalf("install calls = %d, want 2", calls) + } + if strings.Contains(stdout, "Skill 安装完成") { + t.Fatalf("partial companion migration reported success: %s", stdout) + } +} diff --git a/internal/helpers/chat.go b/internal/helpers/chat.go index 7db040a9..1e896542 100644 --- a/internal/helpers/chat.go +++ b/internal/helpers/chat.go @@ -1268,7 +1268,7 @@ func newChatCommand() *cobra.Command { "请求涉及群聊管理、聊天记录、消息发送、会话设置或群机器人", }, AvoidWhen: []string{ - "邮件用 mail;开放平台应用/机器人建号发布用 dev;企业语义找人优先 aisearch person", + "实时监听未来 IM 事件用 event +listen-im;邮件用 mail;开放平台应用/机器人建号发布用 dev;企业语义找人优先 aisearch person", }, }, }) diff --git a/internal/helpers/devapp.go b/internal/helpers/devapp.go index 782f33ea..c01973e8 100644 --- a/internal/helpers/devapp.go +++ b/internal/helpers/devapp.go @@ -94,7 +94,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command { "请求涉及企业内部应用的查询、创建、配置、成员权限、机器人、事件订阅或版本管理", }, AvoidWhen: []string{ - "开放平台接口文档搜索使用 devdoc;普通钉钉业务数据使用对应产品命令", + "个人 IM/OA 实时事件监听使用 event;开放平台接口文档搜索使用 devdoc;普通钉钉业务数据使用对应产品命令", }, }, }) @@ -293,7 +293,7 @@ func newDevAppEventListCommand(runner executor.Runner) *cobra.Command { Selection: contract.SelectionSpec{ AgentSummary: "列出或搜索应用可订阅的事件", UseWhen: []string{"需要查事件码、事件名称或当前事件列表时"}, - AvoidWhen: []string{"订阅或退订事件使用对应写命令"}, + AvoidWhen: []string{"订阅或退订应用回调事件使用对应写命令;监听当前用户个人 IM/OA 事件使用 event"}, Examples: []string{`dws dev app event list --unified-app-id --keyword "审批" --page-size 20`}, }, }, @@ -329,7 +329,7 @@ func newDevAppEventSubscribeCommand(runner executor.Runner) *cobra.Command { Selection: contract.SelectionSpec{ AgentSummary: "为应用订阅指定事件码", UseWhen: []string{"已确认事件码并需要新增事件订阅时"}, - AvoidWhen: []string{"查询事件码或已有订阅时先使用 dev app event list"}, + AvoidWhen: []string{"查询事件码或已有订阅时先使用 dev app event list;个人 IM/OA 事件长连接监听使用 event"}, Examples: []string{"dws dev app event subscribe --unified-app-id --event-codes bpms_task_change --dry-run"}, }, }, @@ -365,7 +365,7 @@ func newDevAppEventUnsubscribeCommand(runner executor.Runner) *cobra.Command { Selection: contract.SelectionSpec{ AgentSummary: "取消应用的指定事件订阅", UseWhen: []string{"需要停止接收一个或多个已订阅事件时"}, - AvoidWhen: []string{"只是查看事件订阅时使用 dev app event list"}, + AvoidWhen: []string{"只是查看应用事件订阅时使用 dev app event list;停止个人事件监听使用 event stop"}, Examples: []string{"dws dev app event unsubscribe --unified-app-id --event-codes bpms_task_change --dry-run"}, }, }, diff --git a/internal/helpers/oa.go b/internal/helpers/oa.go index 0a9d6ab6..87413498 100644 --- a/internal/helpers/oa.go +++ b/internal/helpers/oa.go @@ -63,7 +63,7 @@ func newOaCommand() *cobra.Command { "查看待审、已办、已发起或抄送审批,并执行同意、拒绝、撤销、转交等审批动作时", }, AvoidWhen: []string{ - "不要用于普通待办任务或工作日志;需要创建审批实例时先确认当前命令面是否支持", + "不要用于普通待办任务或工作日志;需要实时监听未来的审批任务/实例事件时使用 event consume", }, }, }) diff --git a/scripts/install-event.sh b/scripts/install-event.sh index 39f0a0d5..3402a333 100755 --- a/scripts/install-event.sh +++ b/scripts/install-event.sh @@ -4,7 +4,8 @@ # # One-command installer for dws personal events. # Downloads the official dws binary and installs: -# - multi skill: dingtalk-misc (hosts personal IM event docs) +# - multi skill: dingtalk-event (personal IM/OA event routing) +# - multi prerequisites: dingtalk-shared + clean dingtalk-misc # - mono skill: dws # # Usage: @@ -25,7 +26,9 @@ INSTALL_DIR="${DWS_INSTALL_DIR:-$HOME/.local/bin}" NO_SKILLS="${DWS_NO_SKILLS:-0}" SKILLS_ONLY="${DWS_SKILLS_ONLY:-0}" BIN_NAME="dws" -EVENT_SKILL_NAME="dingtalk-misc" +EVENT_SKILL_NAME="dingtalk-event" +SHARED_SKILL_NAME="dingtalk-shared" +MISC_SKILL_NAME="dingtalk-misc" MONO_SKILL_NAME="dws" if [ "$EVENT_VERSION" = "latest" ]; then @@ -123,12 +126,13 @@ install_skill_to_homes() { printf '%s\n' "$installed" } -find_event_skill_src() { +find_multi_skill_src() { bundle="$1" + skill_name="$2" for c in \ - "$bundle/multi/$EVENT_SKILL_NAME" \ - "$bundle/skills/multi/$EVENT_SKILL_NAME" \ - "$bundle/$EVENT_SKILL_NAME" + "$bundle/multi/$skill_name" \ + "$bundle/skills/multi/$skill_name" \ + "$bundle/$skill_name" do if [ -f "$c/SKILL.md" ]; then printf '%s\n' "$c" @@ -157,23 +161,40 @@ find_mono_skill_src() { install_skills_from_bundle() { bundle="$1" - event_src="$(find_event_skill_src "$bundle" || true)" + # Resolve the complete, version-matched set before touching any installed or + # cached skill. A malformed release therefore cannot leave a half-migrated + # event/misc pair behind. + event_src="$(find_multi_skill_src "$bundle" "$EVENT_SKILL_NAME" || true)" [ -n "$event_src" ] || err "${EVENT_SKILL_NAME} not found in dws-skills.zip" + shared_src="$(find_multi_skill_src "$bundle" "$SHARED_SKILL_NAME" || true)" + [ -n "$shared_src" ] || err "${SHARED_SKILL_NAME} not found in dws-skills.zip" + misc_src="$(find_multi_skill_src "$bundle" "$MISC_SKILL_NAME" || true)" + [ -n "$misc_src" ] || err "${MISC_SKILL_NAME} not found in dws-skills.zip" mono_src="$(find_mono_skill_src "$bundle" || true)" [ -n "$mono_src" ] || err "mono ${MONO_SKILL_NAME} skill not found in dws-skills.zip" event_cache="$HOME/.dws/skills/multi/$EVENT_SKILL_NAME" + shared_cache="$HOME/.dws/skills/multi/$SHARED_SKILL_NAME" + misc_cache="$HOME/.dws/skills/multi/$MISC_SKILL_NAME" copy_tree "$event_src" "$event_cache" + copy_tree "$shared_src" "$shared_cache" + copy_tree "$misc_src" "$misc_cache" mono_cache="$HOME/.dws/skills/mono" copy_tree "$mono_src" "$mono_cache" event_installed="$(install_skill_to_homes "$event_src" "$EVENT_SKILL_NAME")" + shared_installed="$(install_skill_to_homes "$shared_src" "$SHARED_SKILL_NAME")" + misc_installed="$(install_skill_to_homes "$misc_src" "$MISC_SKILL_NAME")" mono_installed="$(install_skill_to_homes "$mono_src" "$MONO_SKILL_NAME")" say "Skill ${EVENT_SKILL_NAME} -> ${event_installed} agent dir(s)" + say "Skill ${SHARED_SKILL_NAME} -> ${shared_installed} agent dir(s)" + say "Skill ${MISC_SKILL_NAME} -> ${misc_installed} agent dir(s)" say "Skill ${MONO_SKILL_NAME} -> ${mono_installed} agent dir(s)" say "Cached ${EVENT_SKILL_NAME} -> ${event_cache}" + say "Cached ${SHARED_SKILL_NAME} -> ${shared_cache}" + say "Cached ${MISC_SKILL_NAME} -> ${misc_cache}" say "Cached mono ${MONO_SKILL_NAME} -> ${mono_cache}" } diff --git a/scripts/policy/check-skill-context-budget.sh b/scripts/policy/check-skill-context-budget.sh index 1d63b6d7..1205e104 100755 --- a/scripts/policy/check-skill-context-budget.sh +++ b/scripts/policy/check-skill-context-budget.sh @@ -7,7 +7,7 @@ cd "$ROOT" python3 scripts/gen_skill_shortcut_sections.py --check chat_skill="skills/multi/dingtalk-chat/SKILL.md" -event_skill="skills/multi/dingtalk-misc/references/event.md" +event_skill="skills/multi/dingtalk-event/SKILL.md" mono_skill="skills/mono/SKILL.md" runtime_contract="skills/multi/dingtalk-shared/references/runtime-contract.md" chat_max_bytes=10000 diff --git a/scripts/policy/multi-im-skill-chain/main.go b/scripts/policy/multi-im-skill-chain/main.go index 8a3e21e5..a6f75492 100644 --- a/scripts/policy/multi-im-skill-chain/main.go +++ b/scripts/policy/multi-im-skill-chain/main.go @@ -1,8 +1,9 @@ // Copyright 2026 Alibaba Group // Licensed under the Apache License, Version 2.0 (the "License"); -// Command multi-im-skill-chain verifies that reviewed high-frequency IM -// intents keep one default route across Skill references and Agent selection. +// Command multi-im-skill-chain verifies that reviewed high-frequency IM and +// personal-event intents keep one default route across Skill references and +// Agent selection. package main import ( diff --git a/scripts/policy/multi-im-skill-chain/main_test.go b/scripts/policy/multi-im-skill-chain/main_test.go index 81274b1a..009219a6 100644 --- a/scripts/policy/multi-im-skill-chain/main_test.go +++ b/scripts/policy/multi-im-skill-chain/main_test.go @@ -96,13 +96,13 @@ func TestCrossPlatformCoverageValidateManifestAcceptsExactReviewedRoute(t *testi root := t.TempDir() writeTestFile(t, root, "skills/multi/dingtalk-chat/SKILL.md", "| 发消息 | `dws chat +send` | \n") writeTypedContractReference(t, root, "skills/multi/dingtalk-chat/references/contracts.md") - writeEventHandoffReference(t, root, "skills/multi/dingtalk-misc/references/event-im-output.md") + writeEventHandoffReference(t, root, "skills/multi/dingtalk-event/references/event-im-output.md") manifest := routeManifest{ Version: 3, MarkerRoots: []string{"skills/multi/dingtalk-chat"}, RetiredScripts: []string{"skills/multi/dingtalk-chat/scripts/retired.py"}, ContractReference: "skills/multi/dingtalk-chat/references/contracts.md", - HandoffReference: "skills/multi/dingtalk-misc/references/event-im-output.md", + HandoffReference: "skills/multi/dingtalk-event/references/event-im-output.md", Intents: []intentRoute{{ ID: "chat.send", PreferredTool: "chat.shortcut_send", @@ -123,13 +123,13 @@ func TestCrossPlatformCoverageValidateManifestRejectsWrongMarkerAndSafetyDowngra root := t.TempDir() writeTestFile(t, root, "skills/multi/dingtalk-chat/SKILL.md", "| 发消息 | `dws chat message send` | \n") writeTypedContractReference(t, root, "skills/multi/dingtalk-chat/references/contracts.md") - writeEventHandoffReference(t, root, "skills/multi/dingtalk-misc/references/event-im-output.md") + writeEventHandoffReference(t, root, "skills/multi/dingtalk-event/references/event-im-output.md") manifest := routeManifest{ Version: 3, MarkerRoots: []string{"skills/multi/dingtalk-chat"}, RetiredScripts: []string{"skills/multi/dingtalk-chat/scripts/retired.py"}, ContractReference: "skills/multi/dingtalk-chat/references/contracts.md", - HandoffReference: "skills/multi/dingtalk-misc/references/event-im-output.md", + HandoffReference: "skills/multi/dingtalk-event/references/event-im-output.md", Intents: []intentRoute{{ ID: "chat.send", PreferredTool: "chat.shortcut_send", diff --git a/scripts/policy/multi-im-skill-chain/testdata/intent_routes.json b/scripts/policy/multi-im-skill-chain/testdata/intent_routes.json index d904c90a..05986533 100644 --- a/scripts/policy/multi-im-skill-chain/testdata/intent_routes.json +++ b/scripts/policy/multi-im-skill-chain/testdata/intent_routes.json @@ -2,7 +2,7 @@ "version": 3, "marker_roots": [ "skills/multi/dingtalk-chat", - "skills/multi/dingtalk-misc/references" + "skills/multi/dingtalk-event" ], "retired_scripts": [ "skills/multi/dingtalk-chat/scripts/_chat_message_export_common.py", @@ -15,7 +15,7 @@ "skills/mono/scripts/bot_broadcast.py" ], "contract_reference": "skills/multi/dingtalk-chat/references/contracts.md", - "handoff_reference": "skills/multi/dingtalk-misc/references/event-im-output.md", + "handoff_reference": "skills/multi/dingtalk-event/references/event-im-output.md", "intents": [ { "id": "chat.send.dm", @@ -148,8 +148,19 @@ "event.consume" ], "references": [ - "skills/multi/dingtalk-misc/references/event.md", - "skills/multi/dingtalk-misc/references/event-im.md" + "skills/multi/dingtalk-event/SKILL.md", + "skills/multi/dingtalk-event/references/event-im.md" + ] + }, + { + "id": "event.listen.oa", + "preferred_tool": "event.consume", + "forbidden_default_tools": [ + "event.listen_im" + ], + "references": [ + "skills/multi/dingtalk-event/SKILL.md", + "skills/multi/dingtalk-event/references/event-oa.md" ] } ] diff --git a/skills/content-qa/mono-multi-coverage.yaml b/skills/content-qa/mono-multi-coverage.yaml index c7a6da11..80498604 100644 --- a/skills/content-qa/mono-multi-coverage.yaml +++ b/skills/content-qa/mono-multi-coverage.yaml @@ -40,9 +40,10 @@ coverage: - mono: drive multi_skill: dingtalk-drive - mono: event - multi_skill: dingtalk-misc + multi_skill: dingtalk-event multi_refs: - - references/event.md + - references/event-im.md + - references/event-oa.md - mono: hrbrain multi_skill: dingtalk-misc multi_refs: diff --git a/skills/mono/references/products/event.md b/skills/mono/references/products/event.md index af76fcc2..f9b82c95 100644 --- a/skills/mono/references/products/event.md +++ b/skills/mono/references/products/event.md @@ -262,7 +262,6 @@ dws event stop --all --yes ## Full reference -- multi skill: `skills/multi/dingtalk-misc/SKILL.md`(产品索引 → event) -- Event reference: `skills/multi/dingtalk-misc/references/event.md` -- IM task index: `skills/multi/dingtalk-misc/references/event-im.md` -- OA reference: `skills/multi/dingtalk-misc/references/event-oa.md` +- multi skill: `skills/multi/dingtalk-event/SKILL.md` +- IM task index: `skills/multi/dingtalk-event/references/event-im.md` +- OA reference: `skills/multi/dingtalk-event/references/event-oa.md` diff --git a/skills/multi/dingtalk-chat/SKILL.md b/skills/multi/dingtalk-chat/SKILL.md index 8ad44903..6b261ddb 100644 --- a/skills/multi/dingtalk-chat/SKILL.md +++ b/skills/multi/dingtalk-chat/SKILL.md @@ -52,7 +52,7 @@ metadata: | 查看全部会话 | `dws chat +conversation-list --page-all` | 检查 `complete` / `failures` | | 读取并下载消息资源 | 查询命令加 `--download-resources` | 不另起手工下载循环;下载失败项保留在结果中 | | 查看置顶会话 | `dws chat +conversation-list-top` | 会话 Top 与消息 Pin、消息 Top、Favorite 不同 | -| 监听 IM 事件 | 切换 `dingtalk-misc` → [event.md](../dingtalk-misc/references/event.md) | 由事件参考选择确定的 EventKey 和生命周期 | +| 监听未来 IM 事件 | [`dingtalk-event`](../dingtalk-event/SKILL.md) | 常规监听走 `+listen-im`;生命周期/高级控制走 `consume` | 以下次级入口在意图明确时直接使用,不需要先加载完整 Catalog: diff --git a/skills/multi/dingtalk-misc/references/event.md b/skills/multi/dingtalk-event/SKILL.md similarity index 77% rename from skills/multi/dingtalk-misc/references/event.md rename to skills/multi/dingtalk-event/SKILL.md index 0ecc2396..4fdd75a9 100644 --- a/skills/multi/dingtalk-misc/references/event.md +++ b/skills/multi/dingtalk-event/SKILL.md @@ -1,6 +1,19 @@ +--- +name: dingtalk-event +description: 钉钉个人 IM 与 OA 审批事件长连接监听。Use when 用户说监听消息/@我/某人/某群/全部消息、已读/撤回/reaction、群成员加入/群成员退出/群状态变化,或监听审批任务创建/完成/转交、审批实例发起/终止/完成。命令前缀:dws event。 +metadata: + cli_version: ">=0.2.14" + category: product + requires: + bins: + - dws +--- + # 钉钉个人 IM 与 OA 审批事件 -> 本文件为 `dingtalk-misc` 内个人 IM 与 OA 审批事件产品入口。命令前缀:`dws event`。命中后先读本页 Golden Route;子 reference 按需加载,不要预加载全部。 +> **前置:执行 `dws` 前必须完整读取 [`dingtalk-shared`](../dingtalk-shared/SKILL.md)。**Shared references 仅按需加载。 + +本 Skill 只负责未来个人 IM/OA 实时事件;发送和历史消息走 `dingtalk-chat`,审批查询与处理走 `dingtalk-misc` 的 OA,开放平台应用事件配置走其 DevApp。子 reference 按需加载。 实时监听必须使用事件长连接,不写轮询脚本,不用历史消息或审批列表查询模拟事件。高频 IM 意图优先交给 `dws event +listen-im`;它在 CLI 内解析自然目标、选择 EventKey,并复用现有订阅与 bus 生命周期。OA 审批事件使用显式 `dws event consume`。 @@ -8,6 +21,8 @@ 只有群生命周期、Filter DSL、原始 envelope 或底层订阅控制才使用 `event consume` fallback。 +OA 审批任务与审批实例的实时变化使用 `dws event consume`;查询或操作已有审批走 `dws oa`,不要用轮询模拟事件。 + ## Golden Route | 用户意图 | 唯一推荐入口 | @@ -17,8 +32,8 @@ | 监听指定群消息 | `dws event +listen-im --kind group --chat-query <群名>` | | 同一人/群的消息、表情、已读或撤回 | `dws event +listen-im --kind --events message,reaction,read,recall ...` | | 监听全部单聊或全部群消息 | `dws event +listen-im --kind `;只有用户明确要求“全部”时使用 | -| 群改名、成员进退、群解散 | 读取 [EventKey 索引](./event-im-keys.md),使用精确 `event consume` EventKey | -| OA 审批任务或实例事件 | 读取 [OA 事件参考](./event-oa.md),使用精确 `event consume` EventKey | +| 群改名、成员进退、群解散 | 读取 [EventKey 索引](references/event-im-keys.md),使用精确 `event consume` EventKey | +| OA 审批任务或实例事件 | 读取 [OA 事件参考](references/event-oa.md),使用精确 `event consume` EventKey | | 查看 OA 事件目录 | `dws event list --category oa` | | 已知 EventKey 或需要底层订阅控制 | `dws event consume`;参数与约束以 leaf Schema 为准 | | 查看状态 / 停止 | `dws event status` / `dws event stop --dry-run`,确认后再 `--yes` | @@ -50,7 +65,7 @@ user_im_group_member_added user_im_group_member_exited user_im_group_disbanded ``` -六个 OA EventKey 及其输出字段见 [OA 事件参考](./event-oa.md)。 +六个 OA EventKey 及其输出字段见 [OA 事件参考](references/event-oa.md)。 用户类事件传 `--user` 或 `--open-dingtalk-id`,群类事件传 `--group`。群生命周期输出可含 `operator_open_dingtalk_id` 和 `members`;成员项使用 `open_dingtalk_id`。精确组合、兼容性和 Filter 规则见 reference。 @@ -75,9 +90,9 @@ kind + events + target - 有界任务使用 `--max-events N` 或 `--duration 10m`;无界任务需要宿主管理进程并持续读取 stdout。 - 干净退出会取消本次新建的订阅;使用 SIGTERM、关闭符合条件的管道 stdin,或 Runtime 的 bounded exit。不要 `kill -9`。 - 当前用户自己发送的消息会被 self-loop 过滤;自测事件应由另一用户或机器人发送。 -- 事件只负责监听;需要回复时按 [输出与 Chat 交接](./event-im-output.md) 把真实 `conversation_id` 或 `sender_open_dingtalk_id` 交给 `dws chat +messages-send`,不要从显示名猜 ID。 +- 事件只负责监听;需要回复时按 [输出与 Chat 交接](references/event-im-output.md) 把真实 `conversation_id` 或 `sender_open_dingtalk_id` 交给 `dws chat +messages-send`,不要从显示名猜 ID。 - 扁平消息/动作字段按事件类型读取:已读为 `reader_open_dingtalk_id`,撤回为 `recaller_open_dingtalk_id`,回应为 `reaction_name`、`operation_type`。媒体优先通过聊天读取命令加 `--download-resources`;已知消息 ID 的底层降级入口是 `dws chat message download-media`。 -- OA 扁平事件提供审批实例、任务和状态字段;字段差异、原始回退条件及与 OA 命令的稳定 ID 交接以 [OA 事件参考](./event-oa.md) 为准。 +- OA 扁平事件提供审批实例、任务和状态字段;字段差异、原始回退条件及与 OA 命令的稳定 ID 交接以 [OA 事件参考](references/event-oa.md) 为准。 ## 安全与失败处理 @@ -85,7 +100,7 @@ kind + events + target - 多事件属于一次原始操作;任一订阅启动失败时 Runtime 回滚本次已创建项,不拆成新命令绕过重试预算。 - 这套 `0/2/1` 是 **Agent/host** 编排预算,适用于全部 22 个公开个人 EventKey(16 个 IM + 6 个 OA):`retryable=false` 对应 `max_additional_attempts=0`;`retryable=true` 对应 `max_additional_attempts=2`;`retryable=unknown` 对应 `max_additional_attempts=1`。它不是 CLI 持久化硬总次数上限;每次调用最多创建一次,进程内不会自动重试,CLI 也不持久化或计算跨调用的 Agent/host 尝试次数。 - 重试必须遵守 `retry_after_seconds` / `next_retry_at`。遇到 `in_flight`、`cooldown`、`terminal_hold` 不并发或递归重启同一逻辑订阅,也不换 `subscribe_id` / `trace_id` 绕过保护。 -- 认证、profile、订阅保护状态和 bus 排障按失败类型读取 [订阅运维](./event-im-operations.md),不要在正常路径预加载完整运维手册。 +- 认证、profile、订阅保护状态和 bus 排障按失败类型读取 [订阅运维](references/event-im-operations.md),不要在正常路径预加载完整运维手册。 ### 本地订阅保护契约 @@ -105,9 +120,9 @@ kind + events + target | Topic | Reference | 何时读取 | |---|---|---| -| 任务索引 | [event-im.md](./event-im.md) | 还不能判断应该加载哪一个子 reference | -| EventKey、目标规则与底层 consume | [event-im-keys.md](./event-im-keys.md) | 群生命周期、显式 EventKey 或多事件组合 | -| ready、bounded consume 与退出清理 | [event-im-lifecycle.md](./event-im-lifecycle.md) | 启动/托管/关闭 consumer | -| 扁平字段与事件到 Chat 交接 | [event-im-output.md](./event-im-output.md) | 解析事件或自动回复 | -| Filter、status/stop、重试与排障 | [event-im-operations.md](./event-im-operations.md) | 订阅控制或失败恢复 | -| OA 审批事件 | [event-oa.md](./event-oa.md) | 选择六个 OA EventKey、组合消费或解析审批字段 | +| 任务索引 | [event-im.md](references/event-im.md) | 还不能判断应该加载哪一个子 reference | +| EventKey、目标规则与底层 consume | [event-im-keys.md](references/event-im-keys.md) | 群生命周期、显式 EventKey 或多事件组合 | +| ready、bounded consume 与退出清理 | [event-im-lifecycle.md](references/event-im-lifecycle.md) | 启动/托管/关闭 consumer | +| 扁平字段与事件到 Chat 交接 | [event-im-output.md](references/event-im-output.md) | 解析事件或自动回复 | +| Filter、status/stop、重试与排障 | [event-im-operations.md](references/event-im-operations.md) | 订阅控制或失败恢复 | +| OA 审批事件 | [event-oa.md](references/event-oa.md) | 选择六个 OA EventKey、组合消费或解析审批字段 | diff --git a/skills/multi/dingtalk-misc/references/event-im-keys.md b/skills/multi/dingtalk-event/references/event-im-keys.md similarity index 100% rename from skills/multi/dingtalk-misc/references/event-im-keys.md rename to skills/multi/dingtalk-event/references/event-im-keys.md diff --git a/skills/multi/dingtalk-misc/references/event-im-lifecycle.md b/skills/multi/dingtalk-event/references/event-im-lifecycle.md similarity index 100% rename from skills/multi/dingtalk-misc/references/event-im-lifecycle.md rename to skills/multi/dingtalk-event/references/event-im-lifecycle.md diff --git a/skills/multi/dingtalk-misc/references/event-im-operations.md b/skills/multi/dingtalk-event/references/event-im-operations.md similarity index 100% rename from skills/multi/dingtalk-misc/references/event-im-operations.md rename to skills/multi/dingtalk-event/references/event-im-operations.md diff --git a/skills/multi/dingtalk-misc/references/event-im-output.md b/skills/multi/dingtalk-event/references/event-im-output.md similarity index 100% rename from skills/multi/dingtalk-misc/references/event-im-output.md rename to skills/multi/dingtalk-event/references/event-im-output.md diff --git a/skills/multi/dingtalk-misc/references/event-im.md b/skills/multi/dingtalk-event/references/event-im.md similarity index 97% rename from skills/multi/dingtalk-misc/references/event-im.md rename to skills/multi/dingtalk-event/references/event-im.md index 5a1aff33..fc074dd3 100644 --- a/skills/multi/dingtalk-misc/references/event-im.md +++ b/skills/multi/dingtalk-event/references/event-im.md @@ -1,7 +1,7 @@ # IM 事件任务路由 本页是任务索引,不再混放 16 个 EventKey、输出字段和运维细节。先用上层 -[event.md](./event.md) 的 Golden Route;只加载当前任务对应的一份子 reference。 +[SKILL.md](../SKILL.md) 的 Golden Route;只加载当前任务对应的一份子 reference。 消息、reaction、已读和撤回默认使用 `dws event +listen-im` 长连接; 群生命周期、显式 EventKey、Filter DSL、原始 envelope 或底层订阅控制才使用 diff --git a/skills/multi/dingtalk-misc/references/event-oa.md b/skills/multi/dingtalk-event/references/event-oa.md similarity index 94% rename from skills/multi/dingtalk-misc/references/event-oa.md rename to skills/multi/dingtalk-event/references/event-oa.md index 6359da81..6d7ec4e2 100644 --- a/skills/multi/dingtalk-misc/references/event-oa.md +++ b/skills/multi/dingtalk-event/references/event-oa.md @@ -1,8 +1,8 @@ # OA 个人审批事件 -先读事件产品入口 [event.md](./event.md) 的命令规则、调用流和子进程契约。本参考覆盖当前公开的六个 OA 个人事件:审批实例发起、终止和完成,以及审批任务创建、完成和转交。 +先读事件产品入口 [SKILL.md](../SKILL.md) 的命令规则、调用流和子进程契约。本参考覆盖当前公开的六个 OA 个人事件:审批实例发起、终止和完成,以及审批任务创建、完成和转交。 -实时监听审批事件必须使用 `dws event consume` 长连接,不要轮询 OA 待办或审批实例列表来模拟事件。 +实时监听审批事件必须使用 `dws event consume` 长连接,不要轮询 OA 待办或审批实例列表来模拟事件。 ## Prerequisite diff --git a/skills/multi/dingtalk-misc/SKILL.md b/skills/multi/dingtalk-misc/SKILL.md index 9eefbb17..487f8c7f 100644 --- a/skills/multi/dingtalk-misc/SKILL.md +++ b/skills/multi/dingtalk-misc/SKILL.md @@ -1,6 +1,6 @@ --- name: dingtalk-misc -description: 长尾产品集合技能,覆盖低频钉钉产品:OA审批/考勤/直播/DING紧急消息/开放平台应用管理/Agoal目标管理/日志日报周报/电子表格/开放平台文档搜索/文档内嵌白板/DWS技能市场安装/个人 IM 事件与 OA 审批事件/组织大脑Hrbrain/原生Markdown/PAT行为授权/多组织profile。Use when 用户提到上述任一产品,或审批/打卡/排班/OKR/日报周报/单元格读写/白板节点读写/搜索安装技能/开发者后台应用/实时监听消息/被@消息/监听某人或某群/监听已读撤回表情回应/监听待审批任务或审批实例/群成员加入/群成员退出/群状态变化/事件驱动 Agent/人才池/员工档案/职业历程/绩效/原生.md文件/PAT授权/切换组织/跨组织/profile 等相关操作。命中后由本 skill 的「产品索引表」定位具体子产品和命令前缀,再按对应子产品说明执行。 +description: 长尾产品集合技能,覆盖低频钉钉产品:OA审批查询与处理/考勤/直播/DING紧急消息/开放平台应用管理/Agoal目标管理/日志日报周报/电子表格/开放平台文档搜索/文档内嵌白板/DWS技能市场安装/组织大脑Hrbrain/原生Markdown/PAT行为授权/多组织profile。Use when 用户提到上述任一产品,或查待审批/同意拒绝转交撤销审批/打卡/排班/OKR/日报周报/单元格读写/白板节点读写/搜索安装技能/开发者后台应用/人才池/员工档案/职业历程/绩效/原生.md文件/PAT授权/切换组织/跨组织/profile 等相关操作。未来审批任务或实例变化的实时监听不属于本 skill,应使用 dingtalk-event。命中后由本 skill 的「产品索引表」定位具体子产品和命令前缀,再按对应子产品说明执行。 metadata: cli_version: ">=0.2.14" category: product @@ -32,7 +32,6 @@ metadata: | 开放平台文档 / API文档 / 接口文档 / 接口报错 | 开放平台开发文档搜索 | `dws devdoc` | [devdoc.md](references/devdoc.md) | | 白板 / 画布 / OpenNodes / 白板节点 | 读取和更新钉钉文档中的内嵌白板 | `dws whiteboard` | [whiteboard.md](references/whiteboard.md) | | 搜索技能 / 找技能 / 安装技能 / 技能市场 / 安装 DWS mono 或 multi skill | DWS 技能市场搜索、下载、安装与内置技能部署 | `dws skill` | [skill.md](references/skill.md) | -| 个人 IM/OA 审批事件 / 实时监听 / 被@消息 / 监听某人某群 / 已读撤回表情回应 / 群成员加入退出 / 监听待审批任务或审批实例 / 事件驱动 Agent | 个人 IM 与 OA 审批事件长连接监听、订阅与消费 | `dws event` | [event.md](references/event.md) | | 人才池 / 储备干部池 / 员工档案 / 职业历程 / 绩效记录 / 员工标签 / 组织大脑 / 人才搜索 | 组织大脑:人才池、员工档案专项模块与结构化人才搜索 | `dws hrbrain` | [hrbrain.md](references/hrbrain.md) | | 原生 Markdown / `.md` 原文 / 覆盖 Markdown / 局部替换 Markdown | 原生 `.md` 文件读取、创建、全量覆盖与局部替换 | `dws markdown` | [markdown.md](references/markdown.md) | | PAT 授权 / 行为权限 / scope 授权 / 一次性授权 / 会话授权 / 永久授权 / 授权浏览器策略 | PAT 行为授权与本地浏览器策略 | `dws pat` | [pat.md](references/pat.md) | @@ -46,6 +45,6 @@ metadata: - 各产品之间跨产品协作若指向本包内的其它产品,已在对应 `references/.md` 里写成"见本包 references/X.md",无需切换 skill;若指向 top10 独立产品(如 `chat`/`aisearch`/`doc`),仍按 `dingtalk-` 切换 skill。 - `scripts/` 下 yida / finance / `aiapp_create_and_poll.py` 等见 [unsupported-scripts.md](references/unsupported-scripts.md);默认不要当正式能力调用。 - 开放平台应用的命令组细文档在 [references/dev/](references/dev/);命中后先读 [devapp.md](references/devapp.md),再按需加载对应子文件。 -- 个人 IM 或 OA 审批事件命中后先读 [event.md](references/event.md),再按需加载 `event-im*.md` 或 `event-oa.md`;查询或操作审批走 [oa.md](references/oa.md),实时监听审批状态变化走 event;不要与开放平台应用事件 [dev/event.md](references/dev/event.md) 混淆。 +- 查询、同意、拒绝、转交或撤销审批走 [oa.md](references/oa.md);要求未来审批任务或实例发生变化时实时通知,切换独立的 [`dingtalk-event`](../dingtalk-event/SKILL.md)。开放平台应用事件配置仍属于 DevApp,按 [dev/event.md](references/dev/event.md) 执行,不要与个人实时事件混淆。 - 原生 `.md` 与在线富文本 `adoc`、通用文件存储的边界见 [markdown.md](references/markdown.md);跨组织 / profile 规则见 [profile.md](references/profile.md)。 - PAT 行为授权不是开放平台应用权限;后者见 [devapp.md](references/devapp.md)。 diff --git a/skills/multi/dingtalk-shared/references/routing.md b/skills/multi/dingtalk-shared/references/routing.md index beebaca8..7fc79fa1 100644 --- a/skills/multi/dingtalk-shared/references/routing.md +++ b/skills/multi/dingtalk-shared/references/routing.md @@ -10,6 +10,7 @@ | AI 表格、多维表、字段、记录、视图、仪表盘 | [`dingtalk-aitable`](../../dingtalk-aitable/SKILL.md) | | 日程、参会人、会议室、闲忙 | [`dingtalk-calendar`](../../dingtalk-calendar/SKILL.md) | | 群聊、消息、机器人、Webhook、群成员 | [`dingtalk-chat`](../../dingtalk-chat/SKILL.md) | +| 实时监听未来 IM 消息、reaction、已读、撤回、群生命周期或 OA 审批变化 | [`dingtalk-event`](../../dingtalk-event/SKILL.md) | | 已有 userId 的用户详情、部门、角色、组织关系 | [`dingtalk-contact`](../../dingtalk-contact/SKILL.md) | | 文档正文读取、创建、更新、块编辑、媒体和导出 | [`dingtalk-doc`](../../dingtalk-doc/SKILL.md) | | 文件搜索、上传下载、复制移动、重命名、权限 | [`dingtalk-drive`](../../dingtalk-drive/SKILL.md) | @@ -23,7 +24,7 @@ | 组织大脑、人才池、员工档案专项、职业历程、绩效、结构化人才搜索 | [`dingtalk-misc`](../../dingtalk-misc/SKILL.md) → [`hrbrain.md`](../../dingtalk-misc/references/hrbrain.md) | | PAT 行为授权、scope 授权、授权浏览器策略 | [`dingtalk-misc`](../../dingtalk-misc/SKILL.md) → [`pat.md`](../../dingtalk-misc/references/pat.md) | | 切换组织、跨组织、多组织、profile 管理 | [`dingtalk-misc`](../../dingtalk-misc/SKILL.md) → [`profile.md`](../../dingtalk-misc/references/profile.md) | -| 审批、考勤、会议、电子表格、日志、DING、直播、开放平台应用、技能市场安装、个人 IM 事件等长尾产品 | [`dingtalk-misc`](../../dingtalk-misc/SKILL.md) | +| 审批查询与处理、考勤、会议、电子表格、日志、DING、直播、开放平台应用、技能市场安装等长尾产品 | [`dingtalk-misc`](../../dingtalk-misc/SKILL.md) | | 宜搭 / AI 应用创建脚本 / 财务辅助脚本(无稳定产品面) | [`dingtalk-misc`](../../dingtalk-misc/SKILL.md) → [`unsupported-scripts.md`](../../dingtalk-misc/references/unsupported-scripts.md);默认说明未产品化,勿当正式 CLI | 选择 `dingtalk-misc` 后,先读取其 `SKILL.md` 产品索引,再只读取命中产品的单个 @@ -41,10 +42,11 @@ reference,不要加载全部长尾产品文档。 - `calendar`:日历事件、参会人和会议室;视频会议(conference)当前 CLI 不支持,请在钉钉客户端操作; `minutes`:会后听记内容。 - `report`:钉钉日志系统中的日报/周报;`doc`:普通文档创作;`todo`:个人任务。 -- `chat`:普通会话和消息;`event`:个人 IM 事件长连接监听;`ding`:强提醒。 - `event` 与 `ding` 位于 `dingtalk-misc`。 +- `chat`:发送消息、读取历史消息和主动群操作;独立的 `event`:未来个人 IM/OA 事件长连接监听; + `ding`:强提醒,位于 `dingtalk-misc`。 - `hrbrain` / `markdown` / `pat` / `profile` 均位于 `dingtalk-misc`。 -- 请假、加班、外出、出差、补卡等考勤业务审批优先走 `attendance`;其他通用审批 - 走 `oa`。两者均位于 `dingtalk-misc`。 +- 请假、加班、外出、出差、补卡等考勤业务审批优先走 `attendance`;其他通用审批查询、同意、 + 拒绝、转交和撤销走 `oa`。两者均位于 `dingtalk-misc`;未来审批任务或实例变化的实时通知走 + 独立的 `dingtalk-event`。 边界仍无法判断时,只读取 [intent-guide.md](intent-guide.md) 的对应章节。 diff --git a/test/run_skill_tests.py b/test/run_skill_tests.py index 91c97fae..3cec1077 100644 --- a/test/run_skill_tests.py +++ b/test/run_skill_tests.py @@ -341,6 +341,8 @@ def get_reference_path(product: str) -> str: """Get the display path for the skill reference used by a product test.""" if product == 'routing': return 'references/intent-guide.md' + if product == 'event': + return 'skills/multi/dingtalk-event/SKILL.md' return f'references/products/{get_reference_file(product)}' def generate_report(results: list[dict]) -> str: diff --git a/test/scripts/install_script_test.go b/test/scripts/install_script_test.go index d1b15a8f..e104c709 100644 --- a/test/scripts/install_script_test.go +++ b/test/scripts/install_script_test.go @@ -219,6 +219,8 @@ func TestInstallEventScriptStaticExpectations(t *testing.T) { "releases/latest", "EVENT_VERSION", "DWS_SKILLS_ONLY", + "dingtalk-event", + "dingtalk-shared", "dingtalk-misc", "user_im_message_receive_o2o", ".config/opencode/skills", @@ -232,7 +234,6 @@ func TestInstallEventScriptStaticExpectations(t *testing.T) { for _, avoid := range []string{ "releases?per_page=30", "select(.tag_name", - "dingtalk-event", "dingtalk-dev", "client-secret", "--as app", @@ -270,9 +271,11 @@ func TestInstallEventScriptInstallsBinaryAndEventSkills(t *testing.T) { "dws": "fake-event-binary\n", }) writeZip(t, filepath.Join(releaseDir, "dws-skills.zip"), map[string]string{ - "multi/dingtalk-misc/SKILL.md": "event skill user_im_message_receive_o2o\n", - "mono/SKILL.md": "mono skill user_im_message_receive_o2o\n", - "SKILL.md": "legacy mono root\n", + "multi/dingtalk-event/SKILL.md": "event skill user_im_message_receive_o2o\n", + "multi/dingtalk-shared/SKILL.md": "shared prerequisite\n", + "multi/dingtalk-misc/SKILL.md": "clean misc oa routing\n", + "mono/SKILL.md": "mono skill user_im_message_receive_o2o\n", + "SKILL.md": "legacy mono root\n", }) writeFakeCurl(t, filepath.Join(stubRoot, "curl")) @@ -284,6 +287,12 @@ func TestInstallEventScriptInstallsBinaryAndEventSkills(t *testing.T) { t.Fatalf("MkdirAll(%s) error = %v", dir, err) } } + // Simulate both upgrade shapes: an old standalone Event skill and the + // short-lived folded Event-in-misc layout. An unrelated sibling must stay. + mustWriteFile(t, filepath.Join(fakeHome, ".agents", "skills", "dingtalk-event", "SKILL.md"), []byte("old standalone event\n"), 0o644) + mustWriteFile(t, filepath.Join(fakeHome, ".agents", "skills", "dingtalk-misc", "SKILL.md"), []byte("old misc dws event\n"), 0o644) + mustWriteFile(t, filepath.Join(fakeHome, ".agents", "skills", "dingtalk-misc", "references", "event.md"), []byte("folded event docs\n"), 0o644) + mustWriteFile(t, filepath.Join(fakeHome, ".agents", "skills", "dingtalk-chat", "SKILL.md"), []byte("keep sibling\n"), 0o644) scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install-event.sh")) if err != nil { @@ -302,9 +311,16 @@ func TestInstallEventScriptInstallsBinaryAndEventSkills(t *testing.T) { if err != nil { t.Fatalf("install-event.sh error = %v\noutput:\n%s", err, string(output)) } + repeat := exec.Command("sh", scriptPath) + repeat.Env = cmd.Env + if repeatOutput, repeatErr := repeat.CombinedOutput(); repeatErr != nil { + t.Fatalf("second install-event.sh run should be idempotent: %v\noutput:\n%s", repeatErr, string(repeatOutput)) + } got := string(output) for _, want := range []string{ "Version: v1.0.51", + "Skill dingtalk-event", + "Skill dingtalk-shared", "Skill dingtalk-misc", "Skill dws", "dws event consume user_im_message_receive_o2o", @@ -322,26 +338,35 @@ func TestInstallEventScriptInstallsBinaryAndEventSkills(t *testing.T) { t.Fatalf("installed binary content = %q", string(binaryData)) } - for _, rel := range []string{ - ".agents/skills/dingtalk-misc/SKILL.md", - ".codex/skills/dingtalk-misc/SKILL.md", - ".config/opencode/skills/dingtalk-misc/SKILL.md", - ".agents/skills/dws/SKILL.md", - ".codex/skills/dws/SKILL.md", - ".dws/skills/multi/dingtalk-misc/SKILL.md", - ".dws/skills/mono/SKILL.md", - } { + expectedSkills := map[string]string{ + ".agents/skills/dingtalk-event/SKILL.md": "user_im_message_receive_o2o", + ".codex/skills/dingtalk-event/SKILL.md": "user_im_message_receive_o2o", + ".config/opencode/skills/dingtalk-event/SKILL.md": "user_im_message_receive_o2o", + ".agents/skills/dingtalk-shared/SKILL.md": "shared prerequisite", + ".agents/skills/dingtalk-misc/SKILL.md": "clean misc oa routing", + ".agents/skills/dws/SKILL.md": "user_im_message_receive_o2o", + ".codex/skills/dws/SKILL.md": "user_im_message_receive_o2o", + ".dws/skills/multi/dingtalk-event/SKILL.md": "user_im_message_receive_o2o", + ".dws/skills/multi/dingtalk-shared/SKILL.md": "shared prerequisite", + ".dws/skills/multi/dingtalk-misc/SKILL.md": "clean misc oa routing", + ".dws/skills/mono/SKILL.md": "user_im_message_receive_o2o", + } + for rel, marker := range expectedSkills { p := filepath.Join(fakeHome, filepath.FromSlash(rel)) data, err := os.ReadFile(p) if err != nil { t.Fatalf("ReadFile(%s) error = %v\noutput:\n%s", p, err, got) } - if !strings.Contains(string(data), "user_im_message_receive_o2o") { - t.Fatalf("%s does not contain event skill marker: %q", p, string(data)) + if !strings.Contains(string(data), marker) { + t.Fatalf("%s does not contain %q: %q", p, marker, string(data)) } } - if _, err := os.Stat(filepath.Join(fakeHome, ".agents", "skills", "dingtalk-event")); !os.IsNotExist(err) { - t.Fatalf("dingtalk-event should not be installed by install-event.sh, stat err=%v", err) + if _, err := os.Stat(filepath.Join(fakeHome, ".agents", "skills", "dingtalk-misc", "references", "event.md")); !os.IsNotExist(err) { + t.Fatalf("folded misc event reference should be removed, stat err=%v", err) + } + sibling, err := os.ReadFile(filepath.Join(fakeHome, ".agents", "skills", "dingtalk-chat", "SKILL.md")) + if err != nil || string(sibling) != "keep sibling\n" { + t.Fatalf("unrelated sibling changed: data=%q err=%v", sibling, err) } if _, err := os.Stat(filepath.Join(fakeHome, ".agents", "skills", "dingtalk-dev")); !os.IsNotExist(err) { t.Fatalf("dingtalk-dev should not be installed by install-event.sh, stat err=%v", err) @@ -361,8 +386,10 @@ func TestInstallEventScriptSkillsOnlySkipsBinary(t *testing.T) { t.Fatalf("MkdirAll(%s) error = %v", releaseDir, err) } writeZip(t, filepath.Join(releaseDir, "dws-skills.zip"), map[string]string{ - "multi/dingtalk-misc/SKILL.md": "event skill user_im_message_receive_o2o\n", - "mono/SKILL.md": "mono skill user_im_message_receive_o2o\n", + "multi/dingtalk-event/SKILL.md": "event skill user_im_message_receive_o2o\n", + "multi/dingtalk-shared/SKILL.md": "shared prerequisite\n", + "multi/dingtalk-misc/SKILL.md": "clean misc oa routing\n", + "mono/SKILL.md": "mono skill user_im_message_receive_o2o\n", }) writeFakeCurl(t, filepath.Join(stubRoot, "curl")) @@ -387,8 +414,59 @@ func TestInstallEventScriptSkillsOnlySkipsBinary(t *testing.T) { if _, err := os.Stat(filepath.Join(installDir, "dws")); !os.IsNotExist(err) { t.Fatalf("DWS_SKILLS_ONLY=1 should not install binary, stat err=%v\noutput:\n%s", err, string(output)) } - if _, err := os.Stat(filepath.Join(fakeHome, ".agents", "skills", "dingtalk-misc", "SKILL.md")); err != nil { - t.Fatalf("skills-only should install misc skill (hosts event): %v\noutput:\n%s", err, string(output)) + for _, name := range []string{"dingtalk-event", "dingtalk-shared", "dingtalk-misc", "dws"} { + if _, err := os.Stat(filepath.Join(fakeHome, ".agents", "skills", name, "SKILL.md")); err != nil { + t.Fatalf("skills-only should install %s: %v\noutput:\n%s", name, err, string(output)) + } + } +} + +func TestInstallEventScriptPreflightFailureDoesNotChangeSkills(t *testing.T) { + t.Parallel() + + root := t.TempDir() + fakeHome := filepath.Join(root, "home") + releaseDir := filepath.Join(root, "release") + stubRoot := filepath.Join(root, "stubs") + + // Deliberately omit dingtalk-misc. The installer must resolve all members + // from this one bundle before replacing any installed or cached directory. + writeZip(t, filepath.Join(releaseDir, "dws-skills.zip"), map[string]string{ + "multi/dingtalk-event/SKILL.md": "new event\n", + "multi/dingtalk-shared/SKILL.md": "new shared\n", + "mono/SKILL.md": "new mono\n", + }) + writeFakeCurl(t, filepath.Join(stubRoot, "curl")) + + existing := filepath.Join(fakeHome, ".agents", "skills", "dingtalk-event", "SKILL.md") + mustWriteFile(t, existing, []byte("old event stays\n"), 0o644) + + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install-event.sh")) + if err != nil { + t.Fatalf("Abs(install-event.sh) error = %v", err) + } + cmd := exec.Command("sh", scriptPath) + cmd.Env = append(os.Environ(), + "HOME="+fakeHome, + "PATH="+stubRoot+":"+os.Getenv("PATH"), + "EVENT_VERSION=v1.0.51", + "DWS_SKILLS_ONLY=1", + "FAKE_RELEASE_DIR="+releaseDir, + "FAKE_ASSET_NAME=unused", + ) + output, err := cmd.CombinedOutput() + if err == nil { + t.Fatalf("install-event.sh should fail when bundle misses misc:\n%s", string(output)) + } + if !strings.Contains(string(output), "dingtalk-misc not found") { + t.Fatalf("preflight error should name missing misc:\n%s", string(output)) + } + data, readErr := os.ReadFile(existing) + if readErr != nil || string(data) != "old event stays\n" { + t.Fatalf("preflight failure changed installed event: data=%q err=%v", data, readErr) + } + if _, statErr := os.Stat(filepath.Join(fakeHome, ".dws", "skills")); !os.IsNotExist(statErr) { + t.Fatalf("preflight failure should not populate cache, stat err=%v", statErr) } } @@ -415,6 +493,9 @@ func TestInstallEventScriptNoSkillsOnlyInstallsBinary(t *testing.T) { "dws": "fake-event-binary\n", }) writeFakeCurl(t, filepath.Join(stubRoot, "curl")) + for _, name := range []string{"dingtalk-event", "dingtalk-shared", "dingtalk-misc"} { + mustWriteFile(t, filepath.Join(fakeHome, ".agents", "skills", name, "SKILL.md"), []byte("keep "+name+"\n"), 0o644) + } scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install-event.sh")) if err != nil { @@ -437,11 +518,11 @@ func TestInstallEventScriptNoSkillsOnlyInstallsBinary(t *testing.T) { if _, err := os.Stat(filepath.Join(installDir, "dws")); err != nil { t.Fatalf("DWS_NO_SKILLS=1 should install binary: %v\noutput:\n%s", err, string(output)) } - if _, err := os.Stat(filepath.Join(fakeHome, ".agents", "skills", "dingtalk-misc")); !os.IsNotExist(err) { - t.Fatalf("DWS_NO_SKILLS=1 should not install misc skill, stat err=%v\noutput:\n%s", err, string(output)) - } - if _, err := os.Stat(filepath.Join(fakeHome, ".agents", "skills", "dingtalk-event")); !os.IsNotExist(err) { - t.Fatalf("DWS_NO_SKILLS=1 should not install retired event skill, stat err=%v\noutput:\n%s", err, string(output)) + for _, name := range []string{"dingtalk-event", "dingtalk-shared", "dingtalk-misc"} { + data, err := os.ReadFile(filepath.Join(fakeHome, ".agents", "skills", name, "SKILL.md")) + if err != nil || string(data) != "keep "+name+"\n" { + t.Fatalf("DWS_NO_SKILLS=1 changed %s: data=%q err=%v\noutput:\n%s", name, data, err, string(output)) + } } } diff --git a/test/scripts/package_script_test.go b/test/scripts/package_script_test.go index 17418e69..75d9faf0 100644 --- a/test/scripts/package_script_test.go +++ b/test/scripts/package_script_test.go @@ -633,6 +633,33 @@ func TestPostGoreleaserSkillsZipLayout(t *testing.T) { if !foundDingtalk { t.Fatalf("multi/ does not contain any dingtalk-* skill: %v", multiEntries) } + + // Personal IM/OA events are owned by the standalone dingtalk-event Skill. + // Keep the release archive layout explicit so a future refactor cannot + // silently fold the Event entry point back into dingtalk-misc. + for _, rel := range []string{ + filepath.Join("multi", "dingtalk-event", "SKILL.md"), + filepath.Join("multi", "dingtalk-event", "references", "event-oa.md"), + } { + if _, err := os.Stat(filepath.Join(extractDir, rel)); err != nil { + t.Fatalf("zip missing %s: %v", rel, err) + } + } + for _, rel := range []string{ + filepath.Join("multi", "dingtalk-misc", "references", "event.md"), + filepath.Join("multi", "dingtalk-misc", "references", "event-im.md"), + filepath.Join("multi", "dingtalk-misc", "references", "event-im-keys.md"), + filepath.Join("multi", "dingtalk-misc", "references", "event-im-lifecycle.md"), + filepath.Join("multi", "dingtalk-misc", "references", "event-im-operations.md"), + filepath.Join("multi", "dingtalk-misc", "references", "event-im-output.md"), + filepath.Join("multi", "dingtalk-misc", "references", "event-oa.md"), + } { + if _, err := os.Stat(filepath.Join(extractDir, rel)); err == nil { + t.Fatalf("zip unexpectedly contains retired misc Event reference %s", rel) + } else if !os.IsNotExist(err) { + t.Fatalf("Stat(%s) error = %v", rel, err) + } + } } func readReleaseWorkflow(t *testing.T) string { diff --git a/test/skill_tests.md b/test/skill_tests.md index 23898d28..e9f4607b 100644 --- a/test/skill_tests.md +++ b/test/skill_tests.md @@ -47,7 +47,7 @@ Agent 安装 dws skill 后,仅依据 skill 提供的参考文档,将自然 | `devdoc` | `references/products/simple.md` | 2 | 7 | | `dev` | `skills/multi/dingtalk-misc/references/devapp.md` | 19 | 25 | | `ding` | `references/products/ding.md` | 2 | 5 | -| `event` | `references/products/event.md` | 8 | 11 | +| `event` | `skills/multi/dingtalk-event/SKILL.md` | 6 | 40 | | `report` | `references/products/report.md` | 6 | 26 | | `routing` | `references/intent-guide.md` | 1 | 3 | | `todo` | `references/products/todo.md` | 6 | 30 | @@ -994,122 +994,127 @@ Agent 安装 dws skill 后,仅依据 skill 提供的参考文档,将自然 --- -### event(26 条) +### event(40 条) -#### `dws event consume user_im_message_receive_at` +#### `dws event +listen-im` **event_event_consume_at_001** - Prompt: 监听有人 @ 我的消息 -- Expected: `dws event consume user_im_message_receive_at --flatten -f ndjson` +- Expected: `dws event +listen-im --kind at-me` +- Flags: `--kind` = `at-me` #### `dws event consume user_im_message_receive_o2o` **event_event_consume_o2o_001** -- Prompt: 监听我和 userId test-user-001 的单聊消息 +- Prompt: 用原始 EventKey user_im_message_receive_o2o 监听我和 userId test-user-001 的单聊消息 - Expected: `dws event consume user_im_message_receive_o2o --user test-user-001 --flatten -f ndjson` - Flags: `--user` = `test-user-001` **event_event_consume_o2o_open_id_001** -- Prompt: 监听我和 openDingtalkId abc 的单聊消息 +- Prompt: 用原始 EventKey user_im_message_receive_o2o 监听我和 openDingtalkId abc 的单聊消息 - Expected: `dws event consume user_im_message_receive_o2o --open-dingtalk-id abc --flatten -f ndjson` - Flags: `--open-dingtalk-id` = `abc` +#### `dws event +listen-im` + **event_event_consume_o2o_003** `[ASK_USER]` - Prompt: 监听我的个人单聊消息 -- Expected: `dws event consume user_im_message_receive_o2o --flatten -f ndjson` +- Expected: `dws event +listen-im` **event_event_consume_o2o_auto_reply_001** -- Prompt: 监听我和 userId test-user-001 的单聊消息,并对方发什么自动回复什么 -- Expected: `dws event consume user_im_message_receive_o2o --user test-user-001 --flatten -f ndjson` -- Flags: `--user` = `test-user-001` +- Prompt: 监听 userId test-user-001 发给我的消息,并对方发什么自动回复什么 +- Expected: `dws event +listen-im --kind sender --user test-user-001` +- Flags: `--kind` = `sender`, `--user` = `test-user-001` - Contract: 等待 stderr 的 `[event] ready`;从每行 NDJSON 顶层读取 `content` 和 `sender_open_dingtalk_id`,持续读取 stdout,不使用轮询或 output-dir watcher -#### `dws event consume user_im_message_receive_group` +#### `dws event +listen-im` **event_event_consume_group_001** - Prompt: 监听 openConversationId cid123 的群消息 -- Expected: `dws event consume user_im_message_receive_group --group cid123 --flatten -f ndjson` -- Flags: `--group` = `cid123` +- Expected: `dws event +listen-im --kind group --chat-id cid123` +- Flags: `--chat-id` = `cid123`, `--kind` = `group` - Contract: 群自动回复时直接读取事件顶层 `conversation_id` -#### `dws event consume user_im_message_receive_user` +#### `dws event +listen-im` **event_event_consume_user_001** - Prompt: 监听 userId test-user-001 发给我的消息,包括单聊和群聊 -- Expected: `dws event consume user_im_message_receive_user --user test-user-001 --flatten -f ndjson` -- Flags: `--user` = `test-user-001` +- Expected: `dws event +listen-im --kind sender --user test-user-001` +- Flags: `--kind` = `sender`, `--user` = `test-user-001` **event_event_consume_user_open_id_001** - Prompt: 监听 openDingtalkId abc 发给我的消息,包括单聊和群聊 -- Expected: `dws event consume user_im_message_receive_user --open-dingtalk-id abc --flatten -f ndjson` -- Flags: `--open-dingtalk-id` = `abc` +- Expected: `dws event +listen-im --kind sender --open-dingtalk-id abc` +- Flags: `--kind` = `sender`, `--open-dingtalk-id` = `abc` -#### `dws event consume user_im_message_receive_o2o_all` +#### `dws event +listen-im` **event_event_consume_o2o_all_001** - Prompt: 监听我收到的所有单聊消息 -- Expected: `dws event consume user_im_message_receive_o2o_all --flatten -f ndjson` +- Expected: `dws event +listen-im --kind all-direct` +- Flags: `--kind` = `all-direct` - Contract: 只有用户明确要求“所有”时使用,不得替代指定用户的 `receive_o2o` -#### `dws event consume user_im_message_receive_group_all` +#### `dws event +listen-im` **event_event_consume_group_all_001** - Prompt: 监听我所在的所有群消息 -- Expected: `dws event consume user_im_message_receive_group_all --flatten -f ndjson` +- Expected: `dws event +listen-im --kind all-group` +- Flags: `--kind` = `all-group` - Contract: 只有用户明确要求“所有”时使用,不得替代指定群的 `receive_group` -#### `dws event consume user_im_message_read_o2o` +#### `dws event +listen-im` **event_event_consume_read_o2o_001** - Prompt: 监听我发给 userId test-user-001 的单聊消息是否已读 -- Expected: `dws event consume user_im_message_read_o2o --user test-user-001 --flatten -f ndjson` -- Flags: `--user` = `test-user-001` +- Expected: `dws event +listen-im --kind sender --events read --user test-user-001` +- Flags: `--events` = `read`, `--kind` = `sender`, `--user` = `test-user-001` - Contract: 从每行 NDJSON 顶层读取 `message_id`、`reader`、`reader_open_dingtalk_id`、`read_time` -#### `dws event consume user_im_message_read_group` +#### `dws event +listen-im` **event_event_consume_read_group_001** - Prompt: 监听 openConversationId cid123 群里我发的消息是否已读 -- Expected: `dws event consume user_im_message_read_group --group cid123 --flatten -f ndjson` -- Flags: `--group` = `cid123` +- Expected: `dws event +listen-im --kind group --events read --chat-id cid123` +- Flags: `--chat-id` = `cid123`, `--events` = `read`, `--kind` = `group` - Contract: 从每行 NDJSON 顶层读取 `conversation_id`、`reader`、`reader_open_dingtalk_id`、`read_time` -#### `dws event consume user_im_message_recall_o2o` +#### `dws event +listen-im` **event_event_consume_recall_o2o_001** - Prompt: 监听我和 userId test-user-001 的单聊消息撤回事件 -- Expected: `dws event consume user_im_message_recall_o2o --user test-user-001 --flatten -f ndjson` -- Flags: `--user` = `test-user-001` +- Expected: `dws event +listen-im --kind sender --events recall --user test-user-001` +- Flags: `--events` = `recall`, `--kind` = `sender`, `--user` = `test-user-001` - Contract: 从每行 NDJSON 顶层读取 `message_id`、`recaller`、`recaller_open_dingtalk_id`、`recall_time` -#### `dws event consume user_im_message_recall_group` +#### `dws event +listen-im` **event_event_consume_recall_group_001** - Prompt: 监听 openConversationId cid123 的群消息撤回事件 -- Expected: `dws event consume user_im_message_recall_group --group cid123 --flatten -f ndjson` -- Flags: `--group` = `cid123` +- Expected: `dws event +listen-im --kind group --events recall --chat-id cid123` +- Flags: `--chat-id` = `cid123`, `--events` = `recall`, `--kind` = `group` - Contract: 从每行 NDJSON 顶层读取 `conversation_id`、`recaller`、`recaller_open_dingtalk_id`、`recall_time` -#### `dws event consume user_im_message_reaction_o2o` +#### `dws event +listen-im` **event_event_consume_reaction_o2o_001** - Prompt: 监听我和 userId test-user-001 的单聊消息贴表情事件 -- Expected: `dws event consume user_im_message_reaction_o2o --user test-user-001 --flatten -f ndjson` -- Flags: `--user` = `test-user-001` +- Expected: `dws event +listen-im --kind sender --events reaction --user test-user-001` +- Flags: `--events` = `reaction`, `--kind` = `sender`, `--user` = `test-user-001` - Contract: 从每行 NDJSON 顶层读取 `operator`、`operator_open_dingtalk_id`、`reaction_name`、`operation_type` **event_event_consume_reaction_o2o_open_id_001** - Prompt: 监听我和 openDingtalkId abc 的单聊消息贴表情事件 -- Expected: `dws event consume user_im_message_reaction_o2o --open-dingtalk-id abc --flatten -f ndjson` -- Flags: `--open-dingtalk-id` = `abc` +- Expected: `dws event +listen-im --kind sender --events reaction --open-dingtalk-id abc` +- Flags: `--events` = `reaction`, `--kind` = `sender`, `--open-dingtalk-id` = `abc` - Contract: 从每行 NDJSON 顶层读取 `operator`、`operator_open_dingtalk_id`、`reaction_name`、`operation_type` -#### `dws event consume user_im_message_reaction_group` +#### `dws event +listen-im` **event_event_consume_reaction_group_001** - Prompt: 监听 openConversationId cid123 的群消息表情回应事件 -- Expected: `dws event consume user_im_message_reaction_group --group cid123 --flatten -f ndjson` -- Flags: `--group` = `cid123` +- Expected: `dws event +listen-im --kind group --events reaction --chat-id cid123` +- Flags: `--chat-id` = `cid123`, `--events` = `reaction`, `--kind` = `group` - Contract: 从每行 NDJSON 顶层读取 `conversation_id`、`operator`、`reaction_name`、`reaction_text`、`operation_type` #### `dws event consume user_im_group_updated` @@ -1151,20 +1156,96 @@ Agent 安装 dws skill 后,仅依据 skill 提供的参考文档,将自然 - Expected: `dws chat search --query "项目群" --format json` - Flags: `--query` = `"项目群"` -#### `dws event consume` 多事件 +#### `dws event +listen-im` **event_event_consume_many_user_001** -- Prompt: 同时监听我和 userId test-user-001 的单聊消息、已读和撤回事件 -- Expected: `dws event consume user_im_message_receive_o2o user_im_message_read_o2o user_im_message_recall_o2o --user test-user-001 --flatten -f ndjson` -- Flags: `--user` = `test-user-001` +- Prompt: 同时监听 userId test-user-001 发给我的消息,以及我和他的单聊已读、撤回事件 +- Expected: `dws event +listen-im --kind sender --events message,read,recall --user test-user-001` +- Flags: `--events` = `message,read,recall`, `--kind` = `sender`, `--user` = `test-user-001` - Contract: 保存每条 `[event] subscription` 的 subscribe ID,等待 `[event] ready event_count=3` 后处理 stdout;停止一个 subscribe ID 时其余两个继续监听 +#### `dws event consume` 多事件 + **event_event_consume_many_group_001** - Prompt: 同时监听 openConversationId cid123 的群消息、群改名和群解散事件 - Expected: `dws event consume user_im_message_receive_group user_im_group_updated user_im_group_disbanded --group cid123 --flatten -f ndjson` - Flags: `--group` = `cid123` - Contract: 同一群使用一个多事件进程;不同群或不同过滤条件必须拆成多个 consume 进程 +#### `dws event consume` OA 审批事件 + +**event_event_consume_oa_task_created_001** +- Prompt: 有新的待我审批任务创建时实时通知我 +- Expected: `dws event consume user_oa_approval_task_created --flatten -f ndjson` + +**event_event_consume_oa_task_finished_001** +- Prompt: 我的审批任务完成时实时通知我 +- Expected: `dws event consume user_oa_approval_task_finished --flatten -f ndjson` + +**event_event_consume_oa_task_redirected_001** +- Prompt: 待我处理的审批任务被转交时实时通知我 +- Expected: `dws event consume user_oa_approval_task_redirected --flatten -f ndjson` + +**event_event_consume_oa_instance_started_001** +- Prompt: 有和我相关的审批实例发起时实时通知我 +- Expected: `dws event consume user_oa_approval_instance_started --flatten -f ndjson` + +**event_event_consume_oa_instance_terminated_001** +- Prompt: 和我相关的审批实例终止时实时通知我 +- Expected: `dws event consume user_oa_approval_instance_terminated --flatten -f ndjson` + +**event_event_consume_oa_instance_finished_001** +- Prompt: 我发起的审批实例完成时实时通知我 +- Expected: `dws event consume user_oa_approval_instance_finished --flatten -f ndjson` + +#### `dws event consume` 底层控制 + +**event_event_consume_raw_envelope_001** +- Prompt: 用原始 EventKey user_im_message_receive_at 监听,并输出原始 transport envelope +- Expected: `dws event consume user_im_message_receive_at --max-events 1 -f raw` +- Flags: `--max-events` = `1` + +**event_event_consume_filter_dsl_001** +- Prompt: 用 Filter DSL 监听内容等于 urgent 的 @我消息 +- Expected: `dws event consume user_im_message_receive_at --filter-json '{"field":"content","op":"eq","value":"urgent"}' --flatten -f ndjson` +- Flags: `--filter-json` = `{"field":"content","op":"eq","value":"urgent"}` + +**event_event_consume_subscribe_id_001** +- Prompt: 复用个人事件订阅 subId-existing 继续消费十分钟 +- Expected: `dws event consume --subscribe-id subId-existing --duration 10m --flatten -f ndjson` +- Flags: `--duration` = `10m`, `--subscribe-id` = `subId-existing` + +#### `dws event schema` + +**event_event_schema_im_001** +- Prompt: 查看 user_im_message_receive_at 的扁平输出字段 +- Expected: `dws event schema user_im_message_receive_at --flatten -f json` + +**event_event_schema_oa_001** +- Prompt: 查看 user_oa_approval_task_created 的扁平输出字段 +- Expected: `dws event schema user_oa_approval_task_created --flatten -f json` + +#### `dws chat message list` + +**event_negative_history_chat_001** +- Prompt: 查看 cid123 群从 2026-08-01 00:00:00 开始的历史消息 +- Expected: `dws chat message list --group cid123 --time "2026-08-01 00:00:00" --format json` +- Flags: `--group` = `cid123`, `--time` = `2026-08-01 00:00:00` + +#### `dws oa approval list-pending` + +**event_negative_oa_crud_001** +- Prompt: 查询 8 月第一周待我处理的审批单 +- Expected: `dws oa approval list-pending --start "2026-08-01T00:00:00+08:00" --end "2026-08-08T00:00:00+08:00" --format json` +- Flags: `--end` = `2026-08-08T00:00:00+08:00`, `--start` = `2026-08-01T00:00:00+08:00` + +#### `dws dev app event list` + +**event_negative_devapp_event_001** +- Prompt: 查看开放平台应用 app123 已配置的回调事件 +- Expected: `dws dev app event list --unified-app-id app123 --format json` +- Flags: `--unified-app-id` = `app123` + #### `dws event status` **event_event_status_001** diff --git a/test/skill_tests_results.md b/test/skill_tests_results.md index 7bc7003e..eb561066 100644 --- a/test/skill_tests_results.md +++ b/test/skill_tests_results.md @@ -2,8 +2,8 @@ ## Summary -- **Total Test Cases**: 244 -- **Passed**: 244 +- **Total Test Cases**: 276 +- **Passed**: 276 - **Failed**: 0 - **Pass Rate**: 100.0% @@ -19,7 +19,7 @@ | dev | 25 | 25 | 0 | 100.0% | | devdoc | 9 | 9 | 0 | 100.0% | | ding | 5 | 5 | 0 | 100.0% | -| event | 8 | 8 | 0 | 100.0% | +| event | 40 | 40 | 0 | 100.0% | | report | 26 | 26 | 0 | 100.0% | | routing | 3 | 3 | 0 | 100.0% | | todo | 30 | 30 | 0 | 100.0% | @@ -1100,7 +1100,7 @@ - Prompt: 搜索开放平台应用,应用名是 DemoApp - Expected: `dws dev app list --name DemoApp --format json` -- Skill Reference: references/products/skills/multi/dingtalk-dev/SKILL.md +- Skill Reference: references/products/skills/multi/dingtalk-misc/references/devapp.md - Command path: PASS (dev app list) - Flags: PASS (1 flags validated) @@ -1108,7 +1108,7 @@ - Prompt: 分页查询开放平台应用,第 2 页,每页 10 条,按修改时间倒序 - Expected: `dws dev app list --page 2 --page-size 10 --sort gmt_modified --order desc --format json` -- Skill Reference: references/products/skills/multi/dingtalk-dev/SKILL.md +- Skill Reference: references/products/skills/multi/dingtalk-misc/references/devapp.md - Command path: PASS (dev app list) - Flags: PASS (4 flags validated) @@ -1116,7 +1116,7 @@ - Prompt: 在开发者后台搜索企业内部应用 DemoApp - Expected: `dws dev app list --name DemoApp --format json` -- Skill Reference: references/products/skills/multi/dingtalk-dev/SKILL.md +- Skill Reference: references/products/skills/multi/dingtalk-misc/references/devapp.md - Command path: PASS (dev app list) - Flags: PASS (1 flags validated) @@ -1124,31 +1124,31 @@ - Prompt: 查看统一应用 UNIFIED_APP_ID 的应用详情 - Expected: `dws dev app get --unified-app-id UNIFIED_APP_ID --format json` -- Skill Reference: references/products/skills/multi/dingtalk-dev/SKILL.md +- Skill Reference: references/products/skills/multi/dingtalk-misc/references/devapp.md - Command path: PASS (dev app get) - Flags: PASS (1 flags validated) **dev_app_get_002** ✅ PASS -- Prompt: agentId 为 123456 的开放平台应用是哪个 -- Expected: `dws dev app get --agent-id 123456 --format json` -- Skill Reference: references/products/skills/multi/dingtalk-dev/SKILL.md +- Prompt: 按 appKey dingxxx 查开放平台应用详情 +- Expected: `dws dev app get --app-key dingxxx --format json` +- Skill Reference: references/products/skills/multi/dingtalk-misc/references/devapp.md - Command path: PASS (dev app get) - Flags: PASS (1 flags validated) **dev_app_get_003** ✅ PASS - Prompt: 查开放平台应用 DemoApp 的 appKey、clientId 和 agentId -- Expected: `dws dev app get --name DemoApp --format json` -- Skill Reference: references/products/skills/multi/dingtalk-dev/SKILL.md -- Command path: PASS (dev app get) +- Expected: `dws dev app list --name DemoApp --format json` +- Skill Reference: references/products/skills/multi/dingtalk-misc/references/devapp.md +- Command path: PASS (dev app list) - Flags: PASS (1 flags validated) **dev_app_create_001** ✅ PASS - Prompt: 创建一个企业内部应用,名称 DemoApp,描述是内部应用 - Expected: `dws dev app create --name DemoApp --desc "内部应用" --type internal --dry-run --format json` -- Skill Reference: references/products/skills/multi/dingtalk-dev/SKILL.md +- Skill Reference: references/products/skills/multi/dingtalk-misc/references/devapp.md - Command path: PASS (dev app create) - Flags: PASS (3 flags validated) @@ -1156,7 +1156,7 @@ - Prompt: 已确认创建企业内部应用 DemoApp - Expected: `dws dev app create --name DemoApp --type internal --yes --format json` -- Skill Reference: references/products/skills/multi/dingtalk-dev/SKILL.md +- Skill Reference: references/products/skills/multi/dingtalk-misc/references/devapp.md - Command path: PASS (dev app create) - Flags: PASS (2 flags validated) @@ -1164,7 +1164,7 @@ - Prompt: 把应用 UNIFIED_APP_ID 的描述改成 新描述 - Expected: `dws dev app update --unified-app-id UNIFIED_APP_ID --desc "新描述" --dry-run --format json` -- Skill Reference: references/products/skills/multi/dingtalk-dev/SKILL.md +- Skill Reference: references/products/skills/multi/dingtalk-misc/references/devapp.md - Command path: PASS (dev app update) - Flags: PASS (2 flags validated) @@ -1172,7 +1172,7 @@ - Prompt: 删除开放平台应用 UNIFIED_APP_ID,先给我预览 - Expected: `dws dev app delete --unified-app-id UNIFIED_APP_ID --dry-run --format json` -- Skill Reference: references/products/skills/multi/dingtalk-dev/SKILL.md +- Skill Reference: references/products/skills/multi/dingtalk-misc/references/devapp.md - Command path: PASS (dev app delete) - Flags: PASS (1 flags validated) @@ -1180,7 +1180,7 @@ - Prompt: 查询应用 UNIFIED_APP_ID 的开放平台凭证 - Expected: `dws dev app credentials get --unified-app-id UNIFIED_APP_ID --format json` -- Skill Reference: references/products/skills/multi/dingtalk-dev/SKILL.md +- Skill Reference: references/products/skills/multi/dingtalk-misc/references/devapp.md - Command path: PASS (dev app credentials get) - Flags: PASS (1 flags validated) @@ -1188,7 +1188,7 @@ - Prompt: 查询 AgentId AGENT_ID 对应应用的开放平台凭证 - Expected: `dws dev app credentials get --agent-id AGENT_ID --format json` -- Skill Reference: references/products/skills/multi/dingtalk-dev/SKILL.md +- Skill Reference: references/products/skills/multi/dingtalk-misc/references/devapp.md - Command path: PASS (dev app credentials get) - Flags: PASS (1 flags validated) @@ -1196,7 +1196,7 @@ - Prompt: 查看开放平台应用 UNIFIED_APP_ID 的成员 - Expected: `dws dev app member list --unified-app-id UNIFIED_APP_ID --format json` -- Skill Reference: references/products/skills/multi/dingtalk-dev/SKILL.md +- Skill Reference: references/products/skills/multi/dingtalk-misc/references/devapp.md - Command path: PASS (dev app member list) - Flags: PASS (1 flags validated) @@ -1204,7 +1204,7 @@ - Prompt: 给开放平台应用 UNIFIED_APP_ID 添加开发者成员 userId1,userId2,先预览 - Expected: `dws dev app member add --unified-app-id UNIFIED_APP_ID --user-ids userId1,userId2 --member-type DEVELOPER --dry-run --format json` -- Skill Reference: references/products/skills/multi/dingtalk-dev/SKILL.md +- Skill Reference: references/products/skills/multi/dingtalk-misc/references/devapp.md - Command path: PASS (dev app member add) - Flags: PASS (3 flags validated) @@ -1212,7 +1212,7 @@ - Prompt: 已确认,从开放平台应用 UNIFIED_APP_ID 移除开发者成员 userId1 - Expected: `dws dev app member remove --unified-app-id UNIFIED_APP_ID --user-ids userId1 --member-type DEVELOPER --yes --format json` -- Skill Reference: references/products/skills/multi/dingtalk-dev/SKILL.md +- Skill Reference: references/products/skills/multi/dingtalk-misc/references/devapp.md - Command path: PASS (dev app member remove) - Flags: PASS (4 flags validated) @@ -1220,7 +1220,7 @@ - Prompt: 给开放平台应用 UNIFIED_APP_ID 配置 IP 白名单 192.0.2.10,先预览 - Expected: `dws dev app security config --unified-app-id UNIFIED_APP_ID --ip-whitelist 192.0.2.10 --dry-run --format json` -- Skill Reference: references/products/skills/multi/dingtalk-dev/SKILL.md +- Skill Reference: references/products/skills/multi/dingtalk-misc/references/devapp.md - Command path: PASS (dev app security config) - Flags: PASS (2 flags validated) @@ -1228,7 +1228,7 @@ - Prompt: 查询应用 UNIFIED_APP_ID 已开通和未开通的全部 APP、SNS 权限 - Expected: `dws dev app permission list --unified-app-id UNIFIED_APP_ID --status all --format json` -- Skill Reference: references/products/skills/multi/dingtalk-dev/SKILL.md +- Skill Reference: references/products/skills/multi/dingtalk-misc/references/devapp.md - Command path: PASS (dev app permission list) - Flags: PASS (2 flags validated) @@ -1236,7 +1236,7 @@ - Prompt: 搜索应用 UNIFIED_APP_ID 里机器人发送消息相关的未开通权限 - Expected: `dws dev app permission list --unified-app-id UNIFIED_APP_ID --keyword "机器人发送消息" --status unauthed --format json` -- Skill Reference: references/products/skills/multi/dingtalk-dev/SKILL.md +- Skill Reference: references/products/skills/multi/dingtalk-misc/references/devapp.md - Command path: PASS (dev app permission list) - Flags: PASS (3 flags validated) @@ -1244,7 +1244,7 @@ - Prompt: 看一下权限 qyapi_robot_sendmsg 覆盖哪些 API - Expected: `dws dev app permission list --unified-app-id UNIFIED_APP_ID --scope qyapi_robot_sendmsg --format json` -- Skill Reference: references/products/skills/multi/dingtalk-dev/SKILL.md +- Skill Reference: references/products/skills/multi/dingtalk-misc/references/devapp.md - Command path: PASS (dev app permission list) - Flags: PASS (2 flags validated) @@ -1252,7 +1252,7 @@ - Prompt: 给开放平台应用 UNIFIED_APP_ID 搜索手机号权限,应用权限和个人权限都要展示 - Expected: `dws dev app permission list --unified-app-id UNIFIED_APP_ID --keyword "手机号" --status all --format json` -- Skill Reference: references/products/skills/multi/dingtalk-dev/SKILL.md +- Skill Reference: references/products/skills/multi/dingtalk-misc/references/devapp.md - Command path: PASS (dev app permission list) - Flags: PASS (3 flags validated) @@ -1260,7 +1260,7 @@ - Prompt: 给应用 UNIFIED_APP_ID 申请 qyapi_robot_sendmsg 权限,先预览 - Expected: `dws dev app permission add --unified-app-id UNIFIED_APP_ID --permissions qyapi_robot_sendmsg --dry-run --format json` -- Skill Reference: references/products/skills/multi/dingtalk-dev/SKILL.md +- Skill Reference: references/products/skills/multi/dingtalk-misc/references/devapp.md - Command path: PASS (dev app permission add) - Flags: PASS (2 flags validated) @@ -1268,7 +1268,7 @@ - Prompt: 已确认,给应用 UNIFIED_APP_ID 申请 Contact.User.mobile 权限 - Expected: `dws dev app permission add --unified-app-id UNIFIED_APP_ID --permissions Contact.User.mobile --yes --format json` -- Skill Reference: references/products/skills/multi/dingtalk-dev/SKILL.md +- Skill Reference: references/products/skills/multi/dingtalk-misc/references/devapp.md - Command path: PASS (dev app permission add) - Flags: PASS (2 flags validated) @@ -1276,7 +1276,7 @@ - Prompt: 从应用 UNIFIED_APP_ID 移除 qyapi_robot_sendmsg 权限,先预览 - Expected: `dws dev app permission remove --unified-app-id UNIFIED_APP_ID --permission qyapi_robot_sendmsg --dry-run --format json` -- Skill Reference: references/products/skills/multi/dingtalk-dev/SKILL.md +- Skill Reference: references/products/skills/multi/dingtalk-misc/references/devapp.md - Command path: PASS (dev app permission remove) - Flags: PASS (2 flags validated) @@ -1284,7 +1284,7 @@ - Prompt: 查询应用 UNIFIED_APP_ID 的事件订阅配置 - Expected: `dws dev app event list --unified-app-id UNIFIED_APP_ID --format json` -- Skill Reference: references/products/skills/multi/dingtalk-dev/SKILL.md +- Skill Reference: references/products/skills/multi/dingtalk-misc/references/devapp.md - Command path: PASS (dev app event list) - Flags: PASS (1 flags validated) @@ -1292,7 +1292,7 @@ - Prompt: 已确认,发布应用 UNIFIED_APP_ID 的版本 VERSION_ID,审批人是 USERID - Expected: `dws dev app version publish --unified-app-id UNIFIED_APP_ID --version-id VERSION_ID --approver USERID --yes --format json` -- Skill Reference: references/products/skills/multi/dingtalk-dev/SKILL.md +- Skill Reference: references/products/skills/multi/dingtalk-misc/references/devapp.md - Command path: PASS (dev app version publish) - Flags: PASS (3 flags validated) @@ -1417,52 +1417,324 @@ **event_event_consume_at_001** ✅ PASS - Prompt: 监听有人 @ 我的消息 -- Expected: `dws event consume user_im_message_receive_at -f ndjson` -- Skill Reference: references/products/event.md -- Command path: PASS (event consume user_im_message_receive_at -f ndjson) -- Flags: N/A (no flags specified) +- Expected: `dws event +listen-im --kind at-me` +- Skill Reference: skills/multi/dingtalk-event/SKILL.md +- Command path: PASS (event +listen-im) +- Flags: PASS (1 flags validated) **event_event_consume_o2o_001** ✅ PASS -- Prompt: 监听我和 userId test-user-001 的单聊消息 -- Expected: `dws event consume user_im_message_receive_o2o --user test-user-001 -f ndjson` -- Skill Reference: references/products/event.md +- Prompt: 用原始 EventKey user_im_message_receive_o2o 监听我和 userId test-user-001 的单聊消息 +- Expected: `dws event consume user_im_message_receive_o2o --user test-user-001 --flatten -f ndjson` +- Skill Reference: skills/multi/dingtalk-event/SKILL.md +- Command path: PASS (event consume user_im_message_receive_o2o) +- Flags: PASS (1 flags validated) + +**event_event_consume_o2o_open_id_001** ✅ PASS + +- Prompt: 用原始 EventKey user_im_message_receive_o2o 监听我和 openDingtalkId abc 的单聊消息 +- Expected: `dws event consume user_im_message_receive_o2o --open-dingtalk-id abc --flatten -f ndjson` +- Skill Reference: skills/multi/dingtalk-event/SKILL.md - Command path: PASS (event consume user_im_message_receive_o2o) - Flags: PASS (1 flags validated) **event_event_consume_o2o_003** ✅ PASS - Prompt: 监听我的个人单聊消息 -- Expected: `dws event consume user_im_message_receive_o2o -f ndjson` -- Skill Reference: references/products/event.md -- Command path: PASS (event consume user_im_message_receive_o2o -f ndjson) +- Expected: `dws event +listen-im` +- Skill Reference: skills/multi/dingtalk-event/SKILL.md +- Command path: PASS (event +listen-im) - Flags: N/A (no flags specified) - Note: [ASK_USER] case - only command path validated +**event_event_consume_o2o_auto_reply_001** ✅ PASS + +- Prompt: 监听 userId test-user-001 发给我的消息,并对方发什么自动回复什么 +- Expected: `dws event +listen-im --kind sender --user test-user-001` +- Skill Reference: skills/multi/dingtalk-event/SKILL.md +- Command path: PASS (event +listen-im) +- Flags: PASS (2 flags validated) + **event_event_consume_group_001** ✅ PASS - Prompt: 监听 openConversationId cid123 的群消息 -- Expected: `dws event consume user_im_message_receive_group --group cid123 -f ndjson` -- Skill Reference: references/products/event.md -- Command path: PASS (event consume user_im_message_receive_group) +- Expected: `dws event +listen-im --kind group --chat-id cid123` +- Skill Reference: skills/multi/dingtalk-event/SKILL.md +- Command path: PASS (event +listen-im) +- Flags: PASS (2 flags validated) + +**event_event_consume_user_001** ✅ PASS + +- Prompt: 监听 userId test-user-001 发给我的消息,包括单聊和群聊 +- Expected: `dws event +listen-im --kind sender --user test-user-001` +- Skill Reference: skills/multi/dingtalk-event/SKILL.md +- Command path: PASS (event +listen-im) +- Flags: PASS (2 flags validated) + +**event_event_consume_user_open_id_001** ✅ PASS + +- Prompt: 监听 openDingtalkId abc 发给我的消息,包括单聊和群聊 +- Expected: `dws event +listen-im --kind sender --open-dingtalk-id abc` +- Skill Reference: skills/multi/dingtalk-event/SKILL.md +- Command path: PASS (event +listen-im) +- Flags: PASS (2 flags validated) + +**event_event_consume_o2o_all_001** ✅ PASS + +- Prompt: 监听我收到的所有单聊消息 +- Expected: `dws event +listen-im --kind all-direct` +- Skill Reference: skills/multi/dingtalk-event/SKILL.md +- Command path: PASS (event +listen-im) +- Flags: PASS (1 flags validated) + +**event_event_consume_group_all_001** ✅ PASS + +- Prompt: 监听我所在的所有群消息 +- Expected: `dws event +listen-im --kind all-group` +- Skill Reference: skills/multi/dingtalk-event/SKILL.md +- Command path: PASS (event +listen-im) +- Flags: PASS (1 flags validated) + +**event_event_consume_read_o2o_001** ✅ PASS + +- Prompt: 监听我发给 userId test-user-001 的单聊消息是否已读 +- Expected: `dws event +listen-im --kind sender --events read --user test-user-001` +- Skill Reference: skills/multi/dingtalk-event/SKILL.md +- Command path: PASS (event +listen-im) +- Flags: PASS (3 flags validated) + +**event_event_consume_read_group_001** ✅ PASS + +- Prompt: 监听 openConversationId cid123 群里我发的消息是否已读 +- Expected: `dws event +listen-im --kind group --events read --chat-id cid123` +- Skill Reference: skills/multi/dingtalk-event/SKILL.md +- Command path: PASS (event +listen-im) +- Flags: PASS (3 flags validated) + +**event_event_consume_recall_o2o_001** ✅ PASS + +- Prompt: 监听我和 userId test-user-001 的单聊消息撤回事件 +- Expected: `dws event +listen-im --kind sender --events recall --user test-user-001` +- Skill Reference: skills/multi/dingtalk-event/SKILL.md +- Command path: PASS (event +listen-im) +- Flags: PASS (3 flags validated) + +**event_event_consume_recall_group_001** ✅ PASS + +- Prompt: 监听 openConversationId cid123 的群消息撤回事件 +- Expected: `dws event +listen-im --kind group --events recall --chat-id cid123` +- Skill Reference: skills/multi/dingtalk-event/SKILL.md +- Command path: PASS (event +listen-im) +- Flags: PASS (3 flags validated) + +**event_event_consume_reaction_o2o_001** ✅ PASS + +- Prompt: 监听我和 userId test-user-001 的单聊消息贴表情事件 +- Expected: `dws event +listen-im --kind sender --events reaction --user test-user-001` +- Skill Reference: skills/multi/dingtalk-event/SKILL.md +- Command path: PASS (event +listen-im) +- Flags: PASS (3 flags validated) + +**event_event_consume_reaction_o2o_open_id_001** ✅ PASS + +- Prompt: 监听我和 openDingtalkId abc 的单聊消息贴表情事件 +- Expected: `dws event +listen-im --kind sender --events reaction --open-dingtalk-id abc` +- Skill Reference: skills/multi/dingtalk-event/SKILL.md +- Command path: PASS (event +listen-im) +- Flags: PASS (3 flags validated) + +**event_event_consume_reaction_group_001** ✅ PASS + +- Prompt: 监听 openConversationId cid123 的群消息表情回应事件 +- Expected: `dws event +listen-im --kind group --events reaction --chat-id cid123` +- Skill Reference: skills/multi/dingtalk-event/SKILL.md +- Command path: PASS (event +listen-im) +- Flags: PASS (3 flags validated) + +**event_event_consume_group_updated_001** ✅ PASS + +- Prompt: 监听 openConversationId cid123 群的群标题变更 +- Expected: `dws event consume user_im_group_updated --group cid123 --flatten -f ndjson` +- Skill Reference: skills/multi/dingtalk-event/SKILL.md +- Command path: PASS (event consume user_im_group_updated) +- Flags: PASS (1 flags validated) + +**event_event_consume_group_member_added_001** ✅ PASS + +- Prompt: 监听有人加入 openConversationId cid123 群 +- Expected: `dws event consume user_im_group_member_added --group cid123 --flatten -f ndjson` +- Skill Reference: skills/multi/dingtalk-event/SKILL.md +- Command path: PASS (event consume user_im_group_member_added) +- Flags: PASS (1 flags validated) + +**event_event_consume_group_member_exited_001** ✅ PASS + +- Prompt: 监听有人退出 openConversationId cid123 群 +- Expected: `dws event consume user_im_group_member_exited --group cid123 --flatten -f ndjson` +- Skill Reference: skills/multi/dingtalk-event/SKILL.md +- Command path: PASS (event consume user_im_group_member_exited) +- Flags: PASS (1 flags validated) + +**event_event_consume_group_disbanded_001** ✅ PASS + +- Prompt: 监听 openConversationId cid-test-disbanded 测试群被解散 +- Expected: `dws event consume user_im_group_disbanded --group cid-test-disbanded --flatten -f ndjson` +- Skill Reference: skills/multi/dingtalk-event/SKILL.md +- Command path: PASS (event consume user_im_group_disbanded) - Flags: PASS (1 flags validated) **event_lookup_group_001** ✅ PASS - Prompt: 监听项目群的消息 - Expected: `dws chat search --query "项目群" --format json` -- Skill Reference: references/products/event.md +- Skill Reference: skills/multi/dingtalk-event/SKILL.md - Command path: PASS (chat search) - Flags: PASS (1 flags validated) -**event_lookup_sender_001** ✅ PASS +**event_event_consume_many_user_001** ✅ PASS -- Prompt: 监听张三发给我的消息 -- Expected: `dws aisearch person --keyword "张三" --dimension name --format json` -- Skill Reference: references/products/event.md -- Command path: PASS (aisearch person) +- Prompt: 同时监听 userId test-user-001 发给我的消息,以及我和他的单聊已读、撤回事件 +- Expected: `dws event +listen-im --kind sender --events message,read,recall --user test-user-001` +- Skill Reference: skills/multi/dingtalk-event/SKILL.md +- Command path: PASS (event +listen-im) +- Flags: PASS (3 flags validated) + +**event_event_consume_many_group_001** ✅ PASS + +- Prompt: 同时监听 openConversationId cid123 的群消息、群改名和群解散事件 +- Expected: `dws event consume user_im_message_receive_group user_im_group_updated user_im_group_disbanded --group cid123 --flatten -f ndjson` +- Skill Reference: skills/multi/dingtalk-event/SKILL.md +- Command path: PASS (event consume user_im_message_receive_group user_im_group_updated user_im_group_disbanded) +- Flags: PASS (1 flags validated) + +**event_event_consume_oa_task_created_001** ✅ PASS + +- Prompt: 有新的待我审批任务创建时实时通知我 +- Expected: `dws event consume user_oa_approval_task_created --flatten -f ndjson` +- Skill Reference: skills/multi/dingtalk-event/SKILL.md +- Command path: PASS (event consume user_oa_approval_task_created) +- Flags: N/A (no flags specified) + +**event_event_consume_oa_task_finished_001** ✅ PASS + +- Prompt: 我的审批任务完成时实时通知我 +- Expected: `dws event consume user_oa_approval_task_finished --flatten -f ndjson` +- Skill Reference: skills/multi/dingtalk-event/SKILL.md +- Command path: PASS (event consume user_oa_approval_task_finished) +- Flags: N/A (no flags specified) + +**event_event_consume_oa_task_redirected_001** ✅ PASS + +- Prompt: 待我处理的审批任务被转交时实时通知我 +- Expected: `dws event consume user_oa_approval_task_redirected --flatten -f ndjson` +- Skill Reference: skills/multi/dingtalk-event/SKILL.md +- Command path: PASS (event consume user_oa_approval_task_redirected) +- Flags: N/A (no flags specified) + +**event_event_consume_oa_instance_started_001** ✅ PASS + +- Prompt: 有和我相关的审批实例发起时实时通知我 +- Expected: `dws event consume user_oa_approval_instance_started --flatten -f ndjson` +- Skill Reference: skills/multi/dingtalk-event/SKILL.md +- Command path: PASS (event consume user_oa_approval_instance_started) +- Flags: N/A (no flags specified) + +**event_event_consume_oa_instance_terminated_001** ✅ PASS + +- Prompt: 和我相关的审批实例终止时实时通知我 +- Expected: `dws event consume user_oa_approval_instance_terminated --flatten -f ndjson` +- Skill Reference: skills/multi/dingtalk-event/SKILL.md +- Command path: PASS (event consume user_oa_approval_instance_terminated) +- Flags: N/A (no flags specified) + +**event_event_consume_oa_instance_finished_001** ✅ PASS + +- Prompt: 我发起的审批实例完成时实时通知我 +- Expected: `dws event consume user_oa_approval_instance_finished --flatten -f ndjson` +- Skill Reference: skills/multi/dingtalk-event/SKILL.md +- Command path: PASS (event consume user_oa_approval_instance_finished) +- Flags: N/A (no flags specified) + +**event_event_consume_raw_envelope_001** ✅ PASS + +- Prompt: 用原始 EventKey user_im_message_receive_at 监听,并输出原始 transport envelope +- Expected: `dws event consume user_im_message_receive_at --max-events 1 -f raw` +- Skill Reference: skills/multi/dingtalk-event/SKILL.md +- Command path: PASS (event consume user_im_message_receive_at) +- Flags: PASS (1 flags validated) + +**event_event_consume_filter_dsl_001** ✅ PASS + +- Prompt: 用 Filter DSL 监听内容等于 urgent 的 @我消息 +- Expected: `dws event consume user_im_message_receive_at --filter-json '{"field":"content","op":"eq","value":"urgent"}' --flatten -f ndjson` +- Skill Reference: skills/multi/dingtalk-event/SKILL.md +- Command path: PASS (event consume user_im_message_receive_at) +- Flags: PASS (1 flags validated) + +**event_event_consume_subscribe_id_001** ✅ PASS + +- Prompt: 复用个人事件订阅 subId-existing 继续消费十分钟 +- Expected: `dws event consume --subscribe-id subId-existing --duration 10m --flatten -f ndjson` +- Skill Reference: skills/multi/dingtalk-event/SKILL.md +- Command path: PASS (event consume) - Flags: PASS (2 flags validated) +**event_event_schema_im_001** ✅ PASS + +- Prompt: 查看 user_im_message_receive_at 的扁平输出字段 +- Expected: `dws event schema user_im_message_receive_at --flatten -f json` +- Skill Reference: skills/multi/dingtalk-event/SKILL.md +- Command path: PASS (event schema user_im_message_receive_at) +- Flags: N/A (no flags specified) + +**event_event_schema_oa_001** ✅ PASS + +- Prompt: 查看 user_oa_approval_task_created 的扁平输出字段 +- Expected: `dws event schema user_oa_approval_task_created --flatten -f json` +- Skill Reference: skills/multi/dingtalk-event/SKILL.md +- Command path: PASS (event schema user_oa_approval_task_created) +- Flags: N/A (no flags specified) + +**event_negative_history_chat_001** ✅ PASS + +- Prompt: 查看 cid123 群从 2026-08-01 00:00:00 开始的历史消息 +- Expected: `dws chat message list --group cid123 --time "2026-08-01 00:00:00" --format json` +- Skill Reference: skills/multi/dingtalk-event/SKILL.md +- Command path: PASS (chat message list) +- Flags: PASS (2 flags validated) + +**event_negative_oa_crud_001** ✅ PASS + +- Prompt: 查询 8 月第一周待我处理的审批单 +- Expected: `dws oa approval list-pending --start "2026-08-01T00:00:00+08:00" --end "2026-08-08T00:00:00+08:00" --format json` +- Skill Reference: skills/multi/dingtalk-event/SKILL.md +- Command path: PASS (oa approval list-pending) +- Flags: PASS (2 flags validated) + +**event_negative_devapp_event_001** ✅ PASS + +- Prompt: 查看开放平台应用 app123 已配置的回调事件 +- Expected: `dws dev app event list --unified-app-id app123 --format json` +- Skill Reference: skills/multi/dingtalk-event/SKILL.md +- Command path: PASS (dev app event list) +- Flags: PASS (1 flags validated) + +**event_event_status_001** ✅ PASS + +- Prompt: 查看指定单聊个人事件订阅状态 +- Expected: `dws event status --event user_im_message_receive_o2o` +- Skill Reference: skills/multi/dingtalk-event/SKILL.md +- Command path: PASS (event status) +- Flags: PASS (1 flags validated) + +**event_event_stop_001** ✅ PASS + +- Prompt: 停止个人事件订阅 subId-abc123 +- Expected: `dws event stop subId-abc123` +- Skill Reference: skills/multi/dingtalk-event/SKILL.md +- Command path: PASS (event stop subId-abc123) +- Flags: N/A (no flags specified) + ### report **report_report_detail_001** ✅ PASS diff --git a/test/unit/skill_docs_policy_test.go b/test/unit/skill_docs_policy_test.go index 93e0ee71..9ccef62a 100644 --- a/test/unit/skill_docs_policy_test.go +++ b/test/unit/skill_docs_policy_test.go @@ -83,8 +83,8 @@ func TestEventSkillUsesFlatOutputContract(t *testing.T) { } root := filepath.Clean(filepath.Join(filepath.Dir(filename), "..", "..")) paths := []string{ - filepath.Join(root, "skills", "multi", "dingtalk-misc", "references", "event.md"), - filepath.Join(root, "skills", "multi", "dingtalk-misc", "references", "event-im.md"), + filepath.Join(root, "skills", "multi", "dingtalk-event", "SKILL.md"), + filepath.Join(root, "skills", "multi", "dingtalk-event", "references", "event-im.md"), filepath.Join(root, "skills", "mono", "references", "products", "event.md"), } for _, path := range paths { @@ -137,8 +137,8 @@ func TestCrossPlatformCoverageEventSkillPinsSubscriptionRetryOrchestrationContra } root := filepath.Clean(filepath.Join(filepath.Dir(filename), "..", "..")) paths := []string{ - filepath.Join(root, "skills", "multi", "dingtalk-misc", "references", "event.md"), - filepath.Join(root, "skills", "multi", "dingtalk-misc", "references", "event-im.md"), + filepath.Join(root, "skills", "multi", "dingtalk-event", "SKILL.md"), + filepath.Join(root, "skills", "multi", "dingtalk-event", "references", "event-im.md"), filepath.Join(root, "skills", "mono", "references", "products", "event.md"), filepath.Join(root, "docs", "event-subprocess-contract.md"), } @@ -203,8 +203,8 @@ func TestCrossPlatformCoverageEventSkillDocumentsSubscriptionGuardOperations(t * } root := filepath.Clean(filepath.Join(filepath.Dir(filename), "..", "..")) paths := []string{ - filepath.Join(root, "skills", "multi", "dingtalk-misc", "references", "event.md"), - filepath.Join(root, "skills", "multi", "dingtalk-misc", "references", "event-im.md"), + filepath.Join(root, "skills", "multi", "dingtalk-event", "SKILL.md"), + filepath.Join(root, "skills", "multi", "dingtalk-event", "references", "event-im.md"), filepath.Join(root, "skills", "mono", "references", "products", "event.md"), filepath.Join(root, "docs", "event-subprocess-contract.md"), } @@ -263,7 +263,7 @@ func TestEventSkillFrontmatterAdvertisesGroupMemberLifecycle(t *testing.T) { root := filepath.Clean(filepath.Join(filepath.Dir(filename), "..", "..")) paths := []string{ filepath.Join(root, "skills", "mono", "SKILL.md"), - filepath.Join(root, "skills", "multi", "dingtalk-misc", "SKILL.md"), + filepath.Join(root, "skills", "multi", "dingtalk-event", "SKILL.md"), } for _, path := range paths { content, err := os.ReadFile(path) @@ -275,7 +275,13 @@ func TestEventSkillFrontmatterAdvertisesGroupMemberLifecycle(t *testing.T) { t.Fatalf("%s missing YAML frontmatter", path) } frontmatter := parts[1] - for _, required := range []string{"个人 IM 事件", "群成员加入", "群成员退出"} { + for _, required := range []string{ + "个人 IM", + "群成员加入", + "群成员退出", + "审批任务创建/完成/转交", + "审批实例发起/终止/完成", + } { if !strings.Contains(frontmatter, required) { t.Errorf("%s frontmatter missing event discovery trigger %q", path, required) } @@ -283,6 +289,146 @@ func TestEventSkillFrontmatterAdvertisesGroupMemberLifecycle(t *testing.T) { } } +func TestStandaloneEventSkillOwnsAllPersonalEventContracts(t *testing.T) { + _, filename, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller(0) failed") + } + root := filepath.Clean(filepath.Join(filepath.Dir(filename), "..", "..")) + eventRoot := filepath.Join(root, "skills", "multi", "dingtalk-event") + skillPath := filepath.Join(eventRoot, "SKILL.md") + skillContent, err := os.ReadFile(skillPath) + if err != nil { + t.Fatalf("read %s: %v", skillPath, err) + } + for _, required := range []string{ + "../dingtalk-shared/SKILL.md", + "", + "", + "16 个 EventKey", + "22 个公开个人 EventKey", + } { + if !strings.Contains(string(skillContent), required) { + t.Errorf("%s missing standalone event contract %q", skillPath, required) + } + } + + wantReferences := []string{ + "event-im-keys.md", + "event-im-lifecycle.md", + "event-im-operations.md", + "event-im-output.md", + "event-im.md", + "event-oa.md", + } + var combined strings.Builder + combined.Write(skillContent) + for _, name := range wantReferences { + path := filepath.Join(eventRoot, "references", name) + content, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + combined.Write(content) + } + refs, err := filepath.Glob(filepath.Join(eventRoot, "references", "event-*.md")) + if err != nil { + t.Fatalf("glob event references: %v", err) + } + if len(refs) != len(wantReferences) { + t.Errorf("event reference count = %d, want %d: %v", len(refs), len(wantReferences), refs) + } + + allEventKeys := []string{ + "user_im_message_receive_at", + "user_im_message_receive_o2o", + "user_im_message_receive_group", + "user_im_message_receive_user", + "user_im_message_receive_o2o_all", + "user_im_message_receive_group_all", + "user_im_message_read_o2o", + "user_im_message_read_group", + "user_im_message_recall_o2o", + "user_im_message_recall_group", + "user_im_message_reaction_o2o", + "user_im_message_reaction_group", + "user_im_group_updated", + "user_im_group_member_added", + "user_im_group_member_exited", + "user_im_group_disbanded", + "user_oa_approval_task_created", + "user_oa_approval_task_finished", + "user_oa_approval_task_redirected", + "user_oa_approval_instance_started", + "user_oa_approval_instance_terminated", + "user_oa_approval_instance_finished", + } + for _, eventKey := range allEventKeys { + if !strings.Contains(combined.String(), eventKey) { + t.Errorf("standalone event skill missing EventKey %q", eventKey) + } + } +} + +func TestMiscSkillDoesNotOwnPersonalEvent(t *testing.T) { + _, filename, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller(0) failed") + } + root := filepath.Clean(filepath.Join(filepath.Dir(filename), "..", "..")) + miscRoot := filepath.Join(root, "skills", "multi", "dingtalk-misc") + miscPath := filepath.Join(miscRoot, "SKILL.md") + content, err := os.ReadFile(miscPath) + if err != nil { + t.Fatalf("read %s: %v", miscPath, err) + } + parts := strings.SplitN(string(content), "---", 3) + if len(parts) != 3 { + t.Fatalf("%s missing YAML frontmatter", miscPath) + } + for _, forbidden := range []string{ + "个人 IM 事件", + "实时监听消息", + "被@消息", + "群成员加入", + "事件驱动 Agent", + "dws event", + } { + if strings.Contains(parts[1], forbidden) { + t.Errorf("%s frontmatter still claims personal Event trigger %q", miscPath, forbidden) + } + } + for _, required := range []string{ + "dingtalk-event", + "oa.md", + "dev/event.md", + } { + if !strings.Contains(string(content), required) { + t.Errorf("%s missing Event ownership handoff %q", miscPath, required) + } + } + + retiredNames := []string{ + "event.md", + "event-im.md", + "event-im-keys.md", + "event-im-lifecycle.md", + "event-im-operations.md", + "event-im-output.md", + "event-oa.md", + } + for _, name := range retiredNames { + path := filepath.Join(miscRoot, "references", name) + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Errorf("misc retains retired personal Event reference %s", path) + } + } + devEventPath := filepath.Join(miscRoot, "references", "dev", "event.md") + if _, err := os.Stat(devEventPath); err != nil { + t.Errorf("DevApp event reference must remain at %s: %v", devEventPath, err) + } +} + func hasAny(s string, needles []string) bool { for _, needle := range needles { if strings.Contains(s, needle) {