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..7942e264 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",
},
},
})
@@ -333,7 +333,7 @@ SIGTERM、关 stdin,或先用 dws event stop --dry-run 预览
f.BoolVar(&force, "force", false,
"仅 --foreground 模式生效:跳过单实例锁 (慎用:会让云事件被随机切分)")
f.BoolVar(&dryRun, "dry-run", false,
- "仅打印解析后的配置,不连接 bus / 云端")
+ "仅打印解析后的配置;不创建订阅、不连接 bus;复用 --subscribe-id 时会只读查询控制面")
f.BoolVar(&foreground, "foreground", false,
"当前进程直接跑 bus 服务、不 fork、不打印事件(给 systemd/k8s 托管用);读事件不要用它")
f.StringVar(&personalOpts.SubscribeID, "subscribe-id", "",
@@ -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_attempts_test.go b/internal/app/event_personal_attempts_test.go
index 0b5f8da5..1acdea27 100644
--- a/internal/app/event_personal_attempts_test.go
+++ b/internal/app/event_personal_attempts_test.go
@@ -152,8 +152,8 @@ func TestCrossPlatformCoveragePersonalSubscriptionProtectionCoversAllPublicEvent
}
}
- if publicCount != 16 {
- t.Fatalf("public personal events = %d, want 16", publicCount)
+ if publicCount != 22 {
+ t.Fatalf("public personal events = %d, want 22 (16 IM + 6 OA)", publicCount)
}
for _, ruleType := range []string{"at", "all", "singleChat", "sender", "group"} {
if !ruleTypes[ruleType] {
diff --git a/internal/app/event_personal_command.go b/internal/app/event_personal_command.go
index a86af7be..262c3e5a 100644
--- a/internal/app/event_personal_command.go
+++ b/internal/app/event_personal_command.go
@@ -26,6 +26,7 @@ import (
"path/filepath"
"sort"
"strings"
+ "sync"
"text/tabwriter"
"time"
@@ -201,12 +202,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",
+ },
},
},
})
@@ -262,6 +266,9 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
if err := ensurePublicPersonalEvent(opts.EventKey); err != nil {
return personalSubscriptionValidationError(err)
}
+ if err := validatePersonalOAOptions(opts.EventKey, opts); err != nil {
+ return fmt.Errorf("event consume --as user: %w", personalSubscriptionValidationError(err))
+ }
rawFormat := ""
if f := c.Flags().Lookup("format"); f != nil && f.Changed {
rawFormat = opts.Common.FormatRaw
@@ -290,11 +297,18 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
if err != nil {
return fmt.Errorf("event consume --as user: %w", personalSubscriptionValidationError(err))
}
+ client := newPersonalEventControlClient(configDir, personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity)
if opts.Common.DryRun {
if strings.TrimSpace(opts.SubscribeID) == "" {
if err := validatePersonalSubscriptionOptions(opts); err != nil {
return fmt.Errorf("event consume --as user: %w", personalSubscriptionValidationError(err))
}
+ } else {
+ _, eventKey, _, err := personalEnsureSubscription(ctx, client, identity, opts)
+ if err != nil {
+ return fmt.Errorf("event consume --as user: %w", err)
+ }
+ opts.EventKey = eventKey
}
cfg := consume.Config{
WorkDir: workDir,
@@ -369,7 +383,6 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
}
}
- client := newPersonalEventControlClient(configDir, personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity)
var attempt *personalSubscriptionAttemptReservation
if strings.TrimSpace(opts.SubscribeID) == "" {
attempt, err = reservePersonalSubscriptionAttempts(
@@ -408,9 +421,17 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
)
return fmt.Errorf("event consume --as user: %w", err)
}
- cleanup := func(cleanupCtx context.Context) {
- _ = personalDeleteSubscription(client, cleanupCtx, sub.SubscribeID)
- _ = personalRemoveRunStates(workDir, []string{sub.SubscribeID})
+ selfCreated := strings.TrimSpace(opts.SubscribeID) == ""
+ ownsSubscription := selfCreated || opts.Ephemeral
+ var cleanupOnce sync.Once
+ cleanupOwnedSubscription := func(cleanupCtx context.Context) {
+ if !ownsSubscription {
+ return
+ }
+ cleanupOnce.Do(func() {
+ _ = personalDeleteSubscription(client, cleanupCtx, sub.SubscribeID)
+ _ = personalRemoveRunStates(workDir, []string{sub.SubscribeID})
+ })
}
if err := personalUpsertRunState(workDir, personal.RunState{
SubscribeID: sub.SubscribeID,
@@ -421,19 +442,19 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
IdentityHash: identityHash,
}); err != nil {
wrapped := fmt.Errorf("save run state: %w", err)
+ cleanupCtx := context.Background()
+ if personalSubscriptionCanceled(ctx, wrapped) {
+ cleanupCtx = ctx
+ }
if attempt != nil {
- cleanupCtx := context.Background()
- if personalSubscriptionCanceled(ctx, wrapped) {
- cleanupCtx = ctx
- }
classification := personalSubscriptionLocalFailure()
wrapped = attempt.completeFailure(ctx, 0, 0, wrapped, &classification)
- cleanup(cleanupCtx)
}
+ cleanupOwnedSubscription(cleanupCtx)
return fmt.Errorf("event consume --as user: %w", wrapped)
}
if err := attempt.completeSuccess(); err != nil {
- cleanup(context.Background())
+ cleanupOwnedSubscription(context.Background())
return fmt.Errorf("event consume --as user: %w", err)
}
// Ownership-based cleanup: a subscription this run CREATED is
@@ -442,9 +463,8 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
// leaks server-side. A subscription REUSED via --subscribe-id is left
// intact — the caller owns its lifecycle. --ephemeral forces cleanup
// either way.
- selfCreated := strings.TrimSpace(opts.SubscribeID) == ""
- if opts.Ephemeral || selfCreated {
- defer cleanup(context.Background())
+ if ownsSubscription {
+ defer cleanupOwnedSubscription(context.Background())
}
cfg.EventKey = eventKey
@@ -466,17 +486,9 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
Source: foregroundSource,
}
bus.ApplyEnvTuning(&busCfg)
- err = personalBusRun(ctx, busCfg)
- if err != nil && !opts.Ephemeral {
- cleanup(context.Background())
- }
- return err
+ return personalBusRun(ctx, busCfg)
}
- err = personalConsumeRun(ctx, cfg)
- if err != nil && !opts.Ephemeral {
- cleanup(context.Background())
- }
- return err
+ return personalConsumeRun(ctx, cfg)
}
type personalMultiSubscription struct {
@@ -688,6 +700,9 @@ func preparePersonalMultiOptions(opts personalConsumeOptions) ([]personalConsume
if !def.Public {
return nil, personal.PublicAvailabilityError(eventKey)
}
+ if err := validatePersonalOAOptions(eventKey, opts); err != nil {
+ return nil, err
+ }
switch def.RuleType {
case "singleChat", "sender":
hasUserScope = true
@@ -814,6 +829,9 @@ func applyPersonalConsumeFilters(cfg *consume.Config, opts personalConsumeOption
}
func validatePersonalSubscriptionOptions(opts personalConsumeOptions) error {
+ if err := validatePersonalOAOptions(opts.EventKey, opts); err != nil {
+ return err
+ }
if _, _, err := personal.BuildRuleParam(opts.EventKey, personal.RuleOptions{
RuleType: opts.Rule,
UserID: opts.UserID,
@@ -826,6 +844,37 @@ func validatePersonalSubscriptionOptions(opts personalConsumeOptions) error {
return err
}
+func validatePersonalOAOptions(eventKey string, opts personalConsumeOptions) error {
+ changed := personalOAOptionNames(opts)
+ if len(changed) == 0 {
+ return nil
+ }
+ def, ok := personalLookupDefinition(strings.TrimSpace(eventKey))
+ if !ok || def.Category != "oa" {
+ return nil
+ }
+ return fmt.Errorf("%s not supported for OA event %s", strings.Join(changed, ", "), eventKey)
+}
+
+func personalOAOptionNames(opts personalConsumeOptions) []string {
+ var changed []string
+ for _, item := range []struct {
+ name string
+ value string
+ }{
+ {name: "--user", value: opts.UserID},
+ {name: "--open-dingtalk-id", value: opts.OpenDingTalkID},
+ {name: "--group", value: opts.GroupID},
+ {name: "--query", value: opts.QueryCSV},
+ {name: "--filter-json", value: opts.FilterJSON},
+ } {
+ if strings.TrimSpace(item.value) != "" {
+ changed = append(changed, item.name)
+ }
+ }
+ return changed
+}
+
type personalPreparedSubscription struct {
EventKey string
RuleType string
@@ -839,6 +888,9 @@ func preparePersonalSubscription(identity personal.Identity, opts personalConsum
if err := ensurePublicPersonalEvent(opts.EventKey); err != nil {
return personalPreparedSubscription{}, err
}
+ if err := validatePersonalOAOptions(opts.EventKey, opts); err != nil {
+ return personalPreparedSubscription{}, err
+ }
ruleType, ruleParam, err := personal.BuildRuleParam(opts.EventKey, personal.RuleOptions{
RuleType: opts.Rule,
UserID: opts.UserID,
@@ -885,13 +937,32 @@ func ensurePersonalSubscription(ctx context.Context, client *personal.Client, id
if err != nil {
return nil, "", "", err
}
- eventKey := firstNonEmptyPersonalString(opts.EventKey, sub.EventKey)
+ if sub == nil {
+ return nil, "", "", errors.New("personal event: server returned an empty subscription")
+ }
+ requestedEventKey := strings.TrimSpace(opts.EventKey)
+ actualEventKey := strings.TrimSpace(sub.EventKey)
+ if requestedEventKey != "" && actualEventKey != "" && requestedEventKey != actualEventKey {
+ return nil, "", "", fmt.Errorf(
+ "event_key %q does not match reused subscription %q event_key %q",
+ requestedEventKey,
+ strings.TrimSpace(opts.SubscribeID),
+ actualEventKey,
+ )
+ }
+ eventKey := actualEventKey
+ if eventKey == "" {
+ eventKey = requestedEventKey
+ }
if eventKey == "" {
return nil, "", "", fmt.Errorf("event_key is required when --subscribe-id lookup returns no event_key")
}
if err := ensurePublicPersonalEvent(eventKey); err != nil {
return nil, "", "", err
}
+ if err := validatePersonalOAOptions(eventKey, opts); err != nil {
+ return nil, "", "", err
+ }
ruleType := firstNonEmptyPersonalString(sub.RuleType, opts.Rule)
if ruleType == "" {
if def, ok := personal.Lookup(eventKey); ok {
diff --git a/internal/app/event_personal_full_coverage_test.go b/internal/app/event_personal_full_coverage_test.go
index a7cf1aa7..b6c70bda 100644
--- a/internal/app/event_personal_full_coverage_test.go
+++ b/internal/app/event_personal_full_coverage_test.go
@@ -18,6 +18,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/source"
eventtransport "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
@@ -36,18 +37,20 @@ func TestCrossPlatformCoveragePersonalEventRemainingSchemaAndSubscriptionCoverag
}
}
- oldGet := personalGetSubscription
- oldCreate := personalCreateSubscription
- t.Cleanup(func() {
- personalGetSubscription = oldGet
- personalCreateSubscription = oldCreate
- })
+ testseam.Protect(t, &personalGetSubscription)
+ testseam.Protect(t, &personalCreateSubscription)
client := personal.NewClient("https://example.test", personal.Identity{})
wantErr := errors.New("subscription")
personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) { return nil, wantErr }
if _, _, _, err := ensurePersonalSubscription(context.Background(), client, personal.Identity{}, personalConsumeOptions{SubscribeID: "sub"}); !errors.Is(err, wantErr) {
t.Fatalf("get subscription error = %v", err)
}
+ personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) {
+ return nil, nil
+ }
+ if _, _, _, err := ensurePersonalSubscription(context.Background(), client, personal.Identity{}, personalConsumeOptions{SubscribeID: "sub"}); err == nil || !strings.Contains(err.Error(), "empty subscription") {
+ t.Fatalf("nil subscription = %v", err)
+ }
personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) {
return &personal.Subscription{}, nil
}
diff --git a/internal/app/event_personal_oa_test.go b/internal/app/event_personal_oa_test.go
new file mode 100644
index 00000000..11f8dbe4
--- /dev/null
+++ b/internal/app/event_personal_oa_test.go
@@ -0,0 +1,627 @@
+// Copyright 2026 Alibaba Group
+// Licensed under the Apache License, Version 2.0 (the "License");
+
+package app
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "io"
+ "reflect"
+ "strings"
+ "testing"
+
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/consume"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
+ "github.com/spf13/cobra"
+)
+
+func TestPersonalOAEventListAndSchemaCommands(t *testing.T) {
+ list := newEventListCommand()
+ list.SilenceUsage = true
+ list.SilenceErrors = true
+ var listOut bytes.Buffer
+ list.SetOut(&listOut)
+ list.SetArgs([]string{"--category", "oa"})
+ if err := list.Execute(); err != nil {
+ t.Fatalf("event list --category oa error = %v", err)
+ }
+ tests := []struct {
+ eventKey string
+ properties []string
+ }{
+ {
+ eventKey: personal.EventOAApprovalTaskCreated,
+ properties: []string{
+ "type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
+ "process_code", "task_id", "title", "status", "create_time", "event_time",
+ },
+ },
+ {
+ eventKey: personal.EventOAApprovalTaskFinished,
+ properties: []string{
+ "type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
+ "process_code", "task_id", "title", "status", "result", "create_time",
+ "finish_time", "event_time",
+ },
+ },
+ {
+ eventKey: personal.EventOAApprovalTaskRedirected,
+ properties: []string{
+ "type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
+ "process_code", "task_id", "title", "status", "result", "create_time",
+ "finish_time", "event_time",
+ },
+ },
+ {
+ eventKey: personal.EventOAApprovalInstanceStarted,
+ properties: []string{
+ "type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
+ "process_code", "title", "status", "create_time", "event_time",
+ },
+ },
+ {
+ eventKey: personal.EventOAApprovalInstanceTerminated,
+ properties: []string{
+ "type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
+ "process_code", "title", "status", "create_time", "finish_time", "event_time",
+ },
+ },
+ {
+ eventKey: personal.EventOAApprovalInstanceFinished,
+ properties: []string{
+ "type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
+ "process_code", "title", "status", "result", "create_time", "finish_time",
+ "event_time",
+ },
+ },
+ }
+ for _, tt := range tests {
+ eventKey := tt.eventKey
+ if !strings.Contains(listOut.String(), eventKey) {
+ t.Fatalf("OA event list missing %s:\n%s", eventKey, listOut.String())
+ }
+
+ schema := newEventSchemaCommand()
+ schema.SilenceUsage = true
+ schema.SilenceErrors = true
+ var schemaOut bytes.Buffer
+ schema.SetOut(&schemaOut)
+ schema.SetArgs([]string{eventKey, "--flatten"})
+ if err := schema.Execute(); err != nil {
+ t.Fatalf("event schema %s --flatten error = %v", eventKey, err)
+ }
+ var doc map[string]any
+ if err := json.Unmarshal(schemaOut.Bytes(), &doc); err != nil {
+ t.Fatalf("decode schema for %s: %v\n%s", eventKey, err, schemaOut.String())
+ }
+ if doc["event_key"] != eventKey || doc["rule_type"] != "all" || doc["jq_root_path"] != "." {
+ t.Fatalf("schema document for %s = %#v", eventKey, doc)
+ }
+ schemaBody, ok := doc["schema"].(map[string]any)
+ if !ok {
+ t.Fatalf("schema body for %s = %#v", eventKey, doc["schema"])
+ }
+ properties, ok := schemaBody["properties"].(map[string]any)
+ if !ok || len(properties) != len(tt.properties) {
+ t.Fatalf("schema properties for %s = %#v, want %d fields", eventKey, schemaBody["properties"], len(tt.properties))
+ }
+ for _, name := range tt.properties {
+ if _, ok := properties[name].(map[string]any); !ok {
+ t.Fatalf("schema property %s for %s = %#v", name, eventKey, properties[name])
+ }
+ }
+ if _, ok := properties["payload"]; ok {
+ t.Fatalf("schema for %s exposed generic payload: %#v", eventKey, properties)
+ }
+ }
+ if strings.Contains(listOut.String(), personal.EventMention) {
+ t.Fatalf("OA category list leaked IM event:\n%s", listOut.String())
+ }
+}
+
+func TestPersonalOAEventConsumeDryRunAndValidation(t *testing.T) {
+ oldIdentity := personalResolveEventIdentity
+ oldGet := personalGetSubscription
+ t.Cleanup(func() {
+ personalResolveEventIdentity = oldIdentity
+ personalGetSubscription = oldGet
+ })
+ personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
+ return personal.Identity{
+ AccessToken: "token",
+ LocalSubject: "subject",
+ ClientID: "client",
+ SourceID: "open",
+ }, nil
+ }
+ personalGetSubscription = func(_ *personal.Client, _ context.Context, subscribeID string) (*personal.Subscription, error) {
+ switch subscribeID {
+ case "oa-sub-task":
+ return &personal.Subscription{
+ SubscribeID: subscribeID,
+ EventKey: personal.EventOAApprovalTaskCreated,
+ RuleType: "all",
+ }, nil
+ case "im-sub-at":
+ return &personal.Subscription{
+ SubscribeID: subscribeID,
+ EventKey: personal.EventMention,
+ RuleType: "at",
+ }, nil
+ default:
+ t.Fatalf("unexpected subscription lookup %q", subscribeID)
+ return nil, nil
+ }
+ }
+
+ oaEvents := []string{
+ personal.EventOAApprovalTaskCreated,
+ personal.EventOAApprovalTaskFinished,
+ personal.EventOAApprovalTaskRedirected,
+ personal.EventOAApprovalInstanceStarted,
+ personal.EventOAApprovalInstanceTerminated,
+ personal.EventOAApprovalInstanceFinished,
+ }
+ for _, eventKey := range oaEvents {
+ t.Run(eventKey+"/dry-run", func(t *testing.T) {
+ cmd := newEventConsumeCommand()
+ cmd.SilenceUsage = true
+ cmd.SilenceErrors = true
+ var stderr bytes.Buffer
+ cmd.SetOut(io.Discard)
+ cmd.SetErr(&stderr)
+ cmd.SetArgs([]string{eventKey, "--dry-run"})
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("OA dry-run error = %v", err)
+ }
+ if !strings.Contains(stderr.String(), "event_types : "+eventKey) {
+ t.Fatalf("OA dry-run does not select %s:\n%s", eventKey, stderr.String())
+ }
+ })
+
+ for _, args := range [][]string{
+ {"--user", "user-1"},
+ {"--open-dingtalk-id", "open-user-1"},
+ {"--group", "cid-1"},
+ {"--query", "urgent"},
+ {"--filter-json", `{"field":"content","op":"eq","value":"urgent"}`},
+ } {
+ name := strings.TrimPrefix(args[0], "--")
+ t.Run(eventKey+"/reject-"+name, func(t *testing.T) {
+ cmd := newEventConsumeCommand()
+ cmd.SilenceUsage = true
+ cmd.SilenceErrors = true
+ cmd.SetOut(io.Discard)
+ cmd.SetErr(io.Discard)
+ cmd.SetArgs(append([]string{eventKey}, append(args, "--dry-run")...))
+ err := cmd.Execute()
+ if err == nil || !strings.Contains(err.Error(), "not supported") {
+ t.Fatalf("OA consume %s error = %v, want unsupported option", args[0], err)
+ }
+ })
+ }
+ }
+
+ t.Run("multi-dry-run", func(t *testing.T) {
+ cmd := newEventConsumeCommand()
+ cmd.SilenceUsage = true
+ cmd.SilenceErrors = true
+ var stderr bytes.Buffer
+ cmd.SetOut(io.Discard)
+ cmd.SetErr(&stderr)
+ cmd.SetArgs(append(append([]string(nil), oaEvents...), "--dry-run"))
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("multi OA dry-run error = %v", err)
+ }
+ for _, eventKey := range oaEvents {
+ want := "event_key=" + eventKey + " rule_type=all rule_param={}"
+ if !strings.Contains(stderr.String(), want) {
+ t.Fatalf("multi OA dry-run missing %q:\n%s", want, stderr.String())
+ }
+ }
+ })
+
+ reuseOverrides := [][]string{
+ {"--user", "user-1"},
+ {"--open-dingtalk-id", "open-user-1"},
+ {"--group", "cid-1"},
+ {"--query", "urgent"},
+ {"--filter-json", `{"field":"content","op":"eq","value":"urgent"}`},
+ }
+ t.Run("reuse-dry-run/implicit-event-key/resolves-oa-event", func(t *testing.T) {
+ cmd := newEventConsumeCommand()
+ cmd.SilenceUsage = true
+ cmd.SilenceErrors = true
+ var stderr bytes.Buffer
+ cmd.SetOut(io.Discard)
+ cmd.SetErr(&stderr)
+ cmd.SetArgs([]string{"--subscribe-id", "oa-sub-task", "--dry-run"})
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("implicit reused OA dry-run error = %v", err)
+ }
+ if !strings.Contains(stderr.String(), "event_types : "+personal.EventOAApprovalTaskCreated) {
+ t.Fatalf("implicit reused OA dry-run did not resolve event key:\n%s", stderr.String())
+ }
+ })
+ for _, explicitEventKey := range []bool{true, false} {
+ mode := "implicit-event-key"
+ if explicitEventKey {
+ mode = "explicit-event-key"
+ }
+ for _, override := range reuseOverrides {
+ flag := override[0]
+ t.Run("reuse-dry-run/"+mode+"/"+strings.TrimPrefix(flag, "--"), func(t *testing.T) {
+ args := make([]string, 0, 6)
+ if explicitEventKey {
+ args = append(args, personal.EventOAApprovalTaskCreated)
+ }
+ args = append(args, "--subscribe-id", "oa-sub-task", flag, override[1], "--dry-run")
+ cmd := newEventConsumeCommand()
+ cmd.SilenceUsage = true
+ cmd.SilenceErrors = true
+ cmd.SetOut(io.Discard)
+ cmd.SetErr(io.Discard)
+ cmd.SetArgs(args)
+ err := cmd.Execute()
+ if err == nil || !strings.Contains(err.Error(), flag+" not supported for OA event") {
+ t.Fatalf("%s reused OA dry-run %s error = %v", mode, flag, err)
+ }
+ })
+ }
+ }
+
+ t.Run("reuse-dry-run/implicit-im-remains-supported", func(t *testing.T) {
+ cmd := newEventConsumeCommand()
+ cmd.SilenceUsage = true
+ cmd.SilenceErrors = true
+ cmd.SetOut(io.Discard)
+ cmd.SetErr(io.Discard)
+ cmd.SetArgs([]string{"--subscribe-id", "im-sub-at", "--query", "urgent", "--dry-run"})
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("implicit reused IM dry-run error = %v", err)
+ }
+ })
+
+ for _, override := range reuseOverrides {
+ flag, value := override[0], override[1]
+ t.Run("multi-reject-"+strings.TrimPrefix(flag, "--"), func(t *testing.T) {
+ cmd := newEventConsumeCommand()
+ cmd.SilenceUsage = true
+ cmd.SilenceErrors = true
+ cmd.SetOut(io.Discard)
+ cmd.SetErr(io.Discard)
+ args := append([]string(nil), oaEvents...)
+ args = append(args, flag, value, "--dry-run")
+ cmd.SetArgs(args)
+ err := cmd.Execute()
+ if err == nil || !strings.Contains(err.Error(), "not supported for OA event") {
+ t.Fatalf("multi OA consume %s error = %v", flag, err)
+ }
+ })
+ }
+
+ for _, test := range []struct {
+ name string
+ args []string
+ }{
+ {
+ name: "message query remains supported",
+ args: []string{personal.EventMention, "--query", "urgent", "--dry-run"},
+ },
+ {
+ name: "single group lifecycle filter remains supported",
+ args: []string{
+ personal.EventGroupUpdated,
+ "--group", "cid-1",
+ "--filter-json", `{"field":"future","op":"eq","value":"value"}`,
+ "--dry-run",
+ },
+ },
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ cmd := newEventConsumeCommand()
+ cmd.SilenceUsage = true
+ cmd.SilenceErrors = true
+ cmd.SetOut(io.Discard)
+ cmd.SetErr(io.Discard)
+ cmd.SetArgs(test.args)
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("existing IM consume behavior changed: %v", err)
+ }
+ })
+ }
+}
+
+func TestCrossPlatformCoveragePersonalOAValidationBranches(t *testing.T) {
+ invalid := personalConsumeOptions{
+ EventKey: personal.EventOAApprovalTaskCreated,
+ UserID: "user-1",
+ }
+ if err := validatePersonalSubscriptionOptions(invalid); err == nil ||
+ !strings.Contains(err.Error(), "--user not supported for OA event") {
+ t.Fatalf("validatePersonalSubscriptionOptions() error = %v", err)
+ }
+ if _, err := preparePersonalSubscription(personal.Identity{}, invalid); err == nil ||
+ !strings.Contains(err.Error(), "--user not supported for OA event") {
+ t.Fatalf("preparePersonalSubscription() error = %v", err)
+ }
+
+ oldGet := personalGetSubscription
+ t.Cleanup(func() { personalGetSubscription = oldGet })
+ personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) {
+ return &personal.Subscription{
+ SubscribeID: "oa-sub-without-event-key",
+ RuleType: "all",
+ }, nil
+ }
+ _, _, _, err := ensurePersonalSubscription(
+ context.Background(),
+ nil,
+ personal.Identity{},
+ personalConsumeOptions{
+ SubscribeID: "oa-sub-without-event-key",
+ EventKey: personal.EventOAApprovalTaskCreated,
+ UserID: "user-1",
+ },
+ )
+ if err == nil || !strings.Contains(err.Error(), "--user not supported for OA event") {
+ t.Fatalf("ensurePersonalSubscription() error = %v", err)
+ }
+}
+
+func TestPersonalOAMultiConsumeCreatesIndependentAllSubscriptionsOnSharedBus(t *testing.T) {
+ restoreMany := installPersonalManySeams(t)
+ defer restoreMany()
+ oldCreate := personalCreateSubscription
+ defer func() { personalCreateSubscription = oldCreate }()
+ t.Setenv("DWS_CONFIG_DIR", t.TempDir())
+
+ identity := personal.Identity{
+ AccessToken: "token",
+ LocalSubject: "subject",
+ ClientID: "client",
+ SourceID: "open",
+ }
+ personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
+ return identity, nil
+ }
+ var requests []personal.CreateSubscriptionRequest
+ personalCreateSubscription = func(_ *personal.Client, _ context.Context, req personal.CreateSubscriptionRequest) (*personal.Subscription, error) {
+ requests = append(requests, req)
+ return &personal.Subscription{SubscribeID: "sub-" + req.EventKey}, nil
+ }
+ personalEnsureSubscription = ensurePersonalSubscription
+ var states []personal.RunState
+ personalUpsertRunState = func(_ string, state personal.RunState) error {
+ states = append(states, state)
+ return nil
+ }
+ personalDeleteSubscription = func(*personal.Client, context.Context, string) error { return nil }
+ personalRemoveRunStates = func(string, []string) error { return nil }
+ personalValidateConsumeConfig = func(consume.Config) error { return nil }
+ runManyCalls := 0
+ var gotSpecs []consume.ConsumerSpec
+ personalConsumeRunMany = func(_ context.Context, _ consume.Config, specs []consume.ConsumerSpec) error {
+ runManyCalls++
+ gotSpecs = append([]consume.ConsumerSpec(nil), specs...)
+ return nil
+ }
+
+ eventKeys := []string{
+ personal.EventOAApprovalTaskCreated,
+ personal.EventOAApprovalTaskFinished,
+ personal.EventOAApprovalTaskRedirected,
+ personal.EventOAApprovalInstanceStarted,
+ personal.EventOAApprovalInstanceTerminated,
+ personal.EventOAApprovalInstanceFinished,
+ }
+ if err := runPersonalEventConsume(newPersonalCoverageCommand(), personalConsumeOptions{
+ EventKeys: eventKeys,
+ Flatten: true,
+ }); err != nil {
+ t.Fatalf("multi OA consume error = %v", err)
+ }
+ if runManyCalls != 1 {
+ t.Fatalf("RunMany calls = %d, want one shared-bus consume call", runManyCalls)
+ }
+ if len(requests) != len(eventKeys) || len(states) != len(eventKeys) || len(gotSpecs) != len(eventKeys) {
+ t.Fatalf("requests=%d states=%d specs=%d, want %d each", len(requests), len(states), len(gotSpecs), len(eventKeys))
+ }
+ for i, eventKey := range eventKeys {
+ req := requests[i]
+ if req.EventKey != eventKey || req.RuleType != "all" || req.RuleParam == nil || len(req.RuleParam) != 0 || req.Filter != nil {
+ t.Fatalf("subscription request[%d] = %#v, want %s all/{}", i, req, eventKey)
+ }
+ if states[i].EventKey != eventKey || states[i].RuleType != "all" {
+ t.Fatalf("run state[%d] = %#v", i, states[i])
+ }
+ wantSpec := consume.ConsumerSpec{
+ EventKey: eventKey,
+ EventTypes: []string{eventKey},
+ SubscribeID: "sub-" + eventKey,
+ ReadySubscribeID: "sub-" + eventKey,
+ }
+ if !reflect.DeepEqual(gotSpecs[i], wantSpec) {
+ t.Fatalf("consumer spec[%d] = %#v, want %#v", i, gotSpecs[i], wantSpec)
+ }
+ }
+}
+
+func TestPersonalOAReusedSubscriptionRejectsDefinitionOverridesAtRuntime(t *testing.T) {
+ oldGet := personalGetSubscription
+ t.Cleanup(func() { personalGetSubscription = oldGet })
+ getCalls := 0
+ personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) {
+ getCalls++
+ return &personal.Subscription{
+ SubscribeID: "oa-sub-task",
+ EventKey: personal.EventOAApprovalTaskCreated,
+ RuleType: "all",
+ }, nil
+ }
+
+ tests := []struct {
+ name string
+ set func(*personalConsumeOptions)
+ }{
+ {name: "user", set: func(opts *personalConsumeOptions) { opts.UserID = "user-1" }},
+ {name: "open-dingtalk-id", set: func(opts *personalConsumeOptions) { opts.OpenDingTalkID = "open-user-1" }},
+ {name: "group", set: func(opts *personalConsumeOptions) { opts.GroupID = "cid-1" }},
+ {name: "query", set: func(opts *personalConsumeOptions) { opts.QueryCSV = "urgent" }},
+ {name: "filter-json", set: func(opts *personalConsumeOptions) { opts.FilterJSON = `{"field":"content","op":"eq","value":"urgent"}` }},
+ }
+ for _, explicitEventKey := range []bool{true, false} {
+ mode := "implicit-event-key"
+ if explicitEventKey {
+ mode = "explicit-event-key"
+ }
+ for _, test := range tests {
+ t.Run(mode+"/"+test.name, func(t *testing.T) {
+ opts := personalConsumeOptions{SubscribeID: "oa-sub-task"}
+ if explicitEventKey {
+ opts.EventKey = personal.EventOAApprovalTaskCreated
+ }
+ test.set(&opts)
+ before := getCalls
+ _, _, _, err := ensurePersonalSubscription(
+ context.Background(),
+ nil,
+ personal.Identity{},
+ opts,
+ )
+ if err == nil || !strings.Contains(err.Error(), "--"+test.name+" not supported for OA event") {
+ t.Fatalf("reused OA subscription %s error = %v", test.name, err)
+ }
+ if getCalls != before+1 {
+ t.Fatalf("subscription lookup calls = %d, want %d", getCalls, before+1)
+ }
+ })
+ }
+ }
+}
+
+func TestPersonalOAImplicitReuseRuntimeLooksUpEventBeforeValidation(t *testing.T) {
+ oldIdentity := personalResolveEventIdentity
+ oldGet := personalGetSubscription
+ t.Cleanup(func() {
+ personalResolveEventIdentity = oldIdentity
+ personalGetSubscription = oldGet
+ })
+ personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
+ return personal.Identity{
+ AccessToken: "token",
+ LocalSubject: "subject",
+ ClientID: "client",
+ SourceID: "open",
+ }, nil
+ }
+ getCalls := 0
+ personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) {
+ getCalls++
+ return &personal.Subscription{
+ SubscribeID: "oa-sub-task",
+ EventKey: personal.EventOAApprovalTaskCreated,
+ RuleType: "all",
+ }, nil
+ }
+
+ cmd := newEventConsumeCommand()
+ cmd.SilenceUsage = true
+ cmd.SilenceErrors = true
+ cmd.SetOut(io.Discard)
+ cmd.SetErr(io.Discard)
+ cmd.SetArgs([]string{"--subscribe-id", "oa-sub-task", "--group", "cid-1"})
+ err := cmd.Execute()
+ if err == nil || !strings.Contains(err.Error(), "--group not supported for OA event "+personal.EventOAApprovalTaskCreated) {
+ t.Fatalf("implicit reused OA runtime error = %v", err)
+ }
+ if getCalls != 1 {
+ t.Fatalf("subscription lookup calls = %d, want 1", getCalls)
+ }
+}
+
+func TestPersonalIMReusedSubscriptionWithExistingOverridesRemainsSupported(t *testing.T) {
+ oldGet := personalGetSubscription
+ t.Cleanup(func() { personalGetSubscription = oldGet })
+ personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) {
+ return &personal.Subscription{
+ SubscribeID: "im-sub",
+ EventKey: personal.EventSingleChat,
+ RuleType: "singleChat",
+ }, nil
+ }
+
+ sub, eventKey, ruleType, err := ensurePersonalSubscription(
+ context.Background(),
+ nil,
+ personal.Identity{},
+ personalConsumeOptions{
+ SubscribeID: "im-sub",
+ EventKey: personal.EventSingleChat,
+ UserID: "user-1",
+ QueryCSV: "urgent",
+ FilterJSON: `{"field":"content","op":"eq","value":"urgent"}`,
+ },
+ )
+ if err != nil {
+ t.Fatalf("reused IM subscription error = %v", err)
+ }
+ if sub.SubscribeID != "im-sub" || eventKey != personal.EventSingleChat || ruleType != "singleChat" {
+ t.Fatalf("reused IM subscription = %#v, event=%q rule=%q", sub, eventKey, ruleType)
+ }
+}
+
+func TestPersonalOAStatusAndStopCommandWiring(t *testing.T) {
+ oldStatus := eventRunPersonalStatus
+ oldStop := eventRunPersonalStop
+ t.Cleanup(func() {
+ eventRunPersonalStatus = oldStatus
+ eventRunPersonalStop = oldStop
+ })
+
+ var statusOpts personalStatusOptions
+ eventRunPersonalStatus = func(_ *cobra.Command, opts personalStatusOptions) error {
+ statusOpts = opts
+ return nil
+ }
+ status := newEventStatusCommand()
+ status.SilenceUsage = true
+ status.SilenceErrors = true
+ status.SetOut(io.Discard)
+ status.SetErr(io.Discard)
+ status.SetArgs([]string{
+ "--event", personal.EventOAApprovalTaskCreated,
+ "--subscribe-id", "oa-sub-task",
+ "--status", "all",
+ })
+ if err := status.Execute(); err != nil {
+ t.Fatalf("OA event status error = %v", err)
+ }
+ if statusOpts.EventKey != personal.EventOAApprovalTaskCreated ||
+ statusOpts.SubscribeID != "oa-sub-task" ||
+ statusOpts.Status != "all" {
+ t.Fatalf("OA status options = %#v", statusOpts)
+ }
+
+ var stopOpts personalStopOptions
+ eventRunPersonalStop = func(_ *cobra.Command, opts personalStopOptions) error {
+ stopOpts = opts
+ return nil
+ }
+ root := &cobra.Command{Use: "dws", SilenceUsage: true, SilenceErrors: true}
+ root.SetOut(io.Discard)
+ root.SetErr(io.Discard)
+ root.PersistentFlags().Bool("yes", false, "")
+ event := &cobra.Command{Use: "event"}
+ event.AddCommand(newEventStopCommand())
+ root.AddCommand(event)
+ root.SetArgs([]string{"event", "stop", "oa-sub-task", "--yes"})
+ if err := root.Execute(); err != nil {
+ t.Fatalf("OA event stop error = %v", err)
+ }
+ if stopOpts.SubscribeID != "oa-sub-task" || stopOpts.All {
+ t.Fatalf("OA stop options = %#v", stopOpts)
+ }
+}
diff --git a/internal/app/event_personal_release_safety_test.go b/internal/app/event_personal_release_safety_test.go
new file mode 100644
index 00000000..c1809967
--- /dev/null
+++ b/internal/app/event_personal_release_safety_test.go
@@ -0,0 +1,427 @@
+// Copyright 2026 Alibaba Group
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package app
+
+import (
+ "context"
+ "errors"
+ "strings"
+ "testing"
+
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/bus"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/consume"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/source"
+)
+
+func TestPersonalConsumeCleanupOwnershipRuntimeMatrix(t *testing.T) {
+ runErr := errors.New("runtime failed")
+ for _, foreground := range []bool{false, true} {
+ for _, selfCreated := range []bool{false, true} {
+ for _, ephemeral := range []bool{false, true} {
+ for _, failRuntime := range []bool{false, true} {
+ name := strings.Join([]string{
+ map[bool]string{false: "background", true: "foreground"}[foreground],
+ map[bool]string{false: "reused", true: "self-created"}[selfCreated],
+ map[bool]string{false: "persistent", true: "ephemeral"}[ephemeral],
+ map[bool]string{false: "success", true: "error"}[failRuntime],
+ }, "/")
+ t.Run(name, func(t *testing.T) {
+ restore := installPersonalManySeams(t)
+ t.Cleanup(restore)
+ t.Setenv("DWS_CONFIG_DIR", t.TempDir())
+
+ oldNewSource := personalNewStreamSource
+ oldBusRun := personalBusRun
+ oldConsumeRun := personalConsumeRun
+ t.Cleanup(func() {
+ personalNewStreamSource = oldNewSource
+ personalBusRun = oldBusRun
+ personalConsumeRun = oldConsumeRun
+ })
+
+ personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
+ return personal.Identity{
+ AccessToken: "token",
+ ClientID: "client",
+ SourceID: "open",
+ LocalSubject: "subject",
+ }, nil
+ }
+ personalEnsureSubscription = func(
+ context.Context,
+ *personal.Client,
+ personal.Identity,
+ personalConsumeOptions,
+ ) (*personal.Subscription, string, string, error) {
+ return &personal.Subscription{SubscribeID: "sub-one"}, personal.EventMention, "at", nil
+ }
+ personalValidateConsumeConfig = func(consume.Config) error { return nil }
+ personalValidateNoOutputConflict = func(consume.Config, string) error { return nil }
+ personalUpsertRunState = func(string, personal.RunState) error { return nil }
+
+ deleteCalls := 0
+ removeCalls := 0
+ personalDeleteSubscription = func(*personal.Client, context.Context, string) error {
+ deleteCalls++
+ return nil
+ }
+ personalRemoveRunStates = func(string, []string) error {
+ removeCalls++
+ return nil
+ }
+
+ personalNewStreamSource = func(context.Context, personalStreamSourceOptions) (*source.PersonalSource, error) {
+ return nil, nil
+ }
+ personalBusRun = func(context.Context, bus.Config) error {
+ if failRuntime {
+ return runErr
+ }
+ return nil
+ }
+ personalConsumeRun = func(context.Context, consume.Config) error {
+ if failRuntime {
+ return runErr
+ }
+ return nil
+ }
+
+ opts := personalConsumeOptions{
+ EventKey: personal.EventMention,
+ Ephemeral: ephemeral,
+ ControlBaseURL: "https://mcp.example.test/dws",
+ Common: commonConsumeOptions{
+ Foreground: foreground,
+ },
+ }
+ if !selfCreated {
+ opts.SubscribeID = "sub-one"
+ }
+
+ err := runPersonalEventConsumeSingle(newPersonalCoverageCommand(), opts)
+ if failRuntime {
+ if !errors.Is(err, runErr) {
+ t.Fatalf("runtime error = %v, want %v", err, runErr)
+ }
+ } else if err != nil {
+ t.Fatalf("consume error = %v", err)
+ }
+
+ wantCleanup := 0
+ if selfCreated || ephemeral {
+ wantCleanup = 1
+ }
+ if deleteCalls != wantCleanup || removeCalls != wantCleanup {
+ t.Fatalf(
+ "cleanup delete/remove = %d/%d, want %d/%d",
+ deleteCalls,
+ removeCalls,
+ wantCleanup,
+ wantCleanup,
+ )
+ }
+ })
+ }
+ }
+ }
+ }
+}
+
+func TestPersonalConsumeCleanupOwnershipOnRunStateFailure(t *testing.T) {
+ stateErr := errors.New("save state failed")
+ for _, test := range []struct {
+ name string
+ selfCreated bool
+ ephemeral bool
+ wantCleanup int
+ }{
+ {name: "self-created", selfCreated: true, wantCleanup: 1},
+ {name: "reused persistent", wantCleanup: 0},
+ {name: "reused ephemeral", ephemeral: true, wantCleanup: 1},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ restore := installPersonalManySeams(t)
+ t.Cleanup(restore)
+ t.Setenv("DWS_CONFIG_DIR", t.TempDir())
+
+ personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
+ return personal.Identity{AccessToken: "token", ClientID: "client", SourceID: "open"}, nil
+ }
+ personalEnsureSubscription = func(
+ context.Context,
+ *personal.Client,
+ personal.Identity,
+ personalConsumeOptions,
+ ) (*personal.Subscription, string, string, error) {
+ return &personal.Subscription{SubscribeID: "sub-one"}, personal.EventMention, "at", nil
+ }
+ personalValidateConsumeConfig = func(consume.Config) error { return nil }
+ personalUpsertRunState = func(string, personal.RunState) error { return stateErr }
+ deleteCalls := 0
+ removeCalls := 0
+ personalDeleteSubscription = func(*personal.Client, context.Context, string) error {
+ deleteCalls++
+ return nil
+ }
+ personalRemoveRunStates = func(string, []string) error {
+ removeCalls++
+ return nil
+ }
+
+ opts := personalConsumeOptions{
+ EventKey: personal.EventMention,
+ Ephemeral: test.ephemeral,
+ ControlBaseURL: "https://mcp.example.test/dws",
+ }
+ if !test.selfCreated {
+ opts.SubscribeID = "sub-one"
+ }
+ if err := runPersonalEventConsumeSingle(newPersonalCoverageCommand(), opts); !errors.Is(err, stateErr) {
+ t.Fatalf("state error = %v, want %v", err, stateErr)
+ }
+ if deleteCalls != test.wantCleanup || removeCalls != test.wantCleanup {
+ t.Fatalf(
+ "cleanup delete/remove = %d/%d, want %d/%d",
+ deleteCalls,
+ removeCalls,
+ test.wantCleanup,
+ test.wantCleanup,
+ )
+ }
+ })
+ }
+}
+
+func TestPersonalReusedSubscriptionEventKeyResolution(t *testing.T) {
+ oldGet := personalGetSubscription
+ t.Cleanup(func() { personalGetSubscription = oldGet })
+
+ for _, test := range []struct {
+ name string
+ requested string
+ actual string
+ wantKey string
+ wantErr bool
+ }{
+ {
+ name: "matching key uses actual",
+ requested: personal.EventMention,
+ actual: personal.EventMention,
+ wantKey: personal.EventMention,
+ },
+ {
+ name: "implicit key uses actual",
+ actual: personal.EventOAApprovalTaskCreated,
+ wantKey: personal.EventOAApprovalTaskCreated,
+ },
+ {
+ name: "missing actual falls back to requested",
+ requested: personal.EventOAApprovalTaskCreated,
+ wantKey: personal.EventOAApprovalTaskCreated,
+ },
+ {
+ name: "requested IM mismatches actual OA",
+ requested: personal.EventMention,
+ actual: personal.EventOAApprovalTaskCreated,
+ wantErr: true,
+ },
+ {
+ name: "requested OA mismatches actual IM",
+ requested: personal.EventOAApprovalTaskCreated,
+ actual: personal.EventMention,
+ wantErr: true,
+ },
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) {
+ return &personal.Subscription{
+ SubscribeID: "sub-one",
+ EventKey: test.actual,
+ }, nil
+ }
+ _, eventKey, _, err := ensurePersonalSubscription(
+ context.Background(),
+ nil,
+ personal.Identity{},
+ personalConsumeOptions{SubscribeID: "sub-one", EventKey: test.requested},
+ )
+ if test.wantErr {
+ if err == nil || !strings.Contains(err.Error(), "does not match reused subscription") {
+ t.Fatalf("mismatch error = %v", err)
+ }
+ if !strings.Contains(err.Error(), test.requested) || !strings.Contains(err.Error(), test.actual) {
+ t.Fatalf("mismatch error does not identify both keys: %v", err)
+ }
+ return
+ }
+ if err != nil {
+ t.Fatalf("resolve reused subscription: %v", err)
+ }
+ if eventKey != test.wantKey {
+ t.Fatalf("resolved event key = %q, want %q", eventKey, test.wantKey)
+ }
+ })
+ }
+}
+
+func TestPersonalReusedSubscriptionMismatchStopsDryRunAndRuntime(t *testing.T) {
+ for _, mode := range []struct {
+ name string
+ dryRun bool
+ foreground bool
+ }{
+ {name: "dry-run", dryRun: true},
+ {name: "background"},
+ {name: "foreground", foreground: true},
+ } {
+ t.Run(mode.name, func(t *testing.T) {
+ restore := installPersonalManySeams(t)
+ t.Cleanup(restore)
+ t.Setenv("DWS_CONFIG_DIR", t.TempDir())
+
+ oldGet := personalGetSubscription
+ oldNewSource := personalNewStreamSource
+ oldBusRun := personalBusRun
+ oldConsumeRun := personalConsumeRun
+ t.Cleanup(func() {
+ personalGetSubscription = oldGet
+ personalNewStreamSource = oldNewSource
+ personalBusRun = oldBusRun
+ personalConsumeRun = oldConsumeRun
+ })
+
+ personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
+ return personal.Identity{AccessToken: "token", ClientID: "client", SourceID: "open"}, nil
+ }
+ personalEnsureSubscription = ensurePersonalSubscription
+ personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) {
+ return &personal.Subscription{
+ SubscribeID: "sub-one",
+ EventKey: personal.EventOAApprovalTaskCreated,
+ RuleType: "all",
+ }, nil
+ }
+ personalValidateConsumeConfig = func(consume.Config) error { return nil }
+ personalValidateNoOutputConflict = func(consume.Config, string) error { return nil }
+
+ upsertCalls := 0
+ consumeCalls := 0
+ busCalls := 0
+ personalUpsertRunState = func(string, personal.RunState) error {
+ upsertCalls++
+ return nil
+ }
+ personalNewStreamSource = func(context.Context, personalStreamSourceOptions) (*source.PersonalSource, error) {
+ return nil, nil
+ }
+ personalBusRun = func(context.Context, bus.Config) error {
+ busCalls++
+ return nil
+ }
+ personalConsumeRun = func(context.Context, consume.Config) error {
+ consumeCalls++
+ return nil
+ }
+
+ err := runPersonalEventConsumeSingle(newPersonalCoverageCommand(), personalConsumeOptions{
+ EventKey: personal.EventMention,
+ SubscribeID: "sub-one",
+ ControlBaseURL: "https://mcp.example.test/dws",
+ Common: commonConsumeOptions{
+ DryRun: mode.dryRun,
+ Foreground: mode.foreground,
+ },
+ })
+ if err == nil || !strings.Contains(err.Error(), "does not match reused subscription") {
+ t.Fatalf("mismatch error = %v", err)
+ }
+ if upsertCalls != 0 || consumeCalls != 0 || busCalls != 0 {
+ t.Fatalf(
+ "mismatch reached upsert/consumer/bus = %d/%d/%d",
+ upsertCalls,
+ consumeCalls,
+ busCalls,
+ )
+ }
+ })
+ }
+}
+
+func TestPersonalReusedSubscriptionUsesActualKeyInDryRunAndRuntime(t *testing.T) {
+ for _, dryRun := range []bool{true, false} {
+ name := map[bool]string{false: "runtime", true: "dry-run"}[dryRun]
+ t.Run(name, func(t *testing.T) {
+ restore := installPersonalManySeams(t)
+ t.Cleanup(restore)
+ t.Setenv("DWS_CONFIG_DIR", t.TempDir())
+
+ oldGet := personalGetSubscription
+ oldConsumeRun := personalConsumeRun
+ t.Cleanup(func() {
+ personalGetSubscription = oldGet
+ personalConsumeRun = oldConsumeRun
+ })
+
+ personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
+ return personal.Identity{AccessToken: "token", ClientID: "client", SourceID: "open"}, nil
+ }
+ personalEnsureSubscription = ensurePersonalSubscription
+ personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) {
+ return &personal.Subscription{
+ SubscribeID: "sub-oa",
+ EventKey: personal.EventOAApprovalInstanceFinished,
+ RuleType: "all",
+ }, nil
+ }
+ personalValidateConsumeConfig = func(consume.Config) error { return nil }
+ personalValidateNoOutputConflict = func(consume.Config, string) error { return nil }
+ personalUpsertRunState = func(_ string, state personal.RunState) error {
+ if state.EventKey != personal.EventOAApprovalInstanceFinished {
+ t.Fatalf("run state event key = %q", state.EventKey)
+ }
+ return nil
+ }
+
+ var got consume.Config
+ personalConsumeRun = func(_ context.Context, cfg consume.Config) error {
+ got = cfg
+ return nil
+ }
+ if err := runPersonalEventConsumeSingle(newPersonalCoverageCommand(), personalConsumeOptions{
+ SubscribeID: "sub-oa",
+ ControlBaseURL: "https://mcp.example.test/dws",
+ Common: commonConsumeOptions{
+ DryRun: dryRun,
+ },
+ }); err != nil {
+ t.Fatalf("reuse subscription: %v", err)
+ }
+ if got.EventKey != personal.EventOAApprovalInstanceFinished ||
+ len(got.EventTypes) != 1 || got.EventTypes[0] != personal.EventOAApprovalInstanceFinished ||
+ got.SubscribeID != "sub-oa" {
+ t.Fatalf("consume config = %#v", got)
+ }
+ })
+ }
+}
+
+func TestEventConsumeDryRunHelpDescribesReuseLookup(t *testing.T) {
+ usage := newEventConsumeCommand().Flags().Lookup("dry-run").Usage
+ for _, want := range []string{"不创建订阅", "不连接 bus", "复用 --subscribe-id", "只读查询控制面"} {
+ if !strings.Contains(usage, want) {
+ t.Fatalf("dry-run help %q missing %q", usage, want)
+ }
+ }
+}
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..8be970ba 100644
--- a/internal/app/skill_setup.go
+++ b/internal/app/skill_setup.go
@@ -51,6 +51,8 @@ var (
skillSetupInstallMono = installSkillToHomes
skillSetupInstallMulti = installMultiSkillToHomes
skillSetupCopyDir = copyDir
+ skillSetupMkdirTemp = os.MkdirTemp
+ skillSetupRename = os.Rename
skillSetupRunForm = (*huh.Form).Run
skillSetupInteractive = isInteractiveTerminal
skillSetupReadDir = os.ReadDir
@@ -136,6 +138,9 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
// multi 模式枚举 src 下的子 skill 名,供确认信息与安装步骤共用
var multiSkillNames []string
+ var foldedEventMiscTargets []string
+ var migrateEventMiscTargets []string
+ var installsEventMiscCompanion bool
if mode == skillSetupModeMulti {
allMultiSkillNames, listErr := skillSetupListMulti(skillSrc)
if listErr != nil {
@@ -151,6 +156,30 @@ 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)
+
+ foldedEventMiscTargets = findFoldedEventMiscTargets(dests)
+ if len(foldedEventMiscTargets) > 0 {
+ hasEvent := containsSkillName(multiSkillNames, multiEventSkill)
+ hasMisc := containsSkillName(multiSkillNames, multiMiscSkill)
+ switch {
+ case normalizedSkillListContains(excludeRaw, multiEventSkill):
+ return fmt.Errorf("检测到已有 dingtalk-misc 仍承载个人 Event 路由;不能显式 --exclude event,请先完成 dingtalk-event 迁移")
+ case hasMisc && !hasEvent:
+ return fmt.Errorf("检测到已有 dingtalk-misc 仍承载个人 Event 路由;不能只覆盖 dingtalk-misc,必须同时迁移 dingtalk-event")
+ case hasEvent:
+ 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)
+ }
+ if err := validateEventMiscMigrationSource(skillSrc); err != nil {
+ return err
+ }
+ migrateEventMiscTargets = append(migrateEventMiscTargets, foldedEventMiscTargets...)
+ installsEventMiscCompanion = !hasMisc
+ }
+ }
}
// --dry-run:仅预览将安装的内容与目标目录,不写入任何文件、不弹确认。
@@ -162,11 +191,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, migrateEventMiscTargets, installsEventMiscCompanion)
}
return nil
}
if !autoYes {
+ if mode == skillSetupModeMulti {
+ printEventMiscMigrationPreview(out, migrateEventMiscTargets, installsEventMiscCompanion)
+ }
ok, err := skillSetupConfirm(out, mode, skillSrc, dests, multiSkillNames)
if err != nil {
return err
@@ -182,7 +215,14 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
case skillSetupModeMono:
installed, skipped, err = skillSetupInstallMono(skillSrc, dests, out, errOut)
case skillSetupModeMulti:
- installed, skipped, err = skillSetupInstallMulti(skillSrc, multiSkillNames, dests, out, errOut)
+ installed, skipped, err = installMultiSkillsWithEventMigration(
+ skillSrc,
+ multiSkillNames,
+ dests,
+ migrateEventMiscTargets,
+ out,
+ errOut,
+ )
default:
return fmt.Errorf("内部错误:未知 mode %q", mode)
}
@@ -191,6 +231,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 +245,226 @@ const multiSkillPrefix = "dingtalk-"
// dingtalk-shared that was never installed.
const multiSharedSkill = "dingtalk-shared"
+// legacyMultiSharedSkill is the retired name shipped by older multi-skill
+// bundles. Once the replacement has been installed successfully, remove this
+// exact directory so Agent discovery cannot load both routing contracts.
+const legacyMultiSharedSkill = "dws-shared"
+
+const (
+ multiEventSkill = "dingtalk-event"
+ multiMiscSkill = "dingtalk-misc"
+)
+
+var eventMigrationRequiredReferences = []string{
+ "event-im.md",
+ "event-im-keys.md",
+ "event-im-lifecycle.md",
+ "event-im-operations.md",
+ "event-im-output.md",
+ "event-oa.md",
+}
+
+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-event 与本次已选择的干净 dingtalk-misc"
+ if installsCompanion {
+ action = "将原子切换 dingtalk-event,并额外安装干净的 dingtalk-misc 作为迁移伴侣(仅限以下目标)"
+ }
+ fmt.Fprintf(out, "Event Skill 迁移:%s:\n", action)
+ for _, target := range targets {
+ fmt.Fprintf(out, " - %s\n", target)
+ }
+}
+
+func validateEventMiscMigrationSource(src string) error {
+ if err := validateEventMigrationSkillRoot(filepath.Join(src, multiEventSkill)); err != nil {
+ return fmt.Errorf("event Skill 迁移源无效: %w", err)
+ }
+ if err := validateMigrationSkillRoot(filepath.Join(src, multiMiscSkill), multiMiscSkill, nil); err != nil {
+ return fmt.Errorf("event Skill 迁移源无效: %w", err)
+ }
+
+ if err := validateCleanEventMiscRoot(filepath.Join(src, multiMiscSkill)); err != nil {
+ return fmt.Errorf("event Skill 迁移源无效: %w", err)
+ }
+ return nil
+}
+
+func validateEventMigrationSkillRoot(root string) error {
+ required := make([]string, 0, len(eventMigrationRequiredReferences))
+ for _, name := range eventMigrationRequiredReferences {
+ required = append(required, filepath.Join("references", name))
+ }
+ return validateMigrationSkillRoot(root, multiEventSkill, required)
+}
+
+func validateMigrationSkillRoot(root, expectedName string, requiredFiles []string) error {
+ skillPath := filepath.Join(root, "SKILL.md")
+ skillBody, err := os.ReadFile(skillPath)
+ if err != nil {
+ return fmt.Errorf("无法读取 %s: %w", skillPath, err)
+ }
+ name, err := parseMigrationSkillFrontmatter(skillBody)
+ if err != nil {
+ return fmt.Errorf("%s 无效: %w", skillPath, err)
+ }
+ if name != expectedName {
+ return fmt.Errorf("%s 的 name=%q,期望 %q", skillPath, name, expectedName)
+ }
+ for _, rel := range requiredFiles {
+ path := filepath.Join(root, rel)
+ info, statErr := skillSetupStat(path)
+ if statErr != nil || info.IsDir() {
+ if statErr == nil {
+ statErr = errors.New("is a directory")
+ }
+ return fmt.Errorf("缺少有效文件 %s: %w", path, statErr)
+ }
+ body, readErr := os.ReadFile(path)
+ if readErr != nil {
+ return fmt.Errorf("无法读取 %s: %w", path, readErr)
+ }
+ if strings.TrimSpace(string(body)) == "" {
+ return fmt.Errorf("文件为空 %s", path)
+ }
+ }
+ return nil
+}
+
+func parseMigrationSkillFrontmatter(body []byte) (string, error) {
+ normalized := strings.ReplaceAll(string(body), "\r\n", "\n")
+ lines := strings.Split(normalized, "\n")
+ if len(lines) == 0 || strings.TrimSpace(lines[0]) != "---" {
+ return "", errors.New("缺少 YAML frontmatter")
+ }
+ name := ""
+ description := ""
+ closingLine := -1
+ for i := 1; i < len(lines); i++ {
+ rawLine := lines[i]
+ line := strings.TrimSpace(rawLine)
+ if line == "---" {
+ closingLine = i
+ break
+ }
+ // Only inspect top-level frontmatter keys. Nested metadata may legally
+ // contain its own `name` without changing the Skill identity.
+ if strings.TrimLeft(rawLine, " \t") != rawLine {
+ continue
+ }
+ key, value, ok := strings.Cut(line, ":")
+ if !ok {
+ continue
+ }
+ value = strings.Trim(strings.TrimSpace(value), "\"'")
+ switch strings.TrimSpace(key) {
+ case "name":
+ if name != "" {
+ return "", errors.New("frontmatter 含重复 name")
+ }
+ name = value
+ case "description":
+ description = value
+ }
+ }
+ if closingLine < 0 {
+ return "", errors.New("YAML frontmatter 未闭合")
+ }
+ if name == "" {
+ return "", errors.New("frontmatter 缺少 name")
+ }
+ if description == "" {
+ return "", errors.New("frontmatter 缺少 description")
+ }
+ if strings.TrimSpace(strings.Join(lines[closingLine+1:], "\n")) == "" {
+ return "", errors.New("SKILL.md 正文为空")
+ }
+ return name, nil
+}
+
+func validateCleanEventMiscRoot(miscRoot string) error {
+ miscSkillPath := filepath.Join(miscRoot, "SKILL.md")
+ miscBody, err := os.ReadFile(miscSkillPath)
+ if err != nil {
+ return fmt.Errorf("无法读取 %s: %w", miscSkillPath, err)
+ }
+ if containsPersonalEventRoute(miscBody) {
+ return fmt.Errorf("%s 仍包含个人 Event 路由", miscSkillPath)
+ }
+ refsRoot := filepath.Join(miscRoot, "references")
+ entries, err := skillSetupReadDir(refsRoot)
+ if errors.Is(err, os.ErrNotExist) {
+ return nil
+ }
+ if err != nil {
+ return fmt.Errorf("无法检查 %s: %w", refsRoot, err)
+ }
+ for _, entry := range entries {
+ name := strings.ToLower(entry.Name())
+ if !entry.IsDir() && strings.HasPrefix(name, "event") && strings.HasSuffix(name, ".md") {
+ return fmt.Errorf("%s 仍存在折叠 Event 参考页", filepath.Join(refsRoot, entry.Name()))
+ }
+ }
+ return nil
+}
+
// 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 {
@@ -607,6 +868,21 @@ func cleanupMutualExclusion(dest, mode string, out, errOut io.Writer) {
}
}
+func cleanupLegacyMultiSharedSkill(dest string, out, errOut io.Writer) {
+ legacyPath := filepath.Join(dest, legacyMultiSharedSkill)
+ if _, err := skillSetupStat(legacyPath); err != nil {
+ if !errors.Is(err, os.ErrNotExist) {
+ fmt.Fprintf(errOut, " ⚠️ 无法检查已退役 Skill 残留 %s: %v\n", legacyPath, err)
+ }
+ return
+ }
+ if err := skillSetupRemoveAll(legacyPath); err != nil {
+ fmt.Fprintf(errOut, " ⚠️ 已退役 Skill 清理失败(已安装 %s) %s: %v\n", multiSharedSkill, legacyPath, err)
+ return
+ }
+ fmt.Fprintf(out, " × 已清理已退役 Skill 残留 %s\n", legacyPath)
+}
+
func installSkillToHomes(src string, dests []string, out, errOut io.Writer) (installed, skipped int, err error) {
sort.Strings(dests)
for _, dest := range dests {
@@ -634,6 +910,299 @@ func installSkillToHomes(src string, dests []string, out, errOut io.Writer) (ins
return installed, skipped, nil
}
+func installMultiSkillsWithEventMigration(
+ src string,
+ skillNames []string,
+ dests []string,
+ migrationTargets []string,
+ out, errOut io.Writer,
+) (installed, skipped int, err error) {
+ if len(migrationTargets) == 0 {
+ return skillSetupInstallMulti(src, skillNames, dests, out, errOut)
+ }
+
+ migrationSet := make(map[string]struct{}, len(migrationTargets))
+ for _, dest := range migrationTargets {
+ migrationSet[dest] = struct{}{}
+ }
+ var ordinaryTargets []string
+ for _, dest := range dests {
+ if _, migrates := migrationSet[dest]; !migrates {
+ ordinaryTargets = append(ordinaryTargets, dest)
+ }
+ }
+
+ if len(ordinaryTargets) > 0 {
+ var n, nSkipped int
+ n, nSkipped, err = skillSetupInstallMulti(src, skillNames, ordinaryTargets, out, errOut)
+ installed += n
+ skipped += nSkipped
+ if err != nil {
+ return installed, skipped, err
+ }
+ if nSkipped > 0 {
+ return installed, skipped, fmt.Errorf("multi Skill 安装不完整(skipped=%d);已保留折叠版 Event/misc,未执行迁移", nSkipped)
+ }
+ }
+
+ // The folded pair is excluded from the ordinary best-effort installer. All
+ // other selected skills (especially dingtalk-shared) must succeed before the
+ // old Event route is touched.
+ for _, dest := range migrationTargets {
+ cleanupMutualExclusion(dest, skillSetupModeMulti, out, errOut)
+ }
+ var prerequisiteNames []string
+ for _, name := range skillNames {
+ if name != multiEventSkill && name != multiMiscSkill {
+ prerequisiteNames = append(prerequisiteNames, name)
+ }
+ }
+ if len(prerequisiteNames) > 0 {
+ var n, nSkipped int
+ n, nSkipped, err = skillSetupInstallMulti(src, prerequisiteNames, migrationTargets, out, errOut)
+ installed += n
+ skipped += nSkipped
+ if err != nil {
+ return installed, skipped, err
+ }
+ if nSkipped > 0 {
+ return installed, skipped, fmt.Errorf("event Skill 迁移前置安装不完整(skipped=%d);已保留折叠版 Event/misc", nSkipped)
+ }
+ }
+
+ migrated, migrationErr := migrateEventMiscAtomically(src, migrationTargets, out, errOut)
+ installed += migrated
+ if migrationErr != nil {
+ return installed, skipped, migrationErr
+ }
+ return installed, skipped, nil
+}
+
+type eventMiscMigration struct {
+ dest string
+
+ stageRoot string
+ stagedEvent string
+ stagedMisc string
+ backupEvent string
+ backupMisc string
+
+ eventPath string
+ miscPath string
+
+ eventBackedUp bool
+ miscBackedUp bool
+ newEventEnabled bool
+ newMiscEnabled bool
+}
+
+func prepareEventMiscMigration(src, dest string) (*eventMiscMigration, error) {
+ stageRoot, err := skillSetupMkdirTemp(dest, ".dws-event-migration-")
+ if err != nil {
+ return nil, fmt.Errorf("无法在目标文件系统创建 Event Skill 迁移 staging %s: %w", dest, err)
+ }
+ migration := &eventMiscMigration{
+ dest: dest,
+ stageRoot: stageRoot,
+ stagedEvent: filepath.Join(stageRoot, "new-event"),
+ stagedMisc: filepath.Join(stageRoot, "new-misc"),
+ backupEvent: filepath.Join(stageRoot, "old-event"),
+ backupMisc: filepath.Join(stageRoot, "old-misc"),
+ eventPath: filepath.Join(dest, multiEventSkill),
+ miscPath: filepath.Join(dest, multiMiscSkill),
+ }
+ cleanupOnError := func(cause error) (*eventMiscMigration, error) {
+ if cleanupErr := skillSetupRemoveAll(stageRoot); cleanupErr != nil {
+ cause = errors.Join(cause, fmt.Errorf("清理 staging %s 失败: %w", stageRoot, cleanupErr))
+ }
+ return nil, cause
+ }
+
+ if err := skillSetupCopyDir(filepath.Join(src, multiEventSkill), migration.stagedEvent); err != nil {
+ return cleanupOnError(fmt.Errorf("预备 dingtalk-event 失败 %s: %w", dest, err))
+ }
+ if err := skillSetupCopyDir(filepath.Join(src, multiMiscSkill), migration.stagedMisc); err != nil {
+ return cleanupOnError(fmt.Errorf("预备 dingtalk-misc 失败 %s: %w", dest, err))
+ }
+ if err := validateEventMigrationSkillRoot(migration.stagedEvent); err != nil {
+ return cleanupOnError(fmt.Errorf("迁移 staging 验证失败 %s: %w", migration.stagedEvent, err))
+ }
+ if err := validateMigrationSkillRoot(migration.stagedMisc, multiMiscSkill, nil); err != nil {
+ return cleanupOnError(fmt.Errorf("迁移 staging 验证失败 %s: %w", migration.stagedMisc, err))
+ }
+ if err := validateCleanEventMiscRoot(migration.stagedMisc); err != nil {
+ return cleanupOnError(fmt.Errorf("迁移 staging 验证失败 %s: %w", migration.stagedMisc, err))
+ }
+ return migration, nil
+}
+
+func migrateEventMiscAtomically(src string, dests []string, out, errOut io.Writer) (int, error) {
+ sortedDests := append([]string(nil), dests...)
+ sort.Strings(sortedDests)
+ migrations := make([]*eventMiscMigration, 0, len(sortedDests))
+
+ // Stage every target before switching any target. This prevents a source or
+ // copy failure on a later Agent home from leaving earlier homes upgraded.
+ for _, dest := range sortedDests {
+ migration, err := prepareEventMiscMigration(src, dest)
+ if err != nil {
+ if cleanupErr := cleanupEventMiscStages(migrations, false, errOut); cleanupErr != nil {
+ err = errors.Join(err, cleanupErr)
+ }
+ return 0, err
+ }
+ migrations = append(migrations, migration)
+ }
+
+ committed := make([]*eventMiscMigration, 0, len(migrations))
+ for _, migration := range migrations {
+ if err := commitEventMiscMigration(migration); err != nil {
+ rollbackErr := rollbackEventMiscMigrations(committed)
+ if rollbackErr != nil {
+ err = errors.Join(err, fmt.Errorf("已切换目标回滚失败: %w", rollbackErr))
+ }
+ var recoveryRoots []string
+ for _, candidate := range migrations {
+ if eventMiscMigrationNeedsRecovery(candidate) {
+ recoveryRoots = append(recoveryRoots, candidate.stageRoot)
+ }
+ }
+ if len(recoveryRoots) > 0 {
+ err = errors.Join(err, fmt.Errorf("回滚不完整,已保留恢复目录(请勿删除): %s", strings.Join(recoveryRoots, ", ")))
+ }
+ if cleanupErr := cleanupEventMiscStages(migrations, true, errOut); cleanupErr != nil {
+ err = errors.Join(err, cleanupErr)
+ }
+ return 0, err
+ }
+ committed = append(committed, migration)
+ }
+
+ for _, migration := range migrations {
+ fmt.Fprintf(out, " ✓ %s\n", migration.eventPath)
+ fmt.Fprintf(out, " ✓ %s(Event 原子迁移)\n", migration.miscPath)
+ }
+ if cleanupErr := cleanupEventMiscStages(migrations, false, errOut); cleanupErr != nil {
+ fmt.Fprintf(errOut, " ⚠️ Event Skill 迁移已完成,但 staging 清理不完整: %v\n", cleanupErr)
+ }
+ return len(migrations) * 2, nil
+}
+
+func cleanupEventMiscStages(migrations []*eventMiscMigration, preserveRecovery bool, errOut io.Writer) error {
+ var cleanupErr error
+ for _, migration := range migrations {
+ if preserveRecovery && eventMiscMigrationNeedsRecovery(migration) {
+ fmt.Fprintf(errOut, " ⚠️ 已保留 Event Skill 恢复目录 %s\n", migration.stageRoot)
+ continue
+ }
+ if err := skillSetupRemoveAll(migration.stageRoot); err != nil {
+ cleanupErr = errors.Join(cleanupErr, fmt.Errorf("清理 Event Skill staging %s 失败: %w", migration.stageRoot, err))
+ }
+ }
+ return cleanupErr
+}
+
+func eventMiscMigrationNeedsRecovery(migration *eventMiscMigration) bool {
+ return migration.eventBackedUp || migration.miscBackedUp || migration.newEventEnabled || migration.newMiscEnabled
+}
+
+func commitEventMiscMigration(migration *eventMiscMigration) error {
+ eventExists, err := skillSetupPathExists(migration.eventPath)
+ if err != nil {
+ return fmt.Errorf("无法检查旧 dingtalk-event %s: %w", migration.dest, err)
+ }
+ miscExists, err := skillSetupPathExists(migration.miscPath)
+ if err != nil {
+ return fmt.Errorf("无法检查旧 dingtalk-misc %s: %w", migration.dest, err)
+ }
+ if !miscExists {
+ return fmt.Errorf("event Skill 迁移中止:折叠版 dingtalk-misc 已不存在 %s", migration.dest)
+ }
+
+ rollbackFailure := func(cause error) error {
+ if rollbackErr := rollbackEventMiscMigration(migration); rollbackErr != nil {
+ return errors.Join(cause, fmt.Errorf("回滚 Event/misc 失败 %s: %w", migration.dest, rollbackErr))
+ }
+ return cause
+ }
+ if eventExists {
+ if err := skillSetupRename(migration.eventPath, migration.backupEvent); err != nil {
+ return fmt.Errorf("备份旧 dingtalk-event 失败 %s: %w", migration.dest, err)
+ }
+ migration.eventBackedUp = true
+ }
+ if err := skillSetupRename(migration.stagedEvent, migration.eventPath); err != nil {
+ return rollbackFailure(fmt.Errorf("切换 dingtalk-event 失败 %s: %w", migration.dest, err))
+ }
+ migration.newEventEnabled = true
+ if err := skillSetupRename(migration.miscPath, migration.backupMisc); err != nil {
+ return rollbackFailure(fmt.Errorf("备份旧 dingtalk-misc 失败 %s: %w", migration.dest, err))
+ }
+ migration.miscBackedUp = true
+ if err := skillSetupRename(migration.stagedMisc, migration.miscPath); err != nil {
+ return rollbackFailure(fmt.Errorf("切换 dingtalk-misc 失败 %s: %w", migration.dest, err))
+ }
+ migration.newMiscEnabled = true
+ return nil
+}
+
+func rollbackEventMiscMigrations(migrations []*eventMiscMigration) error {
+ var rollbackErr error
+ for i := len(migrations) - 1; i >= 0; i-- {
+ if err := rollbackEventMiscMigration(migrations[i]); err != nil {
+ rollbackErr = errors.Join(rollbackErr, err)
+ }
+ }
+ return rollbackErr
+}
+
+func rollbackEventMiscMigration(migration *eventMiscMigration) error {
+ move := func(enabled *bool, from, to, label string) error {
+ if !*enabled {
+ return nil
+ }
+ if err := skillSetupRename(from, to); err != nil {
+ return fmt.Errorf("%s: %w", label, err)
+ }
+ *enabled = false
+ return nil
+ }
+
+ // Stop at the first rollback failure. In particular, do not remove the
+ // already-working standalone Event while the folded misc route has not been
+ // restored: even an incomplete rollback must leave at least one Event entry
+ // point live and preserve the remaining assets in staging for recovery.
+ steps := []struct {
+ enabled *bool
+ from string
+ to string
+ label string
+ }{
+ {&migration.newMiscEnabled, migration.miscPath, migration.stagedMisc, "移出新 dingtalk-misc"},
+ {&migration.miscBackedUp, migration.backupMisc, migration.miscPath, "恢复旧 dingtalk-misc"},
+ {&migration.newEventEnabled, migration.eventPath, migration.stagedEvent, "移出新 dingtalk-event"},
+ {&migration.eventBackedUp, migration.backupEvent, migration.eventPath, "恢复旧 dingtalk-event"},
+ }
+ for _, step := range steps {
+ if err := move(step.enabled, step.from, step.to, step.label); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func skillSetupPathExists(path string) (bool, error) {
+ _, err := skillSetupStat(path)
+ switch {
+ case err == nil:
+ return true, nil
+ case errors.Is(err, os.ErrNotExist):
+ return false, nil
+ default:
+ return false, err
+ }
+}
+
// installMultiSkillToHomes installs each subdir of src (dingtalk-*) into
// dest as a sibling skill directory. installed/skipped is counted per
// (agent-home × sub-skill) pair so the user sees granular progress.
@@ -649,6 +1218,7 @@ func installMultiSkillToHomes(src string, skillNames []string, dests []string, o
continue
}
+ sharedInstalled := false
for _, name := range skillNames {
subSrc := filepath.Join(src, name)
subDest := filepath.Join(dest, name)
@@ -664,6 +1234,12 @@ func installMultiSkillToHomes(src string, skillNames []string, dests []string, o
}
fmt.Fprintf(out, " ✓ %s\n", subDest)
installed++
+ if name == multiSharedSkill {
+ sharedInstalled = true
+ }
+ }
+ if sharedInstalled {
+ cleanupLegacyMultiSharedSkill(dest, out, errOut)
}
}
return installed, skipped, nil
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_full_coverage_test.go b/internal/app/skill_setup_full_coverage_test.go
index 16f3aed2..1b379b29 100644
--- a/internal/app/skill_setup_full_coverage_test.go
+++ b/internal/app/skill_setup_full_coverage_test.go
@@ -7,9 +7,11 @@ import (
"io/fs"
"os"
"path/filepath"
+ "strings"
"testing"
"time"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/charmbracelet/huh"
"github.com/spf13/cobra"
)
@@ -120,12 +122,112 @@ func TestCrossPlatformCoverageSkillSetupHighLevelRemainingCoverage(t *testing.T)
}
}
+func TestCrossPlatformCoverageSkillSetupMigratesLegacySharedAfterReplacement(t *testing.T) {
+ src := writeMultiSkillSource(t, []string{multiSharedSkill, "dingtalk-chat"})
+ home := filepath.Join(t.TempDir(), "skills")
+ legacyPath := filepath.Join(home, legacyMultiSharedSkill)
+ customPath := filepath.Join(home, "custom-skill")
+ for _, path := range []string{legacyPath, customPath} {
+ if err := os.MkdirAll(path, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(path, "SKILL.md"), []byte("legacy or custom\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ var out, errOut bytes.Buffer
+ installed, skipped, err := installMultiSkillToHomes(
+ src,
+ []string{multiSharedSkill, "dingtalk-chat"},
+ []string{home},
+ &out,
+ &errOut,
+ )
+ if err != nil || installed != 2 || skipped != 0 {
+ t.Fatalf("install = %d/%d, err=%v, stderr=%s", installed, skipped, err, errOut.String())
+ }
+ if _, err := os.Stat(legacyPath); !os.IsNotExist(err) {
+ t.Fatalf("legacy shared skill still exists: %v", err)
+ }
+ if _, err := os.Stat(filepath.Join(home, multiSharedSkill, "SKILL.md")); err != nil {
+ t.Fatalf("replacement shared skill missing: %v", err)
+ }
+ if _, err := os.Stat(filepath.Join(customPath, "SKILL.md")); err != nil {
+ t.Fatalf("unrelated custom skill changed: %v", err)
+ }
+ if !strings.Contains(out.String(), "已清理已退役 Skill 残留") {
+ t.Fatalf("legacy cleanup was not reported: %s", out.String())
+ }
+
+ t.Run("failed replacement preserves legacy", func(t *testing.T) {
+ missingSource := t.TempDir()
+ failureHome := filepath.Join(t.TempDir(), "skills")
+ failureLegacy := filepath.Join(failureHome, legacyMultiSharedSkill)
+ if err := os.MkdirAll(failureLegacy, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(failureLegacy, "SKILL.md"), []byte("legacy\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ var failureOut, failureErr bytes.Buffer
+ installed, skipped, err := installMultiSkillToHomes(
+ missingSource,
+ []string{multiSharedSkill},
+ []string{failureHome},
+ &failureOut,
+ &failureErr,
+ )
+ if err != nil || installed != 0 || skipped != 1 {
+ t.Fatalf("failed replacement = %d/%d, err=%v", installed, skipped, err)
+ }
+ if _, err := os.Stat(filepath.Join(failureLegacy, "SKILL.md")); err != nil {
+ t.Fatalf("failed replacement removed legacy shared skill: %v", err)
+ }
+ })
+}
+
+func TestCrossPlatformCoverageSkillSetupLegacySharedCleanupFailures(t *testing.T) {
+ fail := errors.New("legacy cleanup failure")
+
+ t.Run("missing legacy is a no-op", func(t *testing.T) {
+ var out, errOut bytes.Buffer
+ cleanupLegacyMultiSharedSkill(t.TempDir(), &out, &errOut)
+ if out.Len() != 0 || errOut.Len() != 0 {
+ t.Fatalf("missing legacy emitted output: stdout=%q stderr=%q", out.String(), errOut.String())
+ }
+ })
+
+ t.Run("stat failure is reported", func(t *testing.T) {
+ testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, fail })
+ var out, errOut bytes.Buffer
+ cleanupLegacyMultiSharedSkill("dest", &out, &errOut)
+ if out.Len() != 0 || !strings.Contains(errOut.String(), "无法检查已退役 Skill 残留") {
+ t.Fatalf("stat failure output: stdout=%q stderr=%q", out.String(), errOut.String())
+ }
+ })
+
+ t.Run("remove failure is reported", func(t *testing.T) {
+ testseam.Swap(t, &skillSetupStat, func(path string) (os.FileInfo, error) {
+ return skillSetupFileInfo{name: filepath.Base(path), mode: os.ModeDir}, nil
+ })
+ testseam.Swap(t, &skillSetupRemoveAll, func(string) error { return fail })
+ var out, errOut bytes.Buffer
+ cleanupLegacyMultiSharedSkill("dest", &out, &errOut)
+ if out.Len() != 0 || !strings.Contains(errOut.String(), "已退役 Skill 清理失败") {
+ t.Fatalf("remove failure output: stdout=%q stderr=%q", out.String(), errOut.String())
+ }
+ })
+}
+
func TestCrossPlatformCoverageSkillSetupLowLevelRemainingCoverage(t *testing.T) {
oldRunForm, oldInteractive := skillSetupRunForm, skillSetupInteractive
oldReadDir, oldStat := skillSetupReadDir, skillSetupStat
oldExecutable, oldGetwd, oldHome := skillSetupExecutable, skillSetupGetwd, skillSetupUserHomeDir
oldRemove, oldMkdir := skillSetupRemoveAll, skillSetupMkdirAll
oldCopyDir, oldWalk, oldRel := skillSetupCopyDir, skillSetupWalk, skillSetupRel
+ oldMkdirTemp, oldRename := skillSetupMkdirTemp, skillSetupRename
oldReadlink, oldOpen, oldOpenFile, oldCopy := skillSetupReadlink, skillSetupOpen, skillSetupOpenFile, skillSetupCopy
t.Cleanup(func() {
skillSetupRunForm, skillSetupInteractive = oldRunForm, oldInteractive
@@ -133,6 +235,7 @@ func TestCrossPlatformCoverageSkillSetupLowLevelRemainingCoverage(t *testing.T)
skillSetupExecutable, skillSetupGetwd, skillSetupUserHomeDir = oldExecutable, oldGetwd, oldHome
skillSetupRemoveAll, skillSetupMkdirAll = oldRemove, oldMkdir
skillSetupCopyDir, skillSetupWalk, skillSetupRel = oldCopyDir, oldWalk, oldRel
+ skillSetupMkdirTemp, skillSetupRename = oldMkdirTemp, oldRename
skillSetupReadlink, skillSetupOpen, skillSetupOpenFile, skillSetupCopy = oldReadlink, oldOpen, oldOpenFile, oldCopy
})
fail := errors.New("failure")
@@ -310,3 +413,236 @@ func TestCrossPlatformCoverageSkillSetupLowLevelRemainingCoverage(t *testing.T)
}
_ = fs.ValidPath("path")
}
+
+func TestCrossPlatformCoverageSkillSetupEventMigrationFailureBranches(t *testing.T) {
+ fail := errors.New("injected failure")
+ validSkill := func(name string) []byte {
+ return []byte("---\nname: " + name + "\ndescription: valid migration skill\n---\n\n# Skill\n")
+ }
+
+ t.Run("folded discovery rejects directory reference", func(t *testing.T) {
+ dest := t.TempDir()
+ miscRoot := filepath.Join(dest, multiMiscSkill)
+ if err := os.MkdirAll(miscRoot, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(miscRoot, "SKILL.md"), []byte("dws event\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ testseam.Swap(t, &skillSetupStat, func(path string) (os.FileInfo, error) {
+ if strings.HasSuffix(path, filepath.Join("references", "event.md")) {
+ return skillSetupFileInfo{name: "event.md", mode: os.ModeDir}, nil
+ }
+ return os.Stat(path)
+ })
+ if got := findFoldedEventMiscTargets([]string{dest}); len(got) != 0 {
+ t.Fatalf("directory event reference accepted: %#v", got)
+ }
+ })
+
+ t.Run("migration root validation failures", func(t *testing.T) {
+ if err := validateMigrationSkillRoot(filepath.Join(t.TempDir(), "missing"), multiEventSkill, nil); err == nil {
+ t.Fatal("missing SKILL.md succeeded")
+ }
+
+ root := t.TempDir()
+ if err := os.WriteFile(filepath.Join(root, "SKILL.md"), validSkill(multiEventSkill), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.MkdirAll(filepath.Join(root, "references", "directory.md"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := validateMigrationSkillRoot(root, multiEventSkill, []string{filepath.Join("references", "directory.md")}); err == nil || !strings.Contains(err.Error(), "is a directory") {
+ t.Fatalf("directory required file = %v", err)
+ }
+
+ missing := filepath.Join("references", "missing.md")
+ testseam.Swap(t, &skillSetupStat, func(path string) (os.FileInfo, error) {
+ if path == filepath.Join(root, missing) {
+ return skillSetupFileInfo{name: "missing.md"}, nil
+ }
+ return os.Stat(path)
+ })
+ if err := validateMigrationSkillRoot(root, multiEventSkill, []string{missing}); err == nil || !strings.Contains(err.Error(), "无法读取") {
+ t.Fatalf("unreadable required file = %v", err)
+ }
+ })
+
+ t.Run("frontmatter validation branches", func(t *testing.T) {
+ validWithIgnoredLine := []byte("---\nignored line\nname: dingtalk-event\ndescription: valid\n---\n\nbody\n")
+ if name, err := parseMigrationSkillFrontmatter(validWithIgnoredLine); err != nil || name != multiEventSkill {
+ t.Fatalf("ignored frontmatter line = %q, %v", name, err)
+ }
+ for name, body := range map[string][]byte{
+ "duplicate name": []byte("---\nname: one\nname: two\ndescription: valid\n---\nbody\n"),
+ "unclosed": []byte("---\nname: one\ndescription: valid\nbody\n"),
+ "missing name": []byte("---\ndescription: valid\n---\nbody\n"),
+ "missing desc": []byte("---\nname: one\n---\nbody\n"),
+ "empty body": []byte("---\nname: one\ndescription: valid\n---\n \n"),
+ } {
+ t.Run(name, func(t *testing.T) {
+ if _, err := parseMigrationSkillFrontmatter(body); err == nil {
+ t.Fatal("invalid frontmatter succeeded")
+ }
+ })
+ }
+ })
+
+ t.Run("clean misc validation branches", func(t *testing.T) {
+ if err := validateCleanEventMiscRoot(filepath.Join(t.TempDir(), "missing")); err == nil {
+ t.Fatal("missing misc root succeeded")
+ }
+
+ routed := t.TempDir()
+ if err := os.WriteFile(filepath.Join(routed, "SKILL.md"), append(validSkill(multiMiscSkill), []byte("dws event\n")...), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := validateCleanEventMiscRoot(routed); err == nil || !strings.Contains(err.Error(), "仍包含个人 Event 路由") {
+ t.Fatalf("routed misc = %v", err)
+ }
+
+ clean := t.TempDir()
+ if err := os.WriteFile(filepath.Join(clean, "SKILL.md"), validSkill(multiMiscSkill), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := validateCleanEventMiscRoot(clean); err != nil {
+ t.Fatalf("missing references should be clean: %v", err)
+ }
+
+ testseam.Swap(t, &skillSetupReadDir, func(string) ([]os.DirEntry, error) { return nil, fail })
+ if err := validateCleanEventMiscRoot(clean); !errors.Is(err, fail) {
+ t.Fatalf("read-dir failure = %v", err)
+ }
+ })
+
+ t.Run("ordinary and prerequisite install errors", func(t *testing.T) {
+ testseam.Swap(t, &skillSetupInstallMulti, func(string, []string, []string, io.Writer, io.Writer) (int, int, error) {
+ return 0, 0, fail
+ })
+ migration := filepath.Join(t.TempDir(), "migration")
+ ordinary := filepath.Join(t.TempDir(), "ordinary")
+ if _, _, err := installMultiSkillsWithEventMigration("src", []string{multiEventSkill}, []string{migration, ordinary}, []string{migration}, io.Discard, io.Discard); !errors.Is(err, fail) {
+ t.Fatalf("ordinary install failure = %v", err)
+ }
+ if _, _, err := installMultiSkillsWithEventMigration("src", []string{multiEventSkill, multiMiscSkill, multiSharedSkill}, []string{migration}, []string{migration}, io.Discard, io.Discard); !errors.Is(err, fail) {
+ t.Fatalf("prerequisite install failure = %v", err)
+ }
+ })
+
+ t.Run("preparation cleanup and staged misc validation", func(t *testing.T) {
+ src := writeMultiSkillSource(t, []string{multiEventSkill, multiMiscSkill})
+ dest := t.TempDir()
+ testseam.Swap(t, &skillSetupCopyDir, func(string, string) error { return fail })
+ cleanupFail := errors.New("cleanup failure")
+ testseam.Swap(t, &skillSetupRemoveAll, func(string) error { return cleanupFail })
+ if _, err := prepareEventMiscMigration(src, dest); err == nil || !errors.Is(err, fail) || !errors.Is(err, cleanupFail) {
+ t.Fatalf("joined preparation cleanup error = %v", err)
+ }
+ })
+
+ t.Run("invalid staged misc root", func(t *testing.T) {
+ src := writeMultiSkillSource(t, []string{multiEventSkill, multiMiscSkill})
+ if err := os.WriteFile(filepath.Join(src, multiMiscSkill, "SKILL.md"), validSkill(multiEventSkill), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := prepareEventMiscMigration(src, t.TempDir()); err == nil || !strings.Contains(err.Error(), "staging 验证失败") {
+ t.Fatalf("invalid staged misc = %v", err)
+ }
+ })
+
+ t.Run("later staging failure joins cleanup error", func(t *testing.T) {
+ src := writeMultiSkillSource(t, []string{multiEventSkill, multiMiscSkill})
+ first := filepath.Join(t.TempDir(), "a")
+ second := filepath.Join(t.TempDir(), "b")
+ if err := os.MkdirAll(first, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.MkdirAll(second, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ originalMkdirTemp := skillSetupMkdirTemp
+ calls := 0
+ testseam.Swap(t, &skillSetupMkdirTemp, func(dir, pattern string) (string, error) {
+ calls++
+ if calls == 2 {
+ return "", fail
+ }
+ return originalMkdirTemp(dir, pattern)
+ })
+ cleanupFail := errors.New("stage cleanup failure")
+ testseam.Swap(t, &skillSetupRemoveAll, func(string) error { return cleanupFail })
+ if _, err := migrateEventMiscAtomically(src, []string{second, first}, io.Discard, io.Discard); err == nil || !errors.Is(err, fail) || !errors.Is(err, cleanupFail) {
+ t.Fatalf("later preparation failure = %v", err)
+ }
+ })
+
+ t.Run("successful migration reports cleanup warning", func(t *testing.T) {
+ src := writeMultiSkillSource(t, []string{multiEventSkill, multiMiscSkill})
+ home := filepath.Join(t.TempDir(), "skills")
+ writeFoldedEventMisc(t, home)
+ cleanupFail := errors.New("final cleanup failure")
+ testseam.Swap(t, &skillSetupRemoveAll, func(string) error { return cleanupFail })
+ var stderr bytes.Buffer
+ installed, err := migrateEventMiscAtomically(src, []string{home}, io.Discard, &stderr)
+ if err != nil || installed != 2 || !strings.Contains(stderr.String(), cleanupFail.Error()) {
+ t.Fatalf("successful migration cleanup warning: installed=%d err=%v stderr=%s", installed, err, stderr.String())
+ }
+ })
+
+ t.Run("commit rollback and cleanup failures are joined", func(t *testing.T) {
+ src := writeMultiSkillSource(t, []string{multiEventSkill, multiMiscSkill})
+ root := t.TempDir()
+ first := filepath.Join(root, "a", "skills")
+ second := filepath.Join(root, "b", "skills")
+ for _, home := range []string{first, second} {
+ writeFoldedEventMisc(t, home)
+ writeOldStandaloneEvent(t, home)
+ }
+ commitFail := errors.New("second commit failure")
+ rollbackFail := errors.New("first rollback failure")
+ originalRename := skillSetupRename
+ testseam.Swap(t, &skillSetupRename, func(oldPath, newPath string) error {
+ if filepath.Base(oldPath) == "new-misc" && newPath == filepath.Join(second, multiMiscSkill) {
+ return commitFail
+ }
+ if filepath.Base(oldPath) == "old-misc" && newPath == filepath.Join(first, multiMiscSkill) {
+ return rollbackFail
+ }
+ return originalRename(oldPath, newPath)
+ })
+ cleanupFail := errors.New("post-rollback cleanup failure")
+ testseam.Swap(t, &skillSetupRemoveAll, func(string) error { return cleanupFail })
+ if _, err := migrateEventMiscAtomically(src, []string{second, first}, io.Discard, io.Discard); err == nil || !errors.Is(err, commitFail) || !errors.Is(err, rollbackFail) || !errors.Is(err, cleanupFail) {
+ t.Fatalf("joined commit/rollback/cleanup error = %v", err)
+ }
+ })
+
+ t.Run("commit preflight and rollback aggregation", func(t *testing.T) {
+ migration := &eventMiscMigration{dest: "dest", eventPath: "event", miscPath: "misc"}
+ testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, fail })
+ if err := commitEventMiscMigration(migration); !errors.Is(err, fail) {
+ t.Fatalf("event stat failure = %v", err)
+ }
+
+ testseam.Swap(t, &skillSetupStat, func(path string) (os.FileInfo, error) {
+ if path == migration.eventPath {
+ return skillSetupFileInfo{name: "event", mode: os.ModeDir}, nil
+ }
+ return nil, fail
+ })
+ if err := commitEventMiscMigration(migration); !errors.Is(err, fail) {
+ t.Fatalf("misc stat failure = %v", err)
+ }
+
+ testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist })
+ if err := commitEventMiscMigration(migration); err == nil || !strings.Contains(err.Error(), "已不存在") {
+ t.Fatalf("missing misc = %v", err)
+ }
+
+ migration.newMiscEnabled = true
+ testseam.Swap(t, &skillSetupRename, func(string, string) error { return fail })
+ if err := rollbackEventMiscMigrations([]*eventMiscMigration{migration}); !errors.Is(err, fail) {
+ t.Fatalf("rollback aggregation = %v", err)
+ }
+ })
+}
diff --git a/internal/app/skill_setup_test.go b/internal/app/skill_setup_test.go
index aa8a6ed8..eadcdd97 100644
--- a/internal/app/skill_setup_test.go
+++ b/internal/app/skill_setup_test.go
@@ -2,6 +2,9 @@ package app
import (
"bytes"
+ "errors"
+ "fmt"
+ "io"
"os"
"path/filepath"
"strings"
@@ -184,12 +187,20 @@ func writeMultiSkillSource(t *testing.T, names []string) string {
if err := os.MkdirAll(filepath.Join(sub, "references"), 0o755); err != nil {
t.Fatal(err)
}
- if err := os.WriteFile(filepath.Join(sub, "SKILL.md"), []byte("# "+n), 0o644); err != nil {
+ skillBody := "---\nname: " + n + "\ndescription: test skill\n---\n\n# " + n + "\n"
+ if err := os.WriteFile(filepath.Join(sub, "SKILL.md"), []byte(skillBody), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(sub, "references", n+".md"), []byte("ref "+n), 0o644); err != nil {
t.Fatal(err)
}
+ if n == multiEventSkill {
+ for _, ref := range eventMigrationRequiredReferences {
+ if err := os.WriteFile(filepath.Join(sub, "references", ref), []byte("ref "+ref+"\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+ }
}
return root
}
@@ -549,3 +560,787 @@ 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 writeOldStandaloneEvent(t *testing.T, agentHome string) {
+ t.Helper()
+ eventRoot := filepath.Join(agentHome, multiEventSkill)
+ if err := os.MkdirAll(eventRoot, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(eventRoot, "SKILL.md"), []byte("old standalone event\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func assertOldEventMiscPair(t *testing.T, agentHome string) {
+ t.Helper()
+ eventBody, err := os.ReadFile(filepath.Join(agentHome, multiEventSkill, "SKILL.md"))
+ if err != nil || string(eventBody) != "old standalone event\n" {
+ t.Fatalf("old standalone event was not restored: body=%q err=%v", eventBody, err)
+ }
+ miscBody, err := os.ReadFile(filepath.Join(agentHome, multiMiscSkill, "SKILL.md"))
+ if err != nil || !strings.Contains(string(miscBody), "dws event") {
+ t.Fatalf("folded misc was not restored: body=%q err=%v", miscBody, err)
+ }
+ if _, err := os.Stat(filepath.Join(agentHome, multiMiscSkill, "references", "event.md")); err != nil {
+ t.Fatalf("folded Event reference was not restored: %v", err)
+ }
+}
+
+func assertNoEventMigrationStages(t *testing.T, agentHome string) {
+ t.Helper()
+ entries, err := os.ReadDir(agentHome)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, entry := range entries {
+ if strings.HasPrefix(entry.Name(), ".dws-event-migration-") {
+ t.Fatalf("unexpected leftover Event migration stage %s", filepath.Join(agentHome, entry.Name()))
+ }
+ }
+}
+
+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 TestSkillSetupEventMigrationAcceptsShippedMultiBundle(t *testing.T) {
+ wd, err := os.Getwd()
+ if err != nil {
+ t.Fatal(err)
+ }
+ src := filepath.Clean(filepath.Join(wd, "..", "..", "skills", "multi"))
+ if err := validateEventMiscMigrationSource(src); err != nil {
+ t.Fatalf("shipped multi bundle is not a valid Event migration source: %v", err)
+ }
+}
+
+func TestSkillSetupEventMigrationRejectsInvalidSkillBundlesBeforeWrites(t *testing.T) {
+ tests := []struct {
+ name string
+ mutate func(t *testing.T, src string)
+ }{
+ {
+ name: "empty event root",
+ mutate: func(t *testing.T, src string) {
+ t.Helper()
+ if err := os.WriteFile(filepath.Join(src, multiEventSkill, "SKILL.md"), nil, 0o644); err != nil {
+ t.Fatal(err)
+ }
+ },
+ },
+ {
+ name: "wrong event name",
+ mutate: func(t *testing.T, src string) {
+ t.Helper()
+ body := "---\nname: dingtalk-chat\ndescription: wrong skill\n---\n\n# Wrong\n"
+ if err := os.WriteFile(filepath.Join(src, multiEventSkill, "SKILL.md"), []byte(body), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ },
+ },
+ {
+ name: "missing event reference",
+ mutate: func(t *testing.T, src string) {
+ t.Helper()
+ if err := os.Remove(filepath.Join(src, multiEventSkill, "references", "event-oa.md")); err != nil {
+ t.Fatal(err)
+ }
+ },
+ },
+ {
+ name: "empty event reference",
+ mutate: func(t *testing.T, src string) {
+ t.Helper()
+ if err := os.WriteFile(filepath.Join(src, multiEventSkill, "references", "event-im.md"), nil, 0o644); err != nil {
+ t.Fatal(err)
+ }
+ },
+ },
+ {
+ name: "wrong misc name",
+ mutate: func(t *testing.T, src string) {
+ t.Helper()
+ body := "---\nname: dingtalk-event\ndescription: wrong skill\n---\n\n# Wrong\n"
+ if err := os.WriteFile(filepath.Join(src, multiMiscSkill, "SKILL.md"), []byte(body), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ },
+ },
+ }
+
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
+ test.mutate(t, src)
+ home := filepath.Join(t.TempDir(), "skills")
+ writeFoldedEventMisc(t, home)
+ writeOldStandaloneEvent(t, home)
+
+ stdout, _, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--yes")
+ if err == nil || !strings.Contains(err.Error(), "迁移源无效") {
+ t.Fatalf("invalid migration source was accepted: %v", err)
+ }
+ if strings.Contains(stdout, "Skill 安装完成") {
+ t.Fatalf("invalid migration source reported success: %s", stdout)
+ }
+ assertOldEventMiscPair(t, home)
+ if _, statErr := os.Stat(filepath.Join(home, multiSharedSkill)); !os.IsNotExist(statErr) {
+ t.Fatalf("invalid source wrote shared skill: %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(), "已保留折叠版 Event/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 TestSkillSetupFreshTargetFailureDoesNotTouchFoldedPair(t *testing.T) {
+ src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
+ freshHome := filepath.Join(t.TempDir(), "fresh", "skills")
+ foldedHome := filepath.Join(t.TempDir(), "folded", "skills")
+ writeFoldedEventMisc(t, foldedHome)
+ writeOldStandaloneEvent(t, foldedHome)
+
+ 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("folded target prerequisites ran after fresh target failure")
+ }
+ return 1, 1, nil
+ }
+
+ stdout, _, err := executeMultiSkillSetupTest(
+ t,
+ src,
+ []string{foldedHome, freshHome},
+ "--skill", "event",
+ "--yes",
+ )
+ if err == nil || !strings.Contains(err.Error(), "已保留折叠版 Event/misc") {
+ t.Fatalf("fresh target failure did not block migration: %v", err)
+ }
+ if strings.Contains(stdout, "Skill 安装完成") {
+ t.Fatalf("partial mixed-target install reported success: %s", stdout)
+ }
+ assertOldEventMiscPair(t, foldedHome)
+ assertNoEventMigrationStages(t, foldedHome)
+}
+
+func TestSkillSetupUnrelatedSelectiveInstallLeavesFoldedPairUntouched(t *testing.T) {
+ src := writeMultiSkillSource(t, []string{
+ multiEventSkill, multiSharedSkill, multiMiscSkill, "dingtalk-doc",
+ })
+ home := filepath.Join(t.TempDir(), "skills")
+ writeFoldedEventMisc(t, home)
+ writeOldStandaloneEvent(t, home)
+
+ stdout, stderr, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "doc", "--yes")
+ if err != nil {
+ t.Fatalf("unrelated selective install failed: %v\nstdout=%s\nstderr=%s", err, stdout, stderr)
+ }
+ if strings.Contains(stdout, "Event Skill 迁移") || strings.Contains(stdout, "Event 原子迁移") {
+ t.Fatalf("unrelated selective install planned Event migration: %s", stdout)
+ }
+ if _, err := os.Stat(filepath.Join(home, "dingtalk-doc", "SKILL.md")); err != nil {
+ t.Fatalf("selected doc was not installed: %v", err)
+ }
+ assertOldEventMiscPair(t, home)
+ assertNoEventMigrationStages(t, home)
+}
+
+func TestSkillSetupSelectiveEventAtomicStageFailurePreservesFoldedPair(t *testing.T) {
+ src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
+ home := filepath.Join(t.TempDir(), "skills")
+ writeFoldedEventMisc(t, home)
+
+ originalCopyDir := skillSetupCopyDir
+ t.Cleanup(func() { skillSetupCopyDir = originalCopyDir })
+ skillSetupCopyDir = func(src, dest string) error {
+ if strings.HasSuffix(dest, "new-misc") {
+ return errors.New("injected stage failure")
+ }
+ return originalCopyDir(src, dest)
+ }
+
+ stdout, _, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--yes")
+ if err == nil || !strings.Contains(err.Error(), "预备 dingtalk-misc 失败") {
+ t.Fatalf("expected atomic staging error, got %v", err)
+ }
+ if strings.Contains(stdout, "Skill 安装完成") {
+ t.Fatalf("partial atomic 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("stage failure changed folded misc: body=%q err=%v", body, readErr)
+ }
+ if _, statErr := os.Stat(filepath.Join(home, multiEventSkill)); !os.IsNotExist(statErr) {
+ t.Fatalf("stage failure installed standalone event, stat err=%v", statErr)
+ }
+}
+
+func TestSkillSetupEventMigrationPreparationFailuresPreserveFoldedPair(t *testing.T) {
+ t.Run("same-filesystem staging creation", func(t *testing.T) {
+ src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
+ home := filepath.Join(t.TempDir(), "skills")
+ writeFoldedEventMisc(t, home)
+ writeOldStandaloneEvent(t, home)
+
+ originalMkdirTemp := skillSetupMkdirTemp
+ t.Cleanup(func() { skillSetupMkdirTemp = originalMkdirTemp })
+ skillSetupMkdirTemp = func(dir, pattern string) (string, error) {
+ if dir != home {
+ t.Fatalf("staging dir = %s, want target filesystem root %s", dir, home)
+ }
+ return "", errors.New("injected mkdir-temp failure")
+ }
+
+ _, _, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--yes")
+ if err == nil || !strings.Contains(err.Error(), "injected mkdir-temp failure") {
+ t.Fatalf("staging creation failure was not returned: %v", err)
+ }
+ assertOldEventMiscPair(t, home)
+ assertNoEventMigrationStages(t, home)
+ })
+
+ t.Run("event staging copy", func(t *testing.T) {
+ src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
+ home := filepath.Join(t.TempDir(), "skills")
+ writeFoldedEventMisc(t, home)
+ writeOldStandaloneEvent(t, home)
+
+ originalCopyDir := skillSetupCopyDir
+ t.Cleanup(func() { skillSetupCopyDir = originalCopyDir })
+ skillSetupCopyDir = func(src, dest string) error {
+ if strings.HasSuffix(dest, "new-event") {
+ return errors.New("injected event copy failure")
+ }
+ return originalCopyDir(src, dest)
+ }
+
+ _, _, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--yes")
+ if err == nil || !strings.Contains(err.Error(), "injected event copy failure") {
+ t.Fatalf("event staging failure was not returned: %v", err)
+ }
+ assertOldEventMiscPair(t, home)
+ assertNoEventMigrationStages(t, home)
+ })
+}
+
+func TestSkillSetupSelectiveEventRejectsCorruptStagedMisc(t *testing.T) {
+ src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
+ home := filepath.Join(t.TempDir(), "skills")
+ writeFoldedEventMisc(t, home)
+
+ originalCopyDir := skillSetupCopyDir
+ t.Cleanup(func() { skillSetupCopyDir = originalCopyDir })
+ skillSetupCopyDir = func(src, dest string) error {
+ if err := originalCopyDir(src, dest); err != nil {
+ return err
+ }
+ if strings.HasSuffix(dest, "new-misc") {
+ return os.WriteFile(filepath.Join(dest, "references", "event-partial.md"), []byte("corrupt\n"), 0o644)
+ }
+ return nil
+ }
+
+ _, _, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--yes")
+ if err == nil || !strings.Contains(err.Error(), "staging 验证失败") {
+ t.Fatalf("corrupt staged misc was accepted: %v", err)
+ }
+ miscBody, readErr := os.ReadFile(filepath.Join(home, multiMiscSkill, "SKILL.md"))
+ if readErr != nil || !strings.Contains(string(miscBody), "dws event") {
+ t.Fatalf("staging validation failure changed folded misc: body=%q err=%v", miscBody, readErr)
+ }
+ if _, err := os.Stat(filepath.Join(home, multiEventSkill)); !os.IsNotExist(err) {
+ t.Fatalf("staging validation failure installed event: %v", err)
+ }
+ assertNoEventMigrationStages(t, home)
+}
+
+func TestSkillSetupSelectiveEventRejectsIncompleteStagedEvent(t *testing.T) {
+ src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
+ home := filepath.Join(t.TempDir(), "skills")
+ writeFoldedEventMisc(t, home)
+ writeOldStandaloneEvent(t, home)
+
+ originalCopyDir := skillSetupCopyDir
+ t.Cleanup(func() { skillSetupCopyDir = originalCopyDir })
+ skillSetupCopyDir = func(src, dest string) error {
+ if err := originalCopyDir(src, dest); err != nil {
+ return err
+ }
+ if strings.HasSuffix(dest, "new-event") {
+ return os.Remove(filepath.Join(dest, "references", "event-oa.md"))
+ }
+ return nil
+ }
+
+ _, _, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--yes")
+ if err == nil || !strings.Contains(err.Error(), "staging 验证失败") {
+ t.Fatalf("incomplete staged Event was accepted: %v", err)
+ }
+ assertOldEventMiscPair(t, home)
+ assertNoEventMigrationStages(t, home)
+}
+
+func TestSkillSetupFullEventMigrationIsAtomicAndPreservesSiblings(t *testing.T) {
+ src := writeMultiSkillSource(t, []string{
+ multiEventSkill, multiSharedSkill, multiMiscSkill, "dingtalk-doc",
+ })
+ home := filepath.Join(t.TempDir(), "skills")
+ writeFoldedEventMisc(t, home)
+ writeOldStandaloneEvent(t, home)
+ sibling := filepath.Join(home, "dingtalk-private-sibling")
+ if err := os.MkdirAll(sibling, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(sibling, "SKILL.md"), []byte("keep\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ stdout, stderr, err := executeMultiSkillSetupTest(t, src, []string{home}, "--yes")
+ if err != nil {
+ t.Fatalf("full setup migration failed: %v\nstdout=%s\nstderr=%s", err, stdout, stderr)
+ }
+ if !strings.Contains(stdout, "Event 原子迁移") || !strings.Contains(stdout, "Skill 安装完成") {
+ t.Fatalf("full setup did not report atomic migration success: %s", stdout)
+ }
+ for _, name := range []string{multiEventSkill, multiMiscSkill, multiSharedSkill, "dingtalk-doc"} {
+ if _, err := os.Stat(filepath.Join(home, name, "SKILL.md")); err != nil {
+ t.Fatalf("full setup missing %s: %v", name, err)
+ }
+ }
+ if _, err := os.Stat(filepath.Join(home, multiMiscSkill, "references", "event.md")); !os.IsNotExist(err) {
+ t.Fatalf("full setup retained folded Event reference: %v", err)
+ }
+ body, err := os.ReadFile(filepath.Join(sibling, "SKILL.md"))
+ if err != nil || string(body) != "keep\n" {
+ t.Fatalf("full setup changed unrelated sibling: body=%q err=%v", body, err)
+ }
+ assertNoEventMigrationStages(t, home)
+}
+
+func TestSkillSetupEventMigrationWithoutSharedStillCleansMonoLeftover(t *testing.T) {
+ src := writeMultiSkillSource(t, []string{multiEventSkill, multiMiscSkill})
+ home := filepath.Join(t.TempDir(), "skills")
+ writeFoldedEventMisc(t, home)
+ monoLeftover := filepath.Join(home, "dws")
+ if err := os.MkdirAll(monoLeftover, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(monoLeftover, "SKILL.md"), []byte("old mono\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ stdout, stderr, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--yes")
+ if err != nil {
+ t.Fatalf("migration without shared failed: %v\nstdout=%s\nstderr=%s", err, stdout, stderr)
+ }
+ if _, err := os.Stat(monoLeftover); !os.IsNotExist(err) {
+ t.Fatalf("migration without prerequisites retained mono leftover: %v", err)
+ }
+ for _, name := range []string{multiEventSkill, multiMiscSkill} {
+ if _, err := os.Stat(filepath.Join(home, name, "SKILL.md")); err != nil {
+ t.Fatalf("migration without shared missing %s: %v", name, err)
+ }
+ }
+ assertNoEventMigrationStages(t, home)
+}
+
+func TestSkillSetupFoldedEventMigrationSelectionPreflight(t *testing.T) {
+ src := writeMultiSkillSource(t, []string{
+ multiEventSkill, multiSharedSkill, multiMiscSkill, "dingtalk-doc",
+ })
+ tests := []struct {
+ name string
+ args []string
+ want string
+ }{
+ {name: "misc only", args: []string{"--skill", "misc", "--yes"}, want: "不能只覆盖 dingtalk-misc"},
+ {name: "explicitly excludes event", args: []string{"--exclude", "event", "--yes"}, want: "不能显式 --exclude event"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ home := filepath.Join(t.TempDir(), "skills")
+ writeFoldedEventMisc(t, home)
+ _, _, err := executeMultiSkillSetupTest(t, src, []string{home}, tt.args...)
+ if err == nil || !strings.Contains(err.Error(), tt.want) {
+ t.Fatalf("preflight error = %v, want %q", err, tt.want)
+ }
+ if _, err := os.Stat(filepath.Join(home, multiSharedSkill)); !os.IsNotExist(err) {
+ t.Fatalf("preflight failure wrote shared skill: %v", err)
+ }
+ if _, err := os.Stat(filepath.Join(home, multiEventSkill)); !os.IsNotExist(err) {
+ t.Fatalf("preflight failure wrote event skill: %v", err)
+ }
+ miscBody, readErr := os.ReadFile(filepath.Join(home, multiMiscSkill, "SKILL.md"))
+ if readErr != nil || !strings.Contains(string(miscBody), "dws event") {
+ t.Fatalf("preflight failure changed folded misc: body=%q err=%v", miscBody, readErr)
+ }
+ })
+ }
+}
+
+func TestSkillSetupEventMigrationRejectsEveryFoldedReferenceVariant(t *testing.T) {
+ for _, filename := range []string{"event.md", "event-im.md", "event-oa.md", "EVENT-legacy.MD"} {
+ t.Run(filename, func(t *testing.T) {
+ src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
+ if err := os.WriteFile(filepath.Join(src, multiMiscSkill, "references", filename), []byte("stale\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ 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(), "仍存在折叠 Event 参考页") {
+ t.Fatalf("source with %s was accepted: %v", filename, err)
+ }
+ if _, err := os.Stat(filepath.Join(home, multiSharedSkill)); !os.IsNotExist(err) {
+ t.Fatalf("invalid source wrote shared skill: %v", err)
+ }
+ })
+ }
+}
+
+func TestSkillSetupEventMigrationRenameFailuresRollbackPair(t *testing.T) {
+ for failAt := 1; failAt <= 4; failAt++ {
+ t.Run(fmt.Sprintf("rename_%d", failAt), func(t *testing.T) {
+ src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
+ home := filepath.Join(t.TempDir(), "skills")
+ writeFoldedEventMisc(t, home)
+ writeOldStandaloneEvent(t, home)
+
+ originalRename := skillSetupRename
+ t.Cleanup(func() { skillSetupRename = originalRename })
+ renameCalls := 0
+ skillSetupRename = func(oldPath, newPath string) error {
+ renameCalls++
+ if renameCalls == failAt {
+ return errors.New("injected rename failure")
+ }
+ return originalRename(oldPath, newPath)
+ }
+
+ stdout, _, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--yes")
+ if err == nil || !strings.Contains(err.Error(), "injected rename failure") {
+ t.Fatalf("rename failure %d was not returned: %v", failAt, err)
+ }
+ if strings.Contains(stdout, "Skill 安装完成") {
+ t.Fatalf("rename failure %d reported success: %s", failAt, stdout)
+ }
+ assertOldEventMiscPair(t, home)
+ assertNoEventMigrationStages(t, home)
+ })
+ }
+}
+
+func TestSkillSetupEventMigrationFailureRollsBackEarlierTargets(t *testing.T) {
+ src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
+ root := t.TempDir()
+ firstHome := filepath.Join(root, "a", "skills")
+ secondHome := filepath.Join(root, "b", "skills")
+ for _, home := range []string{firstHome, secondHome} {
+ writeFoldedEventMisc(t, home)
+ writeOldStandaloneEvent(t, home)
+ }
+
+ originalRename := skillSetupRename
+ t.Cleanup(func() { skillSetupRename = originalRename })
+ failed := false
+ skillSetupRename = func(oldPath, newPath string) error {
+ if !failed && oldPath == filepath.Join(secondHome, multiMiscSkill) {
+ failed = true
+ return errors.New("second target failure")
+ }
+ return originalRename(oldPath, newPath)
+ }
+
+ _, _, err := executeMultiSkillSetupTest(t, src, []string{secondHome, firstHome}, "--skill", "event", "--yes")
+ if err == nil || !strings.Contains(err.Error(), "second target failure") {
+ t.Fatalf("second target failure was not returned: %v", err)
+ }
+ for _, home := range []string{firstHome, secondHome} {
+ assertOldEventMiscPair(t, home)
+ assertNoEventMigrationStages(t, home)
+ }
+}
+
+func TestSkillSetupEventMigrationRollbackFailurePreservesRecoveryDirectory(t *testing.T) {
+ src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
+ home := filepath.Join(t.TempDir(), "skills")
+ writeFoldedEventMisc(t, home)
+ writeOldStandaloneEvent(t, home)
+
+ originalRename := skillSetupRename
+ t.Cleanup(func() { skillSetupRename = originalRename })
+ skillSetupRename = func(oldPath, newPath string) error {
+ if strings.HasSuffix(oldPath, filepath.Join("new-misc")) && newPath == filepath.Join(home, multiMiscSkill) {
+ return errors.New("commit failure")
+ }
+ if strings.HasSuffix(oldPath, filepath.Join("old-misc")) && newPath == filepath.Join(home, multiMiscSkill) {
+ return errors.New("rollback restore failure")
+ }
+ return originalRename(oldPath, newPath)
+ }
+
+ _, stderr, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--yes")
+ if err == nil || !strings.Contains(err.Error(), "回滚不完整") || !strings.Contains(err.Error(), "恢复目录") {
+ t.Fatalf("rollback failure did not expose recovery directory: %v", err)
+ }
+ entries, readErr := os.ReadDir(home)
+ if readErr != nil {
+ t.Fatal(readErr)
+ }
+ var recoveryRoot string
+ for _, entry := range entries {
+ if strings.HasPrefix(entry.Name(), ".dws-event-migration-") {
+ recoveryRoot = filepath.Join(home, entry.Name())
+ break
+ }
+ }
+ if recoveryRoot == "" {
+ t.Fatal("rollback failure deleted the only recovery directory")
+ }
+ if !strings.Contains(err.Error(), recoveryRoot) || !strings.Contains(stderr, recoveryRoot) {
+ t.Fatalf("recovery directory was not reported: err=%v stderr=%s", err, stderr)
+ }
+ if _, err := os.Stat(filepath.Join(recoveryRoot, "old-misc", "SKILL.md")); err != nil {
+ t.Fatalf("old folded misc backup is missing from recovery directory: %v", err)
+ }
+ if _, err := os.Stat(filepath.Join(recoveryRoot, "old-event", "SKILL.md")); err != nil {
+ t.Fatalf("old standalone Event backup is missing from recovery directory: %v", err)
+ }
+ if _, err := os.Stat(filepath.Join(home, multiEventSkill, "SKILL.md")); err != nil {
+ t.Fatalf("rollback failure removed the live standalone Event entry: %v", err)
+ }
+}
+
+func TestSkillSetupEventMigrationRollbackFailureKeepsNewEventWithoutOldStandalone(t *testing.T) {
+ src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
+ home := filepath.Join(t.TempDir(), "skills")
+ writeFoldedEventMisc(t, home)
+
+ originalRename := skillSetupRename
+ t.Cleanup(func() { skillSetupRename = originalRename })
+ skillSetupRename = func(oldPath, newPath string) error {
+ if strings.HasSuffix(oldPath, filepath.Join("new-misc")) && newPath == filepath.Join(home, multiMiscSkill) {
+ return errors.New("commit failure")
+ }
+ if strings.HasSuffix(oldPath, filepath.Join("old-misc")) && newPath == filepath.Join(home, multiMiscSkill) {
+ return errors.New("rollback restore failure")
+ }
+ return originalRename(oldPath, newPath)
+ }
+
+ _, _, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--yes")
+ if err == nil || !strings.Contains(err.Error(), "回滚不完整") {
+ t.Fatalf("rollback failure was not returned: %v", err)
+ }
+ if _, statErr := os.Stat(filepath.Join(home, multiEventSkill, "SKILL.md")); statErr != nil {
+ t.Fatalf("rollback failure removed the only live Event entry: %v", statErr)
+ }
+ entries, readErr := os.ReadDir(home)
+ if readErr != nil {
+ t.Fatal(readErr)
+ }
+ for _, entry := range entries {
+ if !strings.HasPrefix(entry.Name(), ".dws-event-migration-") {
+ continue
+ }
+ if _, statErr := os.Stat(filepath.Join(home, entry.Name(), "old-misc", "SKILL.md")); statErr != nil {
+ t.Fatalf("rollback failure lost the folded misc recovery copy: %v", statErr)
+ }
+ return
+ }
+ t.Fatal("rollback failure did not preserve a recovery directory")
+}
diff --git a/internal/event/personal/client_test.go b/internal/event/personal/client_test.go
index ae13dab1..e7ffe3af 100644
--- a/internal/event/personal/client_test.go
+++ b/internal/event/personal/client_test.go
@@ -128,6 +128,12 @@ func TestClientCreateRuleBasedSubscriptionsUsesDocumentedRuleParam(t *testing.T)
{"receive_user/openDingtalkId", EventFromUser, RuleOptions{OpenDingTalkID: "open-user-1"}, map[string]any{"targetUid": "open-user-1", "targetUidType": "openDingtalkId"}},
{"receive_o2o_all", EventAllSingleChat, RuleOptions{}, map[string]any{}},
{"receive_group_all", EventAllGroupChat, RuleOptions{}, map[string]any{}},
+ {"oa_approval_task_created", EventOAApprovalTaskCreated, RuleOptions{}, map[string]any{}},
+ {"oa_approval_task_finished", EventOAApprovalTaskFinished, RuleOptions{}, map[string]any{}},
+ {"oa_approval_task_redirected", EventOAApprovalTaskRedirected, RuleOptions{}, map[string]any{}},
+ {"oa_approval_instance_started", EventOAApprovalInstanceStarted, RuleOptions{}, map[string]any{}},
+ {"oa_approval_instance_terminated", EventOAApprovalInstanceTerminated, RuleOptions{}, map[string]any{}},
+ {"oa_approval_instance_finished", EventOAApprovalInstanceFinished, RuleOptions{}, map[string]any{}},
{"read_group", EventReadGroup, RuleOptions{GroupID: "cid-1"}, map[string]any{"openConversationId": "cid-1"}},
{"recall_group", EventRecallGroup, RuleOptions{GroupID: "cid-1"}, map[string]any{"openConversationId": "cid-1"}},
{"reaction_group", EventReactionGroup, RuleOptions{GroupID: "cid-1"}, map[string]any{"openConversationId": "cid-1"}},
diff --git a/internal/event/personal/output.go b/internal/event/personal/output.go
index 7b4feeb5..00d81a01 100644
--- a/internal/event/personal/output.go
+++ b/internal/event/personal/output.go
@@ -121,6 +121,94 @@ type GroupLifecycleEventOutput struct {
Payload map[string]any `json:"payload" description:"群生命周期事件业务数据,字段以服务端实际推送为准" additional_properties:"true"`
}
+type OAApprovalTaskCreatedOutput struct {
+ Type string `json:"type" description:"事件类型,固定为当前 event_key"`
+ EventID string `json:"event_id" description:"事件 ID,可用于去重"`
+ Timestamp int64 `json:"timestamp" description:"事件发生时间戳" format:"timestamp_ms"`
+ SubscribeID string `json:"subscribe_id" description:"订阅 ID"`
+ ProcessInstanceID string `json:"process_instance_id" description:"审批实例 ID"`
+ ProcessCode string `json:"process_code" description:"审批流程模板编码"`
+ TaskID string `json:"task_id" description:"审批任务 ID"`
+ Title string `json:"title" description:"审批标题"`
+ Status string `json:"status" description:"审批任务状态"`
+ CreateTime int64 `json:"create_time" description:"审批任务创建时间" format:"timestamp_ms"`
+ EventTime int64 `json:"event_time" description:"审批任务事件业务时间" format:"timestamp_ms"`
+}
+
+type OAApprovalTaskFinishedOutput struct {
+ Type string `json:"type" description:"事件类型,固定为当前 event_key"`
+ EventID string `json:"event_id" description:"事件 ID,可用于去重"`
+ Timestamp int64 `json:"timestamp" description:"事件发生时间戳" format:"timestamp_ms"`
+ SubscribeID string `json:"subscribe_id" description:"订阅 ID"`
+ ProcessInstanceID string `json:"process_instance_id" description:"审批实例 ID"`
+ ProcessCode string `json:"process_code" description:"审批流程模板编码"`
+ TaskID string `json:"task_id" description:"审批任务 ID"`
+ Title string `json:"title" description:"审批标题"`
+ Status string `json:"status" description:"审批任务状态"`
+ Result string `json:"result" description:"审批任务处理结果,值以服务端实际推送为准"`
+ CreateTime int64 `json:"create_time" description:"审批任务创建时间" format:"timestamp_ms"`
+ FinishTime int64 `json:"finish_time" description:"审批任务完成时间" format:"timestamp_ms"`
+ EventTime int64 `json:"event_time" description:"审批任务事件业务时间" format:"timestamp_ms"`
+}
+
+type OAApprovalTaskRedirectedOutput struct {
+ Type string `json:"type" description:"事件类型,固定为当前 event_key"`
+ EventID string `json:"event_id" description:"事件 ID,可用于去重"`
+ Timestamp int64 `json:"timestamp" description:"事件发生时间戳" format:"timestamp_ms"`
+ SubscribeID string `json:"subscribe_id" description:"订阅 ID"`
+ ProcessInstanceID string `json:"process_instance_id" description:"审批实例 ID"`
+ ProcessCode string `json:"process_code" description:"审批流程模板编码"`
+ TaskID string `json:"task_id" description:"原审批任务 ID"`
+ Title string `json:"title" description:"审批标题"`
+ Status string `json:"status" description:"原审批任务状态"`
+ Result string `json:"result" description:"审批任务转交结果,值以服务端实际推送为准"`
+ CreateTime int64 `json:"create_time" description:"原审批任务创建时间" format:"timestamp_ms"`
+ FinishTime int64 `json:"finish_time" description:"原审批任务转交完成时间" format:"timestamp_ms"`
+ EventTime int64 `json:"event_time" description:"审批任务转交事件业务时间" format:"timestamp_ms"`
+}
+
+type OAApprovalInstanceStartedOutput struct {
+ Type string `json:"type" description:"事件类型,固定为当前 event_key"`
+ EventID string `json:"event_id" description:"事件 ID,可用于去重"`
+ Timestamp int64 `json:"timestamp" description:"事件发生时间戳" format:"timestamp_ms"`
+ SubscribeID string `json:"subscribe_id" description:"订阅 ID"`
+ ProcessInstanceID string `json:"process_instance_id" description:"审批实例 ID"`
+ ProcessCode string `json:"process_code" description:"审批流程模板编码"`
+ Title string `json:"title" description:"审批标题"`
+ Status string `json:"status" description:"审批实例状态"`
+ CreateTime int64 `json:"create_time" description:"审批实例创建时间" format:"timestamp_ms"`
+ EventTime int64 `json:"event_time" description:"审批实例事件业务时间" format:"timestamp_ms"`
+}
+
+type OAApprovalInstanceTerminatedOutput struct {
+ Type string `json:"type" description:"事件类型,固定为当前 event_key"`
+ EventID string `json:"event_id" description:"事件 ID,可用于去重"`
+ Timestamp int64 `json:"timestamp" description:"事件发生时间戳" format:"timestamp_ms"`
+ SubscribeID string `json:"subscribe_id" description:"订阅 ID"`
+ ProcessInstanceID string `json:"process_instance_id" description:"审批实例 ID"`
+ ProcessCode string `json:"process_code" description:"审批流程模板编码"`
+ Title string `json:"title" description:"审批标题"`
+ Status string `json:"status" description:"审批实例状态"`
+ CreateTime int64 `json:"create_time" description:"审批实例创建时间" format:"timestamp_ms"`
+ FinishTime int64 `json:"finish_time" description:"审批实例终止时间" format:"timestamp_ms"`
+ EventTime int64 `json:"event_time" description:"审批实例终止事件业务时间" format:"timestamp_ms"`
+}
+
+type OAApprovalInstanceFinishedOutput struct {
+ Type string `json:"type" description:"事件类型,固定为当前 event_key"`
+ EventID string `json:"event_id" description:"事件 ID,可用于去重"`
+ Timestamp int64 `json:"timestamp" description:"事件发生时间戳" format:"timestamp_ms"`
+ SubscribeID string `json:"subscribe_id" description:"订阅 ID"`
+ ProcessInstanceID string `json:"process_instance_id" description:"审批实例 ID"`
+ ProcessCode string `json:"process_code" description:"审批流程模板编码"`
+ Title string `json:"title" description:"审批标题"`
+ Status string `json:"status" description:"审批实例状态"`
+ Result string `json:"result" description:"审批实例处理结果,值以服务端实际推送为准"`
+ CreateTime int64 `json:"create_time" description:"审批实例创建时间" format:"timestamp_ms"`
+ FinishTime int64 `json:"finish_time" description:"审批实例完成时间" format:"timestamp_ms"`
+ EventTime int64 `json:"event_time" description:"审批实例事件业务时间" format:"timestamp_ms"`
+}
+
type GroupMemberEventOutput struct {
Type string `json:"type" description:"事件类型,固定为当前 event_key"`
EventID string `json:"event_id" description:"事件 ID,可用于去重"`
@@ -230,6 +318,22 @@ type personalGroupMemberRecord struct {
OpenDingTalkID string `json:"openDingTalkId"`
}
+type personalOAApprovalPayload struct {
+ EventTime int64 `json:"event_time"`
+ Body personalOAApprovalBody `json:"body"`
+}
+
+type personalOAApprovalBody struct {
+ ProcessInstanceID string `json:"processInstanceId"`
+ ProcessCode string `json:"processCode"`
+ TaskID string `json:"taskId"`
+ Title string `json:"title"`
+ Status string `json:"status"`
+ Result string `json:"result"`
+ CreateTime int64 `json:"createTime"`
+ FinishTime int64 `json:"finishTime"`
+}
+
func (b *personalReactionBody) UnmarshalJSON(data []byte) error {
// encoding/json otherwise falls back to case-insensitive field matching.
// Read this protocol field from a map so only operOpenDingtalkId is accepted.
@@ -343,7 +447,7 @@ func ProjectOutput(ev transport.Event) (any, error) {
case isGroupMemberEvent(eventType):
return projectGroupMemberEvent(ev, base, data.Payload)
case isGroupLifecycleEvent(eventType):
- payload, err := decodeGroupLifecyclePayload(data.Payload)
+ payload, err := decodeConservativePayload(data.Payload)
if err != nil {
return ev, fmt.Errorf("decode personal group lifecycle payload: %w", err)
}
@@ -354,6 +458,8 @@ func ProjectOutput(ev transport.Event) (any, error) {
SubscribeID: base.SubscribeID,
Payload: payload,
}, nil
+ case isOAEvent(eventType):
+ return projectOAApprovalEvent(ev, base, data.Payload)
default:
return ev, fmt.Errorf("unsupported personal event type %q", eventType)
}
@@ -370,7 +476,7 @@ func projectMessageEventContext(message personalMessageContext) MessageEventCont
}
}
-func decodeGroupLifecyclePayload(raw json.RawMessage) (map[string]any, error) {
+func decodeConservativePayload(raw json.RawMessage) (map[string]any, error) {
trimmed := bytes.TrimSpace(raw)
if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) {
return nil, fmt.Errorf("payload is missing")
@@ -489,6 +595,112 @@ func projectGroupMemberEvent(ev transport.Event, base baseEventOutput, raw json.
}, nil
}
+func projectOAApprovalEvent(ev transport.Event, base baseEventOutput, raw json.RawMessage) (any, error) {
+ var payload personalOAApprovalPayload
+ if err := decodeRequiredPayload(raw, &payload); err != nil {
+ return ev, fmt.Errorf("decode personal OA payload: %w", err)
+ }
+ if strings.TrimSpace(payload.Body.ProcessInstanceID) == "" {
+ return ev, fmt.Errorf("decode personal OA payload: processInstanceId is required")
+ }
+ if isOAApprovalTaskEvent(base.Type) && strings.TrimSpace(payload.Body.TaskID) == "" {
+ return ev, fmt.Errorf("decode personal OA payload: taskId is required for %s", base.Type)
+ }
+
+ switch base.Type {
+ case EventOAApprovalTaskCreated:
+ return OAApprovalTaskCreatedOutput{
+ Type: base.Type,
+ EventID: base.EventID,
+ Timestamp: base.Timestamp,
+ SubscribeID: base.SubscribeID,
+ ProcessInstanceID: payload.Body.ProcessInstanceID,
+ ProcessCode: payload.Body.ProcessCode,
+ TaskID: payload.Body.TaskID,
+ Title: payload.Body.Title,
+ Status: payload.Body.Status,
+ CreateTime: payload.Body.CreateTime,
+ EventTime: payload.EventTime,
+ }, nil
+ case EventOAApprovalTaskFinished:
+ return OAApprovalTaskFinishedOutput{
+ Type: base.Type,
+ EventID: base.EventID,
+ Timestamp: base.Timestamp,
+ SubscribeID: base.SubscribeID,
+ ProcessInstanceID: payload.Body.ProcessInstanceID,
+ ProcessCode: payload.Body.ProcessCode,
+ TaskID: payload.Body.TaskID,
+ Title: payload.Body.Title,
+ Status: payload.Body.Status,
+ Result: payload.Body.Result,
+ CreateTime: payload.Body.CreateTime,
+ FinishTime: payload.Body.FinishTime,
+ EventTime: payload.EventTime,
+ }, nil
+ case EventOAApprovalTaskRedirected:
+ return OAApprovalTaskRedirectedOutput{
+ Type: base.Type,
+ EventID: base.EventID,
+ Timestamp: base.Timestamp,
+ SubscribeID: base.SubscribeID,
+ ProcessInstanceID: payload.Body.ProcessInstanceID,
+ ProcessCode: payload.Body.ProcessCode,
+ TaskID: payload.Body.TaskID,
+ Title: payload.Body.Title,
+ Status: payload.Body.Status,
+ Result: payload.Body.Result,
+ CreateTime: payload.Body.CreateTime,
+ FinishTime: payload.Body.FinishTime,
+ EventTime: payload.EventTime,
+ }, nil
+ case EventOAApprovalInstanceStarted:
+ return OAApprovalInstanceStartedOutput{
+ Type: base.Type,
+ EventID: base.EventID,
+ Timestamp: base.Timestamp,
+ SubscribeID: base.SubscribeID,
+ ProcessInstanceID: payload.Body.ProcessInstanceID,
+ ProcessCode: payload.Body.ProcessCode,
+ Title: payload.Body.Title,
+ Status: payload.Body.Status,
+ CreateTime: payload.Body.CreateTime,
+ EventTime: payload.EventTime,
+ }, nil
+ case EventOAApprovalInstanceTerminated:
+ return OAApprovalInstanceTerminatedOutput{
+ Type: base.Type,
+ EventID: base.EventID,
+ Timestamp: base.Timestamp,
+ SubscribeID: base.SubscribeID,
+ ProcessInstanceID: payload.Body.ProcessInstanceID,
+ ProcessCode: payload.Body.ProcessCode,
+ Title: payload.Body.Title,
+ Status: payload.Body.Status,
+ CreateTime: payload.Body.CreateTime,
+ FinishTime: payload.Body.FinishTime,
+ EventTime: payload.EventTime,
+ }, nil
+ case EventOAApprovalInstanceFinished:
+ return OAApprovalInstanceFinishedOutput{
+ Type: base.Type,
+ EventID: base.EventID,
+ Timestamp: base.Timestamp,
+ SubscribeID: base.SubscribeID,
+ ProcessInstanceID: payload.Body.ProcessInstanceID,
+ ProcessCode: payload.Body.ProcessCode,
+ Title: payload.Body.Title,
+ Status: payload.Body.Status,
+ Result: payload.Body.Result,
+ CreateTime: payload.Body.CreateTime,
+ FinishTime: payload.Body.FinishTime,
+ EventTime: payload.EventTime,
+ }, nil
+ default:
+ return ev, fmt.Errorf("unsupported personal OA event type %q", base.Type)
+ }
+}
+
func decodeRequiredPayload(raw json.RawMessage, target any) error {
trimmed := bytes.TrimSpace(raw)
if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) {
@@ -651,6 +863,18 @@ func outputTypeForEvent(eventKey string) reflect.Type {
return reflect.TypeOf(GroupMemberEventOutput{})
case isGroupLifecycleEvent(eventKey):
return reflect.TypeOf(GroupLifecycleEventOutput{})
+ case eventKey == EventOAApprovalTaskCreated:
+ return reflect.TypeOf(OAApprovalTaskCreatedOutput{})
+ case eventKey == EventOAApprovalTaskFinished:
+ return reflect.TypeOf(OAApprovalTaskFinishedOutput{})
+ case eventKey == EventOAApprovalTaskRedirected:
+ return reflect.TypeOf(OAApprovalTaskRedirectedOutput{})
+ case eventKey == EventOAApprovalInstanceStarted:
+ return reflect.TypeOf(OAApprovalInstanceStartedOutput{})
+ case eventKey == EventOAApprovalInstanceTerminated:
+ return reflect.TypeOf(OAApprovalInstanceTerminatedOutput{})
+ case eventKey == EventOAApprovalInstanceFinished:
+ return reflect.TypeOf(OAApprovalInstanceFinishedOutput{})
default:
return reflect.TypeOf(baseEventOutput{})
}
@@ -677,6 +901,21 @@ func isGroupLifecycleEvent(eventKey string) bool {
eventKey == EventGroupDisbanded
}
+func isOAEvent(eventKey string) bool {
+ return eventKey == EventOAApprovalTaskCreated ||
+ eventKey == EventOAApprovalTaskFinished ||
+ eventKey == EventOAApprovalTaskRedirected ||
+ eventKey == EventOAApprovalInstanceStarted ||
+ eventKey == EventOAApprovalInstanceTerminated ||
+ eventKey == EventOAApprovalInstanceFinished
+}
+
+func isOAApprovalTaskEvent(eventKey string) bool {
+ return eventKey == EventOAApprovalTaskCreated ||
+ eventKey == EventOAApprovalTaskFinished ||
+ eventKey == EventOAApprovalTaskRedirected
+}
+
func schemaType(t reflect.Type) string {
switch t.Kind() {
case reflect.String:
diff --git a/internal/event/personal/output_test.go b/internal/event/personal/output_test.go
index 06f04dd0..326caf67 100644
--- a/internal/event/personal/output_test.go
+++ b/internal/event/personal/output_test.go
@@ -150,6 +150,59 @@ func personalGroupMemberData(eventKey string) string {
}`, eventKey)
}
+func personalOAData(eventKey string) string {
+ body := map[string]any{
+ "processInstanceId": "process-instance-1",
+ "createTime": int64(1785229100000),
+ "processCode": "PROC-TEST-1",
+ "title": "测试审批",
+ }
+ switch eventKey {
+ case EventOAApprovalTaskCreated:
+ body["taskId"] = "approval-task-1"
+ body["status"] = "RUNNING"
+ case EventOAApprovalTaskFinished:
+ body["taskId"] = "approval-task-1"
+ body["status"] = "FINISHED"
+ body["result"] = "agree"
+ body["finishTime"] = int64(1785229199000)
+ case EventOAApprovalTaskRedirected:
+ body["taskId"] = "approval-task-1"
+ body["status"] = "FINISHED"
+ body["result"] = "redirect"
+ body["finishTime"] = int64(1785229199000)
+ case EventOAApprovalInstanceStarted:
+ body["status"] = "RUNNING"
+ case EventOAApprovalInstanceTerminated:
+ body["status"] = "TERMINATED"
+ body["finishTime"] = int64(1785229199000)
+ case EventOAApprovalInstanceFinished:
+ body["status"] = "FINISHED"
+ body["result"] = "agree"
+ body["finishTime"] = int64(1785229199000)
+ }
+ data := map[string]any{
+ "eventId": "oa-event",
+ "eventKey": eventKey,
+ "occurredAtMs": int64(1785229200123),
+ "subId": "oa-data-sub",
+ "payload": map[string]any{
+ "uid": 100001,
+ "CORPID": "internal-corp",
+ "clientId": "internal-client",
+ "filterSubId": "internal-filter",
+ "bizid": "internal-biz",
+ "orgId": 100002,
+ "sourceId": "open",
+ "body": body,
+ "event_time": int64(1785229199000),
+ "futureField": map[string]any{"nested": true},
+ },
+ }
+ encoded, _ := json.Marshal(data)
+ return string(encoded)
+}
+
func TestCrossPlatformCoverageProjectOutputMessageEvents(t *testing.T) {
for _, eventKey := range []string{EventMention, EventSingleChat, EventInChat, EventFromUser, EventAllSingleChat, EventAllGroupChat} {
t.Run(eventKey, func(t *testing.T) {
@@ -396,6 +449,170 @@ func TestCrossPlatformCoverageProjectOutputGroupLifecycleEvents(t *testing.T) {
}
}
+func TestCrossPlatformCoverageProjectOutputOAEvents(t *testing.T) {
+ tests := []struct {
+ eventKey string
+ want any
+ }{
+ {
+ eventKey: EventOAApprovalTaskCreated,
+ want: OAApprovalTaskCreatedOutput{
+ Type: EventOAApprovalTaskCreated,
+ EventID: "oa-event",
+ Timestamp: 1785229200123,
+ SubscribeID: "outer-sub",
+ ProcessInstanceID: "process-instance-1",
+ ProcessCode: "PROC-TEST-1",
+ TaskID: "approval-task-1",
+ Title: "测试审批",
+ Status: "RUNNING",
+ CreateTime: 1785229100000,
+ EventTime: 1785229199000,
+ },
+ },
+ {
+ eventKey: EventOAApprovalTaskFinished,
+ want: OAApprovalTaskFinishedOutput{
+ Type: EventOAApprovalTaskFinished,
+ EventID: "oa-event",
+ Timestamp: 1785229200123,
+ SubscribeID: "outer-sub",
+ ProcessInstanceID: "process-instance-1",
+ ProcessCode: "PROC-TEST-1",
+ TaskID: "approval-task-1",
+ Title: "测试审批",
+ Status: "FINISHED",
+ Result: "agree",
+ CreateTime: 1785229100000,
+ FinishTime: 1785229199000,
+ EventTime: 1785229199000,
+ },
+ },
+ {
+ eventKey: EventOAApprovalTaskRedirected,
+ want: OAApprovalTaskRedirectedOutput{
+ Type: EventOAApprovalTaskRedirected,
+ EventID: "oa-event",
+ Timestamp: 1785229200123,
+ SubscribeID: "outer-sub",
+ ProcessInstanceID: "process-instance-1",
+ ProcessCode: "PROC-TEST-1",
+ TaskID: "approval-task-1",
+ Title: "测试审批",
+ Status: "FINISHED",
+ Result: "redirect",
+ CreateTime: 1785229100000,
+ FinishTime: 1785229199000,
+ EventTime: 1785229199000,
+ },
+ },
+ {
+ eventKey: EventOAApprovalInstanceStarted,
+ want: OAApprovalInstanceStartedOutput{
+ Type: EventOAApprovalInstanceStarted,
+ EventID: "oa-event",
+ Timestamp: 1785229200123,
+ SubscribeID: "outer-sub",
+ ProcessInstanceID: "process-instance-1",
+ ProcessCode: "PROC-TEST-1",
+ Title: "测试审批",
+ Status: "RUNNING",
+ CreateTime: 1785229100000,
+ EventTime: 1785229199000,
+ },
+ },
+ {
+ eventKey: EventOAApprovalInstanceTerminated,
+ want: OAApprovalInstanceTerminatedOutput{
+ Type: EventOAApprovalInstanceTerminated,
+ EventID: "oa-event",
+ Timestamp: 1785229200123,
+ SubscribeID: "outer-sub",
+ ProcessInstanceID: "process-instance-1",
+ ProcessCode: "PROC-TEST-1",
+ Title: "测试审批",
+ Status: "TERMINATED",
+ CreateTime: 1785229100000,
+ FinishTime: 1785229199000,
+ EventTime: 1785229199000,
+ },
+ },
+ {
+ eventKey: EventOAApprovalInstanceFinished,
+ want: OAApprovalInstanceFinishedOutput{
+ Type: EventOAApprovalInstanceFinished,
+ EventID: "oa-event",
+ Timestamp: 1785229200123,
+ SubscribeID: "outer-sub",
+ ProcessInstanceID: "process-instance-1",
+ ProcessCode: "PROC-TEST-1",
+ Title: "测试审批",
+ Status: "FINISHED",
+ Result: "agree",
+ CreateTime: 1785229100000,
+ FinishTime: 1785229199000,
+ EventTime: 1785229199000,
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.eventKey, func(t *testing.T) {
+ projected, err := ProjectOutput(transport.Event{
+ EventID: "outer-event",
+ EventBornTime: 11,
+ EventType: tt.eventKey,
+ SubscribeID: "outer-sub",
+ Data: personalOAData(tt.eventKey),
+ })
+ if err != nil {
+ t.Fatalf("ProjectOutput() error = %v", err)
+ }
+ if !reflect.DeepEqual(projected, tt.want) {
+ t.Fatalf("ProjectOutput() = %#v, want %#v", projected, tt.want)
+ }
+ assertNoInternalActionFields(t, projected)
+ })
+ }
+}
+
+func TestCrossPlatformCoverageProjectOutputRejectsUnsupportedOAType(t *testing.T) {
+ ev := transport.Event{EventID: "outer-event", EventType: "user_oa_approval_unknown"}
+ projected, err := projectOAApprovalEvent(
+ ev,
+ baseEventOutput{Type: ev.EventType, EventID: ev.EventID},
+ json.RawMessage(`{"body":{"processInstanceId":"process-instance-1"},"event_time":1}`),
+ )
+ if err == nil || !strings.Contains(err.Error(), `unsupported personal OA event type "user_oa_approval_unknown"`) {
+ t.Fatalf("projectOAApprovalEvent() error = %v", err)
+ }
+ if got, ok := projected.(transport.Event); !ok || !reflect.DeepEqual(got, ev) {
+ t.Fatalf("projectOAApprovalEvent() fallback = %#v, want %#v", projected, ev)
+ }
+}
+
+func TestCrossPlatformCoverageProjectOutputOADecodesDoublyWrappedJSONString(t *testing.T) {
+ once, err := json.Marshal(personalOAData(EventOAApprovalTaskCreated))
+ if err != nil {
+ t.Fatal(err)
+ }
+ twice, err := json.Marshal(string(once))
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ projected, err := ProjectOutput(transport.Event{Data: string(twice)})
+ if err != nil {
+ t.Fatalf("ProjectOutput() error = %v", err)
+ }
+ got, ok := projected.(OAApprovalTaskCreatedOutput)
+ if !ok {
+ t.Fatalf("ProjectOutput() type = %T, want OAApprovalTaskCreatedOutput", projected)
+ }
+ if got.Type != EventOAApprovalTaskCreated || got.EventID != "oa-event" || got.SubscribeID != "oa-data-sub" {
+ t.Fatalf("ProjectOutput() = %#v", got)
+ }
+}
+
func TestCrossPlatformCoverageProjectOutputGroupMemberEvents(t *testing.T) {
for _, eventKey := range []string{EventGroupMemberAdded, EventGroupMemberExited} {
t.Run(eventKey, func(t *testing.T) {
@@ -549,6 +766,93 @@ func TestCrossPlatformCoverageProjectOutputRejectsInvalidGroupLifecyclePayloads(
}
}
+func TestCrossPlatformCoverageProjectOutputRejectsInvalidOAPayloads(t *testing.T) {
+ tests := []struct {
+ name string
+ payload string
+ }{
+ {name: "missing"},
+ {name: "null", payload: `,"payload":null`},
+ {name: "empty object", payload: `,"payload":{}`},
+ {name: "array", payload: `,"payload":[]`},
+ {name: "string", payload: `,"payload":"invalid"`},
+ {name: "missing body", payload: `,"payload":{"event_time":1}`},
+ {name: "null body", payload: `,"payload":{"body":null,"event_time":1}`},
+ {name: "empty body", payload: `,"payload":{"body":{},"event_time":1}`},
+ }
+ for _, eventKey := range []string{
+ EventOAApprovalTaskCreated,
+ EventOAApprovalTaskFinished,
+ EventOAApprovalTaskRedirected,
+ EventOAApprovalInstanceStarted,
+ EventOAApprovalInstanceTerminated,
+ EventOAApprovalInstanceFinished,
+ } {
+ for _, tt := range tests {
+ t.Run(eventKey+"/"+tt.name, func(t *testing.T) {
+ ev := transport.Event{
+ EventID: "outer-event",
+ EventType: eventKey,
+ Data: fmt.Sprintf(`{"eventKey":%q%s}`, eventKey, tt.payload),
+ }
+ projected, err := ProjectOutput(ev)
+ if err == nil {
+ t.Fatal("ProjectOutput() error = nil, want OA payload validation error")
+ }
+ if !strings.Contains(err.Error(), "decode personal OA payload") {
+ t.Fatalf("ProjectOutput() error = %v, want OA payload context", err)
+ }
+ got, ok := projected.(transport.Event)
+ if !ok || !reflect.DeepEqual(got, ev) {
+ t.Fatalf("ProjectOutput() fallback = %#v, want %#v", projected, ev)
+ }
+ })
+ }
+ }
+}
+
+func TestCrossPlatformCoverageProjectOutputRejectsOAWithoutStableIDs(t *testing.T) {
+ tests := []struct {
+ name string
+ eventKey string
+ body string
+ want string
+ }{
+ {
+ name: "missing process instance",
+ eventKey: EventOAApprovalInstanceStarted,
+ body: `{"status":"RUNNING"}`,
+ want: "processInstanceId is required",
+ },
+ {
+ name: "missing task",
+ eventKey: EventOAApprovalTaskCreated,
+ body: `{"processInstanceId":"process-instance-1","status":"RUNNING"}`,
+ want: "taskId is required",
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ ev := transport.Event{
+ EventID: "outer-event",
+ EventType: tt.eventKey,
+ Data: fmt.Sprintf(
+ `{"eventKey":%q,"payload":{"body":%s,"event_time":1}}`,
+ tt.eventKey,
+ tt.body,
+ ),
+ }
+ projected, err := ProjectOutput(ev)
+ if err == nil || !strings.Contains(err.Error(), tt.want) {
+ t.Fatalf("ProjectOutput() error = %v, want %q", err, tt.want)
+ }
+ if got, ok := projected.(transport.Event); !ok || !reflect.DeepEqual(got, ev) {
+ t.Fatalf("ProjectOutput() fallback = %#v, want %#v", projected, ev)
+ }
+ })
+ }
+}
+
func TestCrossPlatformCoverageProjectOutputDecodesWrappedJSONString(t *testing.T) {
wrapped, err := json.Marshal(personalMessageData(EventSingleChat))
if err != nil {
diff --git a/internal/event/personal/registry.go b/internal/event/personal/registry.go
index 33b0a4ea..bffb7918 100644
--- a/internal/event/personal/registry.go
+++ b/internal/event/personal/registry.go
@@ -23,22 +23,28 @@ import (
)
const (
- EventMention = "user_im_message_receive_at"
- EventSingleChat = "user_im_message_receive_o2o"
- EventInChat = "user_im_message_receive_group"
- EventFromUser = "user_im_message_receive_user"
- EventAllSingleChat = "user_im_message_receive_o2o_all"
- EventAllGroupChat = "user_im_message_receive_group_all"
- EventReadO2O = "user_im_message_read_o2o"
- EventReadGroup = "user_im_message_read_group"
- EventRecallO2O = "user_im_message_recall_o2o"
- EventRecallGroup = "user_im_message_recall_group"
- EventReactionO2O = "user_im_message_reaction_o2o"
- EventReactionGroup = "user_im_message_reaction_group"
- EventGroupUpdated = "user_im_group_updated"
- EventGroupMemberAdded = "user_im_group_member_added"
- EventGroupMemberExited = "user_im_group_member_exited"
- EventGroupDisbanded = "user_im_group_disbanded"
+ EventMention = "user_im_message_receive_at"
+ EventSingleChat = "user_im_message_receive_o2o"
+ EventInChat = "user_im_message_receive_group"
+ EventFromUser = "user_im_message_receive_user"
+ EventAllSingleChat = "user_im_message_receive_o2o_all"
+ EventAllGroupChat = "user_im_message_receive_group_all"
+ EventReadO2O = "user_im_message_read_o2o"
+ EventReadGroup = "user_im_message_read_group"
+ EventRecallO2O = "user_im_message_recall_o2o"
+ EventRecallGroup = "user_im_message_recall_group"
+ EventReactionO2O = "user_im_message_reaction_o2o"
+ EventReactionGroup = "user_im_message_reaction_group"
+ EventGroupUpdated = "user_im_group_updated"
+ EventGroupMemberAdded = "user_im_group_member_added"
+ EventGroupMemberExited = "user_im_group_member_exited"
+ EventGroupDisbanded = "user_im_group_disbanded"
+ EventOAApprovalTaskCreated = "user_oa_approval_task_created"
+ EventOAApprovalTaskFinished = "user_oa_approval_task_finished"
+ EventOAApprovalTaskRedirected = "user_oa_approval_task_redirected"
+ EventOAApprovalInstanceStarted = "user_oa_approval_instance_started"
+ EventOAApprovalInstanceTerminated = "user_oa_approval_instance_terminated"
+ EventOAApprovalInstanceFinished = "user_oa_approval_instance_finished"
)
const (
@@ -273,6 +279,72 @@ var definitions = []Definition{
Auth: map[string]any{"identity": "user"},
Public: true,
},
+ {
+ EventKey: EventOAApprovalTaskCreated,
+ DisplayName: "审批任务创建",
+ Description: "审批任务创建,发送给审批人",
+ Category: "oa",
+ RuleType: "all",
+ Status: StatusEnabled,
+ RequiredParams: nil,
+ Auth: map[string]any{"identity": "user"},
+ Public: true,
+ },
+ {
+ EventKey: EventOAApprovalTaskFinished,
+ DisplayName: "审批任务完成",
+ Description: "审批任务已完成",
+ Category: "oa",
+ RuleType: "all",
+ Status: StatusEnabled,
+ RequiredParams: nil,
+ Auth: map[string]any{"identity": "user"},
+ Public: true,
+ },
+ {
+ EventKey: EventOAApprovalTaskRedirected,
+ DisplayName: "审批人任务转交",
+ Description: "审批任务已转交",
+ Category: "oa",
+ RuleType: "all",
+ Status: StatusEnabled,
+ RequiredParams: nil,
+ Auth: map[string]any{"identity": "user"},
+ Public: true,
+ },
+ {
+ EventKey: EventOAApprovalInstanceStarted,
+ DisplayName: "发起审批单",
+ Description: "审批实例已发起",
+ Category: "oa",
+ RuleType: "all",
+ Status: StatusEnabled,
+ RequiredParams: nil,
+ Auth: map[string]any{"identity": "user"},
+ Public: true,
+ },
+ {
+ EventKey: EventOAApprovalInstanceTerminated,
+ DisplayName: "审批单终止",
+ Description: "审批实例已终止",
+ Category: "oa",
+ RuleType: "all",
+ Status: StatusEnabled,
+ RequiredParams: nil,
+ Auth: map[string]any{"identity": "user"},
+ Public: true,
+ },
+ {
+ EventKey: EventOAApprovalInstanceFinished,
+ DisplayName: "审批单完成",
+ Description: "审批实例完成,发送给审批单发起人",
+ Category: "oa",
+ RuleType: "all",
+ Status: StatusEnabled,
+ RequiredParams: nil,
+ Auth: map[string]any{"identity": "user"},
+ Public: true,
+ },
}
func targetUIDConstraints() *ParameterConstraints {
diff --git a/internal/event/personal/registry_test.go b/internal/event/personal/registry_test.go
index d96726b8..fb01a3b3 100644
--- a/internal/event/personal/registry_test.go
+++ b/internal/event/personal/registry_test.go
@@ -46,12 +46,47 @@ func TestCatalogEnabledEvents(t *testing.T) {
EventGroupMemberAdded,
EventGroupMemberExited,
EventGroupDisbanded,
+ EventOAApprovalTaskCreated,
+ EventOAApprovalTaskFinished,
+ EventOAApprovalTaskRedirected,
+ EventOAApprovalInstanceStarted,
+ EventOAApprovalInstanceTerminated,
+ EventOAApprovalInstanceFinished,
}
if !reflect.DeepEqual(keys, want) {
t.Fatalf("keys = %#v, want %#v", keys, want)
}
}
+func TestOAEventCatalogDefinitions(t *testing.T) {
+ items := Catalog("oa", true, false)
+ wantKeys := []string{
+ EventOAApprovalTaskCreated,
+ EventOAApprovalTaskFinished,
+ EventOAApprovalTaskRedirected,
+ EventOAApprovalInstanceStarted,
+ EventOAApprovalInstanceTerminated,
+ EventOAApprovalInstanceFinished,
+ }
+ if len(items) != len(wantKeys) {
+ t.Fatalf("Catalog(oa) = %#v, want %d events", items, len(wantKeys))
+ }
+ for i, item := range items {
+ if item.EventKey != wantKeys[i] {
+ t.Fatalf("Catalog(oa)[%d].event_key = %q, want %q", i, item.EventKey, wantKeys[i])
+ }
+ if item.Category != "oa" || item.RuleType != "all" || item.Status != StatusEnabled || !item.Public {
+ t.Fatalf("Catalog(oa)[%d] = %#v, want public enabled oa/all event", i, item)
+ }
+ if len(item.RequiredParams) != 0 || item.Constraints != nil {
+ t.Fatalf("Catalog(oa)[%d] parameters = %#v/%#v, want none", i, item.RequiredParams, item.Constraints)
+ }
+ if item.Auth["identity"] != "user" {
+ t.Fatalf("Catalog(oa)[%d].auth = %#v, want user identity", i, item.Auth)
+ }
+ }
+}
+
func TestEventFromUserIsPublic(t *testing.T) {
if _, ok := Lookup(EventFromUser); !ok {
t.Fatalf("Lookup(%q) failed", EventFromUser)
@@ -119,6 +154,12 @@ func TestSchemaDocumentsDefaultToTransportEnvelope(t *testing.T) {
EventGroupMemberAdded,
EventGroupMemberExited,
EventGroupDisbanded,
+ EventOAApprovalTaskCreated,
+ EventOAApprovalTaskFinished,
+ EventOAApprovalTaskRedirected,
+ EventOAApprovalInstanceStarted,
+ EventOAApprovalInstanceTerminated,
+ EventOAApprovalInstanceFinished,
} {
t.Run(eventKey, func(t *testing.T) {
def, ok := Lookup(eventKey)
@@ -433,6 +474,96 @@ func TestGroupLifecycleSchemaDocumentsUseConservativePayload(t *testing.T) {
}
}
+func TestOAEventSchemaDocumentsMatchOutputDTO(t *testing.T) {
+ tests := []struct {
+ eventKey string
+ properties []string
+ }{
+ {
+ eventKey: EventOAApprovalTaskCreated,
+ properties: []string{
+ "type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
+ "process_code", "task_id", "title", "status", "create_time", "event_time",
+ },
+ },
+ {
+ eventKey: EventOAApprovalTaskFinished,
+ properties: []string{
+ "type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
+ "process_code", "task_id", "title", "status", "result", "create_time",
+ "finish_time", "event_time",
+ },
+ },
+ {
+ eventKey: EventOAApprovalTaskRedirected,
+ properties: []string{
+ "type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
+ "process_code", "task_id", "title", "status", "result", "create_time",
+ "finish_time", "event_time",
+ },
+ },
+ {
+ eventKey: EventOAApprovalInstanceStarted,
+ properties: []string{
+ "type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
+ "process_code", "title", "status", "create_time", "event_time",
+ },
+ },
+ {
+ eventKey: EventOAApprovalInstanceTerminated,
+ properties: []string{
+ "type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
+ "process_code", "title", "status", "create_time", "finish_time", "event_time",
+ },
+ },
+ {
+ eventKey: EventOAApprovalInstanceFinished,
+ properties: []string{
+ "type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
+ "process_code", "title", "status", "result", "create_time", "finish_time",
+ "event_time",
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.eventKey, func(t *testing.T) {
+ def, ok := Lookup(tt.eventKey)
+ if !ok {
+ t.Fatalf("Lookup(%q) failed", tt.eventKey)
+ }
+ doc := BuildSchemaDocumentForMode(def, true)
+ if doc.JQRootPath != "." {
+ t.Fatalf("jq_root_path = %q, want .", doc.JQRootPath)
+ }
+ props, ok := doc.Schema["properties"].(map[string]any)
+ if !ok || len(props) != len(tt.properties) {
+ t.Fatalf("schema.properties = %#v, want exactly %d fields", doc.Schema["properties"], len(tt.properties))
+ }
+ for _, name := range tt.properties {
+ if _, ok := props[name].(map[string]any); !ok {
+ t.Fatalf("schema.properties.%s = %#v, want object", name, props[name])
+ }
+ }
+ eventType := props["type"].(map[string]any)
+ if !reflect.DeepEqual(eventType["enum"], []string{tt.eventKey}) {
+ t.Fatalf("schema.properties.type.enum = %#v, want %q", eventType["enum"], tt.eventKey)
+ }
+ if _, ok := props["payload"]; ok {
+ t.Fatalf("schema.properties exposed generic payload: %#v", props)
+ }
+ for _, name := range []string{"timestamp", "create_time", "finish_time", "event_time"} {
+ property, exists := props[name].(map[string]any)
+ if !exists {
+ continue
+ }
+ if property["type"] != "integer" || property["format"] != "timestamp_ms" {
+ t.Fatalf("schema.properties.%s = %#v, want timestamp_ms integer", name, property)
+ }
+ }
+ })
+ }
+}
+
func TestGroupMemberSchemaDocumentsMatchOutputDTO(t *testing.T) {
wantProperties := []string{
"type", "event_id", "timestamp", "subscribe_id", "conversation_id",
@@ -495,7 +626,16 @@ func TestBuildRuleParamMention(t *testing.T) {
}
func TestBuildRuleParamAllEvents(t *testing.T) {
- for _, eventKey := range []string{EventAllSingleChat, EventAllGroupChat} {
+ for _, eventKey := range []string{
+ EventAllSingleChat,
+ EventAllGroupChat,
+ EventOAApprovalTaskCreated,
+ EventOAApprovalTaskFinished,
+ EventOAApprovalTaskRedirected,
+ EventOAApprovalInstanceStarted,
+ EventOAApprovalInstanceTerminated,
+ EventOAApprovalInstanceFinished,
+ } {
t.Run(eventKey, func(t *testing.T) {
rule, param, err := BuildRuleParam(eventKey, RuleOptions{})
if err != nil {
@@ -701,6 +841,12 @@ func TestSupportsMessageFilter(t *testing.T) {
EventReadO2O,
EventReactionGroup,
EventGroupUpdated,
+ EventOAApprovalTaskCreated,
+ EventOAApprovalTaskFinished,
+ EventOAApprovalTaskRedirected,
+ EventOAApprovalInstanceStarted,
+ EventOAApprovalInstanceTerminated,
+ EventOAApprovalInstanceFinished,
"unknown_event",
} {
if SupportsMessageFilter(eventKey) {
diff --git a/internal/helpers/chat.go b/internal/helpers/chat.go
index b65d34e1..bd9a013a 100644
--- a/internal/helpers/chat.go
+++ b/internal/helpers/chat.go
@@ -1540,7 +1540,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 d425a344..e5c77eba 100755
--- a/scripts/policy/check-skill-context-budget.sh
+++ b/scripts/policy/check-skill-context-budget.sh
@@ -8,7 +8,7 @@ python3 scripts/gen_skill_shortcut_sections.py --check
chat_skill="skills/multi/dingtalk-chat/SKILL.md"
doc_skill="skills/multi/dingtalk-doc/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_target_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 cc2d1822..ce34ea80 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 96b8b55c..440c149e 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/SKILL.md b/skills/mono/SKILL.md
index 27d2800e..dc3ea1d8 100644
--- a/skills/mono/SKILL.md
+++ b/skills/mono/SKILL.md
@@ -1,6 +1,6 @@
---
name: dws
-description: 管理钉钉产品能力(AI表格/AI搜问/日历/通讯录/群聊与机器人/待办/审批/考勤/日志/DING消息/开放平台文档/钉钉文档/钉钉云盘/原生Markdown文件/AI听记/邮箱/在线电子表格/知识库等)。当用户需要操作表格数据、管理日程会议、模糊找人/查谁负责某事项、查询通讯录、管理群聊、机器人发消息、创建待办、提交审批、查看考勤、提交日报周报(钉钉日志模版)、读写钉钉文档、上传下载云盘文件、读取或修改原生.md文件、查询听记纪要、收发邮件、读写在线电子表格(axls)、管理钉钉知识库,或订阅个人 IM 事件、实时监听群成员加入、群成员退出、群改名和群解散时使用。
+description: 管理钉钉产品能力(AI表格/AI搜问/日历/通讯录/群聊与机器人/待办/审批/考勤/日志/DING消息/开放平台文档/钉钉文档/钉钉云盘/原生Markdown文件/AI听记/邮箱/在线电子表格/知识库等)。当用户需要操作表格数据、管理日程会议、模糊找人/查谁负责某事项、查询通讯录、管理群聊、机器人发消息、创建待办、提交审批、查看考勤、提交日报周报(钉钉日志模版)、读写钉钉文档、上传下载云盘文件、读取或修改原生.md文件、查询听记纪要、收发邮件、读写在线电子表格(axls)、管理钉钉知识库,或订阅个人 IM 事件或 OA 审批事件、实时监听群成员加入、群成员退出、群改名和群解散、审批实例发起/终止/完成,以及审批任务创建/完成/转交时使用。
cli_version: ">=1.0.15"
---
@@ -22,7 +22,7 @@ cli_version: ">=1.0.15"
- 单次批量操作不超过 30 条记录
- 所有命令必须**严格遵循**对应产品参考文档里面规定的参数格式(如:如果有参数值,则参数和参数值之间至少用一个空格隔开)
- **脚本只用于明确覆盖的复合任务**:[scripts/](./scripts/) 下的脚本可封装 AI 表格批量导入导出、AI 应用创建轮询、文档创建后写内容、钉盘目录树等流程;当公开 `+` Shortcut 已提供目标唯一解析、分页/部分失败 ledger 和确认语义时,优先 Shortcut。Chat 历史导出与机器人广播已完全下沉 Runtime,不再发布兼容脚本
-- **实时个人消息事件例外**:用户要监听消息、订阅事件、自动回复消息或事件驱动 Agent 时,默认走 `dws event +listen-im ...`;只有明确需要多个原始 EventKey、Filter DSL、subscribe_id 或原始 envelope 时才用 `dws event consume ... --flatten`,不要写脚本轮询消息历史
+- **实时个人事件例外**:普通 IM 消息、reaction、已读和撤回默认走 `dws event +listen-im ...`;OA 审批、群生命周期、明确的原始 EventKey、Filter DSL、subscribe_id 或原始 envelope 使用 `dws event consume ... --flatten`。不要写脚本轮询消息历史或审批列表
## Shortcut 与原子命令的使用原则
@@ -94,7 +94,7 @@ cli_version: ">=1.0.15"
| `todo` | 待办:创建(含优先级/截止时间/循环)/查询/修改/标记完成/删除 | [todo.md](./references/products/todo.md) |
| `wiki` | 知识库:空间创建/详情/列表/搜索 + 成员管理 + 知识库动态查询 | [wiki.md](./references/products/wiki.md) |
| `whiteboard` | 文档内嵌白板:读取 OpenNodes、追加节点、整页重建 | [whiteboard.md](./references/products/whiteboard.md) |
-| `event` | 个人 IM 事件:监听消息接收、指定发送人、已读、撤回、表情回应,NDJSON 输出(实时驱动 Agent)| [event.md](./references/products/event.md) |
+| `event` | 个人 IM/OA 事件:监听消息、群生命周期、审批任务与审批实例事件,NDJSON 输出(实时驱动 Agent)| [event.md](./references/products/event.md) |
## 意图判断决策树
@@ -122,6 +122,7 @@ cli_version: ">=1.0.15"
用户提到"创建知识库/知识库列表/搜索知识库空间/wiki/团队空间/知识库成员管理/我的文档个人空间" → `wiki`
用户提到"文档内嵌白板/画布/OpenNodes/白板节点/连接线/整页重建白板" → `whiteboard`;创建空白板卡片先走 `doc whiteboard insert`
用户提到"监听有人@我/监听单聊或群消息/监听所有单聊或群消息/监听某人发送的消息/监听消息已读/监听消息撤回/监听消息贴表情或表情回应/订阅个人 IM 事件/实时接收钉钉事件/监听并自动回复消息/驱动 Agent 处理消息" → `event +listen-im`;群成员加入/退出、群改名/解散或明确原始 EventKey/Filter DSL → `event consume`
+用户提到"监听待我审批的任务/监听审批任务创建、完成或转交/监听审批单发起或终止/监听我发起的审批完成/监听审批实例完成/订阅 OA 事件/event consume user_oa_approval_*" → `event consume`
普通消息、reaction、已读、撤回监听优先由一个 `dws event +listen-im` 进程表达目标;不同用户、不同群或不同过滤条件拆成独立进程。只有高级事件控制才生成 `dws event consume [event_key...] --flatten`。
@@ -131,6 +132,7 @@ cli_version: ">=1.0.15"
关键区分: doc(在线富文本文档/adoc) vs markdown(原生 .md 纯文本文件) vs drive(通用文件存储与传输)
关键区分: contact(基础通讯录档案:学历/家庭/银行卡/紧急联系人/合同/部门角色) vs aisearch person(通用语义找人:谁负责/上级/下级/多维度模糊搜索) vs hrbrain(人才池/员工档案专项模块数据/职业历程/绩效/结构化高级人才搜索)
关键区分: oa tasks(审批 taskId,审批/拒绝用) vs oa list-pending(收件箱 processInstanceId,查看用)
+关键区分: oa(查询或操作审批) vs event user_oa_approval_*(当前用户审批事件长连接监听)
> 更多易混淆场景见 [intent-guide.md](./references/intent-guide.md)
diff --git a/skills/mono/references/products/event.md b/skills/mono/references/products/event.md
index 609440b8..f9b82c95 100644
--- a/skills/mono/references/products/event.md
+++ b/skills/mono/references/products/event.md
@@ -1,6 +1,6 @@
-# dws event — 个人 IM 事件
+# dws event — 个人 IM 与 OA 审批事件
-通过个人 Stream 长连接监听当前用户的钉钉消息接收、已读、撤回、表情回应和群生命周期事件,NDJSON 输出到 stdout,用于驱动事件触发的 Agent。普通 IM 监听默认使用 `dws event +listen-im`;群生命周期、显式 EventKey、Filter DSL、subscribe_id 复用或原始 envelope 才使用 `dws event consume`。不要写脚本轮询消息历史。
+通过个人 Stream 长连接监听当前用户的钉钉消息接收、已读、撤回、表情回应、群生命周期,以及审批任务和审批实例事件,NDJSON 输出到 stdout,用于驱动事件触发的 Agent。普通 IM 监听默认使用 `dws event +listen-im`;OA 审批、群生命周期、显式 EventKey、Filter DSL、subscribe_id 复用或原始 envelope 使用 `dws event consume`。不要写脚本轮询消息历史或审批列表。
## 运行方式
@@ -14,6 +14,7 @@
| Command | Purpose |
|---|---|
| `dws event +listen-im --kind ... [flags]` | 普通 message/reaction/read/recall 监听;支持自然姓名/群名唯一解析 |
+| `dws event list --category oa` | 查看当前公开的 OA 个人事件目录 |
| `dws event schema --flatten` | 查看 Agent 使用的顶层业务字段 schema |
| `dws event consume [event_key...] --flatten [flags]` | 高级入口:阻塞消费显式 EventKey,事件写到 stdout,用 `-f ndjson` |
| `dws event status --event ` | 查看个人订阅、bus、本地 consume |
@@ -42,8 +43,14 @@
| `user_im_group_member_added` | 指定群聊有成员加入 | `--group` |
| `user_im_group_member_exited` | 指定群聊有成员退出 | `--group` |
| `user_im_group_disbanded` | 指定群聊被解散 | `--group` |
+| `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` | 审批实例完成,发送给审批单发起人 | 无 |
-只承认上表 16 个事件码。默认身份就是当前用户,不要额外加身份切换 flag。
+只承认上表 22 个事件码。默认身份就是当前用户,使用当前用户 OAuth 登录态,不要额外加身份切换 flag。六个 OA 事件订阅当前用户相关的全部审批事件,规则均为 `all`、空 `filterRule`,不需要目标参数。
## Intent mapping
@@ -65,20 +72,27 @@
| "监听并自动回复某人的单聊消息" | `+listen-im --kind sender --user-query "<姓名>"`;事件到达后把稳定 ID 交给 `chat +messages-send` |
| "同时监听同一人的消息、已读和撤回" | 一个 `+listen-im --kind sender --events message,read,recall` 生命周期 |
| "同时监听同一群的消息、改名和解散" | 生命周期事件不在 Shortcut 范围;一个高级 consume 放入 3 个 EventKey,共享 `--group` |
-| "查看个人消息事件 schema" | `dws event schema --flatten` |
+| "监听新的待我审批任务" / "有审批任务创建时通知我" | `event consume`,事件码 `user_oa_approval_task_created`,参数 `--flatten -f ndjson` |
+| "审批任务完成时通知我" | `event consume`,事件码 `user_oa_approval_task_finished`,参数 `--flatten -f ndjson` |
+| "审批任务被转交时通知我" | `event consume`,事件码 `user_oa_approval_task_redirected`,参数 `--flatten -f ndjson` |
+| "有审批单发起时通知我" | `event consume`,事件码 `user_oa_approval_instance_started`,参数 `--flatten -f ndjson` |
+| "有审批单终止时通知我" | `event consume`,事件码 `user_oa_approval_instance_terminated`,参数 `--flatten -f ndjson` |
+| "监听我发起的审批何时完成" / "审批实例完成时通知我" | `event consume`,事件码 `user_oa_approval_instance_finished`,参数 `--flatten -f ndjson` |
+| "同时监听全部已公开 OA 事件" | 一个 consume 放入六个 OA event key,不加目标或消息过滤参数 |
+| "查看个人事件 schema" | `dws event schema --flatten` |
| "看个人事件订阅状态" | `dws event status --event ` |
| "停止这个个人事件订阅" | `dws event stop --dry-run`,确认后改用 `--yes` |
多候选让用户确认。缺必填 ID 且解析不出先追问,不要猜。企业内部 userId 使用 `--user`;明确给出 openDingtalkId,或目标是外部联系人、机器人、跨组织身份时使用 `--open-dingtalk-id`。两者严格二选一,不得混填、猜测或自动转换身份类型。
-“我和某人的单聊”使用 `receive_o2o`;“某人发给我的消息/某人发送的消息”使用 `receive_user`,后者覆盖该发送人的单聊和群聊消息。只有明确说“所有”时才使用 `receive_o2o_all/receive_group_all`,指定对象仍使用范围更小的事件。用户要求执行“撤回消息”时走 `dws chat`;只有“监听/订阅消息撤回”才走 `dws event`。“贴标签”表示给消息贴表情时,对应 `reaction` 表情回应事件。
+“我和某人的单聊”使用 `receive_o2o`;“某人发给我的消息/某人发送的消息”使用 `receive_user`,后者覆盖该发送人的单聊和群聊消息。只有明确说“所有”时才使用 `receive_o2o_all/receive_group_all`,指定对象仍使用范围更小的事件。用户要求执行“撤回消息”时走 `dws chat`;只有“监听/订阅消息撤回”才走 `dws event`。“贴标签”表示给消息贴表情时,对应 `reaction` 表情回应事件。OA 事件不接受 `--user`、`--open-dingtalk-id`、`--group`、`--query` 或 `--filter-json`。
## Call flow
-1. 普通 IM 意图选择 `+listen-im` 的 `--kind` 与 `--events`;人名/群名直接用 `--user-query`/`--chat-query` 唯一解析。高级事件控制才手选 EventKey。
+1. 普通 IM 意图选择 `+listen-im` 的 `--kind` 与 `--events`;人名/群名直接用 `--user-query`/`--chat-query` 唯一解析。OA 审批与高级事件控制手选 EventKey。
2. 需要了解字段时运行 `dws event schema --flatten`,读取 `schema.properties`;此模式的 `jq_root_path` 为 `.`。
-3. 普通任务启动 `dws event +listen-im ... -f ndjson`;高级任务启动 `dws event consume [event_key...] ... --flatten -f ndjson`。等待真实 ready marker;多事件记录每条 subscription,再等待整体 ready。不要用 `sleep` 猜测。
-4. stdout 每行是一个扁平事件 JSON;消息、动作及群成员加入/退出事件读取顶层业务字段。群标题变更和群解散只读取公共字段和 `payload` 中实际存在的字段。
+3. 普通 IM 启动 `dws event +listen-im ... -f ndjson`;OA 或高级任务启动 `dws event consume [event_key...] ... --flatten -f ndjson`。等待真实 ready marker;多事件记录每条 subscription,再等待整体 ready。不要用 `sleep` 猜测。
+4. stdout 每行是一个扁平事件 JSON;消息、动作、群成员加入/退出及 OA 审批事件读取顶层业务字段。群标题变更和群解散只读取公共字段和 `payload` 中实际存在的字段。
5. 需要确认监听状态时运行 `dws event status --event `,查看 `Subscriptions` 和 `Consumers`。
6. 任务完成后优雅结束 consume;本次新建的订阅会自动取消。复用已有订阅或需要从外部主动取消时,先运行 `dws event stop --dry-run`,向用户确认后再以 `--yes` 执行;自测可在 consume 加 `--max-events` 或 `--duration` 自动退出。
@@ -112,6 +126,12 @@ dws event schema user_im_group_updated --flatten
dws event schema user_im_group_member_added --flatten
dws event schema user_im_group_member_exited --flatten
dws event schema user_im_group_disbanded --flatten
+dws event schema user_oa_approval_task_created --flatten
+dws event schema user_oa_approval_task_finished --flatten
+dws event schema user_oa_approval_task_redirected --flatten
+dws event schema user_oa_approval_instance_started --flatten
+dws event schema user_oa_approval_instance_terminated --flatten
+dws event schema user_oa_approval_instance_finished --flatten
```
```bash
@@ -133,6 +153,12 @@ dws event consume user_im_group_updated --group --flatten -
dws event consume user_im_group_member_added --group --flatten -f ndjson
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 consume user_oa_approval_task_created --flatten -f ndjson
+dws event consume user_oa_approval_task_finished --flatten -f ndjson
+dws event consume user_oa_approval_task_redirected --flatten -f ndjson
+dws event consume user_oa_approval_instance_started --flatten -f ndjson
+dws event consume user_oa_approval_instance_terminated --flatten -f ndjson
+dws event consume user_oa_approval_instance_finished --flatten -f ndjson
```
同一目标、同一过滤条件的兼容事件优先使用一个多事件命令:
@@ -153,9 +179,19 @@ dws event consume \
--group \
--flatten \
-f ndjson
+
+dws event consume \
+ 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
```
-用户类事件共享 `--user` 或 `--open-dingtalk-id`,群类事件共享 `--group`,无目标事件可加入任一组合。用户类与群类、不同目标或不同过滤条件要拆成多个进程。多事件共享 `--query` / `--filter-json` 时,所选事件必须全部是消息接收事件。
+用户类事件共享 `--user` 或 `--open-dingtalk-id`,群类事件共享 `--group`,无目标 IM 事件可加入任一组合。用户类与群类、不同目标或不同过滤条件要拆成多个进程。六个 OA 事件可以同进程消费并共享 personal bus,但各自建立独立订阅。多事件共享 `--query` / `--filter-json` 时,所选事件必须全部是 IM 消息接收事件;OA 事件单独或组合消费都禁止使用这两个消息过滤参数。
上述所有 `*_o2o` 命令和 `user_im_message_receive_user` 都可将 `--user ` 替换为 `--open-dingtalk-id `,但两个参数不能同时使用。
@@ -180,7 +216,7 @@ dws event stop --all --yes
## 订阅创建失败与重试预算
-以下约束适用于上表全部 16 个事件以及多事件命令中的每一项,只治理 `[event] ready` 之前的订阅创建;ready 之后的 Stream 断线由长连接重连机制处理。
+以下约束适用于上表全部 22 个公开个人事件(16 个 IM + 6 个 OA)以及多事件命令中的每一项,只治理 `[event] ready` 之前的订阅创建;ready 之后的 Stream 断线由长连接重连机制处理。
- `0/2/1` 是 **Agent/host 编排约束**,不是 CLI 持久化硬总次数上限。每次 `dws event consume` 调用对每个逻辑订阅最多发送一次订阅创建 HTTP 请求,进程内不会自动重试。CLI 本地状态只持久化 `in_flight`、`cooldown`、`terminal_hold` 三种保护状态,不持久化或计算跨调用的 Agent/host 尝试次数。
- 解析人名或群名、执行 `event consume` 以及后续 `event status/stop` 必须使用同一个 `--profile`。不得把其它 profile 下解析出的 userId、openDingtalkId 或 openConversationId 直接带入当前 profile 的订阅。
@@ -210,6 +246,7 @@ dws event stop --all --yes
- 表情回应事件直接读取 `operator/operator_open_dingtalk_id/reaction_name/reaction_text/operation_type/operation_time`。
- 群成员加入/退出事件读取 `conversation_id/operator/operator_open_dingtalk_id/members/event_time`。`operator` 是执行操作的人,`members` 是本次加入或退出的成员数组,成员项包含 `nick/open_dingtalk_id`;系统操作或成员自行退出时操作人字段可能为空。
- 群标题变更和群解散当前只承诺 `type/event_id/timestamp/subscribe_id/payload`;以实际 `payload` 为准,不猜测群标题、操作者等字段。
+- OA 事件读取顶层 `process_instance_id/process_code/title/status/create_time/event_time`;任务事件另有 `task_id`,完成、转交或终止事件按对应 schema 提供 `finish_time`,任务完成、任务转交和实例完成还提供 `result`。`status/result` 保留服务端实际值,不推断完整枚举;缺少稳定 ID 或 payload 非法时 stderr 会输出 warning,stdout 回退为原始 transport envelope。
- 图片、文件等媒体消息的 `content` 可能是可读描述;合并转发媒体的下载定位信息位于对应 `forward_messages[].content`。需要实际媒体文件时调用 `dws chat message download-media`。
- 正常动作事件输出不含内部 `payload/uid/corpid/clientId/filterSubId/bizid`;原始排查才使用 `-f raw` 或 `--debug-raw-events`。
- 自己发的消息不作为事件回来(`isSelfLoop` 过滤);自发验证会看到 0 事件,测试投递使用别人或机器人发消息。
@@ -225,6 +262,6 @@ dws event stop --all --yes
## Full reference
-- multi skill: `skills/multi/dingtalk-misc/SKILL.md`(产品索引 → event)
-- IM reference: `skills/multi/dingtalk-misc/references/event.md`
-- IM task index: `skills/multi/dingtalk-misc/references/event-im.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 8e1e3b3b..8c952ece 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 63%
rename from skills/multi/dingtalk-misc/references/event.md
rename to skills/multi/dingtalk-event/SKILL.md
index b45d0ede..4fdd75a9 100644
--- a/skills/multi/dingtalk-misc/references/event.md
+++ b/skills/multi/dingtalk-event/SKILL.md
@@ -1,13 +1,28 @@
-# 钉钉个人 IM 事件
+---
+name: dingtalk-event
+description: 钉钉个人 IM 与 OA 审批事件长连接监听。Use when 用户说监听消息/@我/某人/某群/全部消息、已读/撤回/reaction、群成员加入/群成员退出/群状态变化,或监听审批任务创建/完成/转交、审批实例发起/终止/完成。命令前缀:dws event。
+metadata:
+ cli_version: ">=0.2.14"
+ category: product
+ requires:
+ bins:
+ - dws
+---
-> 本文件为 `dingtalk-misc` 内个人 IM 事件产品入口。命令前缀:`dws event`。命中后先读本页 Golden Route;子 reference 按需加载,不要预加载全部。
+# 钉钉个人 IM 与 OA 审批事件
-实时监听必须使用事件长连接,不写轮询脚本,不用历史消息查询模拟事件。高频 IM 意图优先交给 `dws event +listen-im`;它在 CLI 内解析自然目标、选择 EventKey,并复用现有订阅与 bus 生命周期。
+> **前置:执行 `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`。
消息、reaction、已读和撤回的默认监听入口是 `dws event +listen-im`;
只有群生命周期、Filter DSL、原始 envelope 或底层订阅控制才使用
`event consume` fallback。
+OA 审批任务与审批实例的实时变化使用 `dws event consume`;查询或操作已有审批走 `dws oa`,不要用轮询模拟事件。
+
## Golden Route
| 用户意图 | 唯一推荐入口 |
@@ -17,7 +32,9 @@
| 监听指定群消息 | `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 |
+| 群改名、成员进退、群解散 | 读取 [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` |
@@ -28,6 +45,8 @@
- `group` 必须且只能传 `--chat-id` 或 `--chat-query` 之一。
- `--query` 只用于纯 `message` 监听;混入 reaction/read/recall 时不得使用。
+OA 事件不进入 `+listen-im`。六个公开 OA EventKey 都订阅当前 OAuth 用户相关的全部审批事件,使用 `ruleType=all`、`filterRule={}`;不接受 `--user`、`--open-dingtalk-id`、`--group`、`--query` 或 `--filter-json`。六项可放入同一个 consume,每项建立独立订阅并共享 bus。
+
自然姓名和群名由 CLI 内部唯一解析:零命中或多候选返回结构化失败,在创建任何订阅前停止。`--dry-run` 走同一解析链。解析、监听、状态和停止必须使用同一个 `--profile`,不得跨组织搬运 ID。
### 兼容 EventKey 索引
@@ -46,6 +65,8 @@ user_im_group_member_added user_im_group_member_exited
user_im_group_disbanded
```
+六个 OA EventKey 及其输出字段见 [OA 事件参考](references/event-oa.md)。
+
用户类事件传 `--user` 或 `--open-dingtalk-id`,群类事件传 `--group`。群生命周期输出可含 `operator_open_dingtalk_id` 和 `members`;成员项使用 `open_dingtalk_id`。精确组合、兼容性和 Filter 规则见 reference。
## 公开层与内部统一边界
@@ -59,7 +80,7 @@ kind + events + target
→ 一次 event consume 生命周期
```
-订阅创建/复用、单 bus、多 consumer、ready marker、扁平 NDJSON、超时/取消、部分失败回滚和退出清理全部复用现有 Runtime。低频 EventKey、群生命周期、Filter DSL、原始 envelope、复用 subscribe_id 等仍由 `event consume` 承担。
+订阅创建/复用、单 bus、多 consumer、ready marker、扁平 NDJSON、超时/取消、部分失败回滚和退出清理全部复用现有 Runtime。低频 EventKey、群生命周期、OA 审批、Filter DSL、原始 envelope、复用 subscribe_id 等仍由 `event consume` 承担。
## 运行与结果契约
@@ -69,16 +90,17 @@ 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 事件参考](references/event-oa.md) 为准。
## 安全与失败处理
- `event stop` 会取消订阅并影响本地 consumer:先 `--dry-run`,用户确认后再加 `--yes`。
- 多事件属于一次原始操作;任一订阅启动失败时 Runtime 回滚本次已创建项,不拆成新命令绕过重试预算。
-- 这套 `0/2/1` 是 **Agent/host** 编排预算,适用于 16 个 EventKey:`retryable=false` 对应 `max_additional_attempts=0`;`retryable=true` 对应 `max_additional_attempts=2`;`retryable=unknown` 对应 `max_additional_attempts=1`。它不是 CLI 持久化硬总次数上限;每次调用最多创建一次,进程内不会自动重试,CLI 也不持久化或计算跨调用的 Agent/host 尝试次数。
+- 这套 `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),不要在正常路径预加载完整运维手册。
### 本地订阅保护契约
@@ -98,8 +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) | 订阅控制或失败恢复 |
+| 任务索引 | [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-event/references/event-oa.md b/skills/multi/dingtalk-event/references/event-oa.md
new file mode 100644
index 00000000..6d7ec4e2
--- /dev/null
+++ b/skills/multi/dingtalk-event/references/event-oa.md
@@ -0,0 +1,149 @@
+# OA 个人审批事件
+
+先读事件产品入口 [SKILL.md](../SKILL.md) 的命令规则、调用流和子进程契约。本参考覆盖当前公开的六个 OA 个人事件:审批实例发起、终止和完成,以及审批任务创建、完成和转交。
+
+实时监听审批事件必须使用 `dws event consume` 长连接,不要轮询 OA 待办或审批实例列表来模拟事件。
+
+## Prerequisite
+
+OA 个人事件使用当前用户 OAuth 登录态。未登录或 token 失效时,先执行:
+
+```bash
+dws auth login
+```
+
+非默认组织使用全局 `--profile `。事件范围始终是该 OAuth 用户相关的全部 OA 事件,不需要也不接受审批人、发起人或审批模板选择参数。
+
+## Event catalog
+
+| 事件码 | 订阅规则 | 接收语义 | 必填参数 |
+|---|---|---|---|
+| `user_oa_approval_task_created` | `all` | 审批任务创建,发送给审批人 | 无 |
+| `user_oa_approval_task_finished` | `all` | 审批任务已完成 | 无 |
+| `user_oa_approval_task_redirected` | `all` | 审批任务已转交 | 无 |
+| `user_oa_approval_instance_started` | `all` | 审批实例已发起 | 无 |
+| `user_oa_approval_instance_terminated` | `all` | 审批实例已终止 | 无 |
+| `user_oa_approval_instance_finished` | `all` | 审批实例完成,发送给审批单发起人 | 无 |
+
+只承认上表 6 个 OA 事件码。CLI 为每个事件发送 `ruleType=all`、`filterRule={}` 的独立订阅请求;不要添加 `--user`、`--open-dingtalk-id`、`--group`、`--query` 或 `--filter-json`。
+
+## Intent mapping
+
+| 用户说 | 下一步 |
+|---|---|
+| “监听新的待我审批任务” / “有审批任务创建时通知我” | `dws event consume user_oa_approval_task_created --flatten -f ndjson` |
+| “审批任务完成时通知我” | `dws event consume user_oa_approval_task_finished --flatten -f ndjson` |
+| “审批任务被转交时通知我” | `dws event consume user_oa_approval_task_redirected --flatten -f ndjson` |
+| “有审批单发起时通知我” | `dws event consume user_oa_approval_instance_started --flatten -f ndjson` |
+| “有审批单终止时通知我” | `dws event consume user_oa_approval_instance_terminated --flatten -f ndjson` |
+| “监听我发起的审批何时完成” / “审批实例完成时通知我” | `dws event consume user_oa_approval_instance_finished --flatten -f ndjson` |
+| “同时监听全部已公开 OA 事件” | 一个 consume 放入六个 OA event key,不加目标或过滤参数 |
+| “查看 OA 事件目录” | `dws event list --category oa` |
+| “查看 OA 事件输出字段” | 对对应事件运行 `dws event schema --flatten` |
+
+三个审批任务事件分别表达任务已创建、已完成和已转交;三个审批实例事件分别表达实例已发起、已终止和已完成。扁平字段来自六类事件的预发联调样本;`status` 和 `result` 保留服务端原值,不把当前样本值推断为完整枚举。
+
+## Commands
+
+查看稳定的扁平输出 schema:
+
+```bash
+dws event schema user_oa_approval_task_created --flatten
+dws event schema user_oa_approval_task_finished --flatten
+dws event schema user_oa_approval_task_redirected --flatten
+dws event schema user_oa_approval_instance_started --flatten
+dws event schema user_oa_approval_instance_terminated --flatten
+dws event schema user_oa_approval_instance_finished --flatten
+```
+
+单独监听一种事件:
+
+```bash
+dws event consume user_oa_approval_task_created --flatten -f ndjson
+dws event consume user_oa_approval_task_finished --flatten -f ndjson
+dws event consume user_oa_approval_task_redirected --flatten -f ndjson
+dws event consume user_oa_approval_instance_started --flatten -f ndjson
+dws event consume user_oa_approval_instance_terminated --flatten -f ndjson
+dws event consume user_oa_approval_instance_finished --flatten -f ndjson
+```
+
+同时监听六种事件:
+
+```bash
+dws event consume \
+ 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 会为六个 event key 分别创建订阅和逻辑 consumer,并共享当前组织的 personal bus、远程连接、stdout 和生命周期。不要给 OA 命令加 `--query` 或 `--filter-json`;这两个 flag 只用于兼容的 IM 消息接收事件。
+
+## Output contract
+
+`--flatten` 模式的所有 OA 事件都包含以下顶层字段:
+
+```json
+{
+ "type": "user_oa_approval_task_created",
+ "event_id": "...",
+ "timestamp": 0,
+ "subscribe_id": "...",
+ "process_instance_id": "...",
+ "process_code": "...",
+ "title": "...",
+ "status": "RUNNING",
+ "create_time": 0,
+ "event_time": 0
+}
+```
+
+- `type` 是当前 event key;`event_id` 可用于去重;`timestamp` 是 transport 事件发生时间;`subscribe_id` 标识对应的独立订阅。
+- `process_instance_id` 是审批实例 ID,可传给 OA 审批命令的 `--instance-id`;`process_code` 是审批流程模板编码。
+- `create_time`、`finish_time` 和 `event_time` 都是毫秒时间戳。`event_time` 是审批业务事件时间,`timestamp` 是 transport 事件时间。
+- 六类事件的额外字段如下;具体事件始终以 `dws event schema --flatten` 为准。
+
+| 事件 | 额外顶层字段 |
+|---|---|
+| `user_oa_approval_task_created` | `task_id` |
+| `user_oa_approval_task_finished` | `task_id`、`result`、`finish_time` |
+| `user_oa_approval_task_redirected` | `task_id`、`result`、`finish_time` |
+| `user_oa_approval_instance_started` | 无 |
+| `user_oa_approval_instance_terminated` | `finish_time` |
+| `user_oa_approval_instance_finished` | `result`、`finish_time` |
+
+任务完成事件示例:
+
+```json
+{
+ "type": "user_oa_approval_task_finished",
+ "event_id": "...",
+ "timestamp": 0,
+ "subscribe_id": "...",
+ "process_instance_id": "...",
+ "process_code": "...",
+ "task_id": "...",
+ "title": "测试审批",
+ "status": "FINISHED",
+ "result": "agree",
+ "create_time": 0,
+ "finish_time": 0,
+ "event_time": 0
+}
+```
+
+- `task_id` 是当前审批任务 ID,可传给接受任务 ID 的 OA 审批命令。
+- `status` 和 `result` 是服务端字符串;不要只根据当前样本把 `RUNNING/FINISHED/TERMINATED` 或 `agree/redirect` 写成封闭枚举。
+- payload 缺失、为空、缺少对应事件的稳定 ID 或无法解析时,consume 会在 stderr 记录 warning,并把原始 transport envelope 写到 stdout,保证事件不被静默丢弃。
+- 不传 `--flatten` 时保持兼容 transport envelope,业务 payload 位于 `.data | fromjson`。需要联调完整原始协议时使用不带 `--flatten` 的 `-f raw` 或 `--debug-raw-events`。
+
+## Lifecycle
+
+- 单事件等待 `[event] ready event_key= bus_pid= subscribe_id=`。
+- 六事件先保存六条 `[event] subscription event_key= subscribe_id=`,再等待 `[event] ready event_count=6 bus_pid=`。
+- 临时验证使用 `--max-events 1` 或 `--duration 10m`;任务完成后优雅结束 consume,本次新建的订阅会自动取消。
+- 外部停止已有订阅时先运行 `dws event stop --dry-run`,确认后再加 `--yes`。不要 `kill -9`,否则会跳过自动退订。
diff --git a/skills/multi/dingtalk-misc/SKILL.md b/skills/multi/dingtalk-misc/SKILL.md
index 668671ba..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事件/组织大脑Hrbrain/原生Markdown/PAT行为授权/多组织profile。Use when 用户提到上述任一产品,或审批/打卡/排班/OKR/日报周报/单元格读写/白板节点读写/搜索安装技能/开发者后台应用/实时监听消息/被@消息/监听某人或某群/监听已读撤回表情回应/个人 IM 事件/群成员加入/群成员退出/群状态变化/事件驱动 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 事件 / 实时监听 / 被@消息 / 监听某人某群 / 已读撤回表情回应 / 群成员加入退出 / 事件驱动 Agent | 个人 IM 事件长连接监听、订阅与消费 | `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 事件命中后先读 [event.md](references/event.md),再按需加载 `event-im*.md`;不要与开放平台应用事件 [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 faefe8ca..75e9b2c5 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 TestCrossPlatformCoverageDocSkillPinsExactVersionRoutesAndHelpBudget(t *testing.T) {
_, filename, _, ok := runtime.Caller(0)
if !ok {