diff --git a/internal/app/event_command.go b/internal/app/event_command.go index 89cbbf39..2953e28d 100644 --- a/internal/app/event_command.go +++ b/internal/app/event_command.go @@ -343,7 +343,7 @@ SIGTERM、关 stdin,或先用 dws event stop --dry-run 预览 f.StringVar(&personalOpts.GroupID, "group", "", "group 规则:openConversationId") f.StringVar(&personalOpts.ControlBaseURL, "personal-event-base-url", "", - "个人事件控制面 base URL;默认由 MCP base 派生 /dws") + "个人事件控制面 base URL;当前默认预发 https://pre-mcp.dingtalk.com/dws") f.BoolVar(&personalOpts.DebugRawEvents, "debug-raw-events", false, "个人事件联调:绕过本地 event type/subscribe_id 过滤,输出当前 personal stream bus 收到的所有事件") f.StringVar(&streamOpts.Mode, "stream-ticket-mode", strings.TrimSpace(os.Getenv("DWS_STREAM_TICKET_MODE")), @@ -351,7 +351,7 @@ SIGTERM、关 stdin,或先用 dws event stop --dry-run 预览 f.StringVar(&streamOpts.SourceID, "stream-source-id", strings.TrimSpace(os.Getenv("DWS_STREAM_SOURCE_ID")), "个人 Stream sourceId;开源版默认 open,可由 edition 覆盖") f.StringVar(&streamOpts.TicketURL, "stream-ticket-url", strings.TrimSpace(os.Getenv("DWS_STREAM_TICKET_URL")), - "个人 Stream 取票 URL;默认由 MCP base 派生 /stream/connections/ticket") + "个人 Stream 取票 URL;当前默认预发 https://pre-mcp.dingtalk.com/stream/connections/ticket") hideEventInternalFlags(cmd, "as") cli.AnnotateRuntimePositionals(cmd, cli.RuntimeSchemaPositional{ Name: "event_key", @@ -792,7 +792,7 @@ func newEventStatusCommand() *cobra.Command { cmd.Flags().StringVar(&personalOpts.EventKey, "event", "", "个人事件 event_key 过滤") cmd.Flags().StringVar(&personalOpts.Status, "status", "active", "个人订阅状态过滤: active|paused|error|deleted|all") cmd.Flags().StringVar(&personalOpts.SubscribeID, "subscribe-id", "", "个人订阅 ID 过滤") - cmd.Flags().StringVar(&personalOpts.ControlBaseURL, "personal-event-base-url", "", "个人事件控制面 base URL;默认由 MCP base 派生 /dws") + cmd.Flags().StringVar(&personalOpts.ControlBaseURL, "personal-event-base-url", "", "个人事件控制面 base URL;当前默认预发 https://pre-mcp.dingtalk.com/dws") cmd.Flags().StringVar(&personalOpts.StreamSourceID, "stream-source-id", strings.TrimSpace(os.Getenv("DWS_STREAM_SOURCE_ID")), "个人事件 sourceId;开源版默认 open,可由 edition 覆盖") hideEventInternalFlags(cmd, "as", "all", "all-editions", "client-id", "fail-on-orphan") @@ -1090,7 +1090,7 @@ func newEventStopCommand() *cobra.Command { }, } cmd.Flags().StringVar(&asIdentity, "as", "user", "事件身份: user") - cmd.Flags().StringVar(&opts.ControlBaseURL, "personal-event-base-url", "", "个人事件控制面 base URL;默认由 MCP base 派生 /dws") + cmd.Flags().StringVar(&opts.ControlBaseURL, "personal-event-base-url", "", "个人事件控制面 base URL;当前默认预发 https://pre-mcp.dingtalk.com/dws") cmd.Flags().StringVar(&opts.StreamSourceID, "stream-source-id", strings.TrimSpace(os.Getenv("DWS_STREAM_SOURCE_ID")), "个人事件 sourceId;开源版默认 open,可由 edition 覆盖") cmd.Flags().BoolVar(&opts.All, "all", false, "取消当前身份下本地记录的所有个人订阅") diff --git a/internal/app/event_personal_command.go b/internal/app/event_personal_command.go index 372c0604..4f783898 100644 --- a/internal/app/event_personal_command.go +++ b/internal/app/event_personal_command.go @@ -38,11 +38,12 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/source" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" "github.com/spf13/cobra" ) +const defaultPersonalEventMCPBaseURL = "https://pre-mcp.dingtalk.com" + type commonConsumeOptions struct { EventTypes []string Filter string @@ -229,6 +230,9 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions if err := ensurePublicPersonalEvent(opts.EventKey); err != nil { return err } + if err := validatePersonalOAOptions(opts.EventKey, opts); err != nil { + return fmt.Errorf("event consume --as user: %w", err) + } rawFormat := "" if f := c.Flags().Lookup("format"); f != nil && f.Changed { rawFormat = opts.Common.FormatRaw @@ -567,6 +571,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 @@ -693,6 +700,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, @@ -705,12 +715,49 @@ 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 +} + func ensurePersonalSubscription(ctx context.Context, client *personal.Client, identity personal.Identity, opts personalConsumeOptions) (*personal.Subscription, string, string, error) { if strings.TrimSpace(opts.SubscribeID) != "" { sub, err := personalGetSubscription(client, ctx, opts.SubscribeID) if err != nil { return nil, "", "", err } + lookupEventKey := strings.TrimSpace(sub.EventKey) + if lookupEventKey != "" { + if err := validatePersonalOAOptions(lookupEventKey, opts); err != nil { + return nil, "", "", err + } + } eventKey := firstNonEmptyPersonalString(opts.EventKey, sub.EventKey) if eventKey == "" { return nil, "", "", fmt.Errorf("event_key is required when --subscribe-id lookup returns no event_key") @@ -718,6 +765,9 @@ func ensurePersonalSubscription(ctx context.Context, client *personal.Client, id 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 { @@ -733,6 +783,9 @@ func ensurePersonalSubscription(ctx context.Context, client *personal.Client, id if err := ensurePublicPersonalEvent(opts.EventKey); err != nil { return nil, "", "", err } + if err := validatePersonalOAOptions(opts.EventKey, opts); err != nil { + return nil, "", "", err + } ruleType, ruleParam, err := personal.BuildRuleParam(opts.EventKey, personal.RuleOptions{ RuleType: opts.Rule, UserID: opts.UserID, @@ -1300,7 +1353,7 @@ func personalEventMCPBaseURL(configDir string) string { if v := configuredMCPBaseURL(configDir); v != "" { return strings.TrimRight(v, "/") } - return strings.TrimRight(config.DefaultMCPBaseURL, "/") + return defaultPersonalEventMCPBaseURL } func configuredMCPBaseURL(configDir string) string { diff --git a/internal/app/event_personal_identity_test.go b/internal/app/event_personal_identity_test.go index afdbdbe7..5448c68c 100644 --- a/internal/app/event_personal_identity_test.go +++ b/internal/app/event_personal_identity_test.go @@ -133,18 +133,18 @@ func TestResolvePersonalEventIdentityDefaultsSourceIDToOpen(t *testing.T) { } } -func TestPersonalEventDefaultsUseProductionWithoutMCPConfig(t *testing.T) { +func TestPersonalEventDefaultsUsePreReleaseWithoutMCPConfig(t *testing.T) { dir := t.TempDir() t.Setenv("DWS_CONFIG_DIR", dir) prev := edition.Get() edition.Override(&edition.Hooks{}) t.Cleanup(func() { edition.Override(prev) }) - if got := personalEventControlBaseURL("", dir); got != "https://mcp.dingtalk.com/dws" { - t.Fatalf("personalEventControlBaseURL() = %q, want production control URL", got) + if got := personalEventControlBaseURL("", dir); got != "https://pre-mcp.dingtalk.com/dws" { + t.Fatalf("personalEventControlBaseURL() = %q, want pre-release control URL", got) } - if got := personalEventStreamTicketURL("", dir); got != "https://mcp.dingtalk.com/stream/connections/ticket" { - t.Fatalf("personalEventStreamTicketURL() = %q, want production ticket URL", got) + if got := personalEventStreamTicketURL("", dir); got != "https://pre-mcp.dingtalk.com/stream/connections/ticket" { + t.Fatalf("personalEventStreamTicketURL() = %q, want pre-release ticket URL", got) } if got := personalEventStreamSourceID(""); got != "open" { t.Fatalf("personalEventStreamSourceID() = %q, want open", got) diff --git a/internal/app/event_personal_oa_test.go b/internal/app/event_personal_oa_test.go new file mode 100644 index 00000000..4ff33c38 --- /dev/null +++ b/internal/app/event_personal_oa_test.go @@ -0,0 +1,484 @@ +// 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) + } + for _, eventKey := range []string{ + personal.EventOAApprovalTaskCreated, + personal.EventOAApprovalInstanceFinished, + } { + 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 { + t.Fatalf("schema properties for %s = %#v", eventKey, schemaBody["properties"]) + } + payload, ok := properties["payload"].(map[string]any) + if !ok || payload["additionalProperties"] != true { + t.Fatalf("schema payload for %s = %#v, want open object", eventKey, properties["payload"]) + } + } + 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 + t.Cleanup(func() { personalResolveEventIdentity = oldIdentity }) + personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) { + return personal.Identity{ + AccessToken: "token", + LocalSubject: "subject", + ClientID: "client", + SourceID: "open", + }, nil + } + + oaEvents := []string{ + personal.EventOAApprovalTaskCreated, + 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([]string{oaEvents[0], oaEvents[1], "--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"}`}, + } + 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 explicitEventKey { + if err == nil || !strings.Contains(err.Error(), flag+" not supported for OA event") { + t.Fatalf("explicit reused OA dry-run %s error = %v", flag, err) + } + return + } + if err != nil { + t.Fatalf("implicit reused IM-compatible subscription dry-run %s error = %v", flag, 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) + cmd.SetArgs([]string{oaEvents[0], oaEvents[1], flag, value, "--dry-run"}) + 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 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.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/cli/schema_agent_metadata/index.json b/internal/cli/schema_agent_metadata/index.json index 6195ecce..c26af7b9 100644 --- a/internal/cli/schema_agent_metadata/index.json +++ b/internal/cli/schema_agent_metadata/index.json @@ -1,6 +1,6 @@ { "version": 1, - "source_hash": "sha256:557f4019cc83df15cbfe6d1611774331b0bdde87c0b5748c4d37d778b35abade", + "source_hash": "sha256:8fcdd1f039a25ff4b9f868fe1955d1bad87d3177400b11384da6b314151cef29", "surface_hash": "sha256:4cf8460240b19f896c3a330c69982cbb5f57aa8576cdf30373172082eea893ed", "coverage": { "surface_products": 26, diff --git a/internal/cli/schema_agent_metadata_audit.json b/internal/cli/schema_agent_metadata_audit.json index ad43c166..ebcbc0dc 100644 --- a/internal/cli/schema_agent_metadata_audit.json +++ b/internal/cli/schema_agent_metadata_audit.json @@ -1,6 +1,6 @@ { "version": 1, - "source_hash": "sha256:557f4019cc83df15cbfe6d1611774331b0bdde87c0b5748c4d37d778b35abade", + "source_hash": "sha256:8fcdd1f039a25ff4b9f868fe1955d1bad87d3177400b11384da6b314151cef29", "surface_hash": "sha256:4cf8460240b19f896c3a330c69982cbb5f57aa8576cdf30373172082eea893ed", "source_files": 160, "hint_files": 54, @@ -164,7 +164,7 @@ { "tool_path": "calendar participant delete", "source": "skills/mono/SKILL.md", - "line": 150, + "line": 151, "candidates": [ "calendar attendee delete", "calendar event delete", @@ -2800,7 +2800,7 @@ { "tool_path": "event consume user_im_message_receive_o2o_all", "source": "skills/mono/references/products/event.md", - "line": 117, + "line": 125, "candidates": [ "event consume", "event list", @@ -2810,7 +2810,7 @@ { "tool_path": "event consume user_im_message_receive_group_all", "source": "skills/mono/references/products/event.md", - "line": 118, + "line": 126, "candidates": [ "event consume", "event list", @@ -2820,7 +2820,7 @@ { "tool_path": "event consume user_im_message_receive_o2o user_im_message_read_o2o user_im_message_recall_o2o", "source": "skills/mono/references/products/event.md", - "line": 134, + "line": 144, "candidates": [ "event consume", "event list", @@ -2830,7 +2830,7 @@ { "tool_path": "event consume user_im_message_receive_group user_im_group_updated user_im_group_disbanded", "source": "skills/mono/references/products/event.md", - "line": 142, + "line": 152, "candidates": [ "event consume", "event list", diff --git a/internal/cli/schema_catalog.json b/internal/cli/schema_catalog.json index c5928cb7..93badc4c 100644 --- a/internal/cli/schema_catalog.json +++ b/internal/cli/schema_catalog.json @@ -1,12 +1,12 @@ { "version": 1, - "source_hash": "sha256:56946c5c8d966b0f0803e575dd2f86e2733c2fc6697bdc6e799aae23a363981c", + "source_hash": "sha256:d75c65a5a9c33a781c365d20c83171f1c4278a913f15abd8d6c583fa13d58a6f", "surface_hash": "sha256:4cf8460240b19f896c3a330c69982cbb5f57aa8576cdf30373172082eea893ed", "catalog": { "agent_metadata": { "products_with_metadata": 26, "source": "embedded-skill-metadata", - "source_hash": "sha256:557f4019cc83df15cbfe6d1611774331b0bdde87c0b5748c4d37d778b35abade", + "source_hash": "sha256:8fcdd1f039a25ff4b9f868fe1955d1bad87d3177400b11384da6b314151cef29", "surface_hash": "sha256:4cf8460240b19f896c3a330c69982cbb5f57aa8576cdf30373172082eea893ed", "surface_products": 26, "surface_tools": 813, @@ -399950,7 +399950,7 @@ "type": "string" }, "personal-event-base-url": { - "description": "个人事件控制面 base URL;默认由 MCP base 派生 /dws", + "description": "个人事件控制面 base URL;当前默认预发 https://pre-mcp.dingtalk.com/dws", "field_provenance": { "description": { "candidates": [ @@ -399958,7 +399958,7 @@ "precedence": "cobra_contract", "selected": true, "source": "cobra_usage", - "value": "个人事件控制面 base URL;默认由 MCP base 派生 /dws" + "value": "个人事件控制面 base URL;当前默认预发 https://pre-mcp.dingtalk.com/dws" }, { "precedence": "default", @@ -399970,7 +399970,7 @@ "precedence": "cobra_contract", "resolution": "highest_precedence", "source": "cobra_usage", - "value": "个人事件控制面 base URL;默认由 MCP base 派生 /dws" + "value": "个人事件控制面 base URL;当前默认预发 https://pre-mcp.dingtalk.com/dws" }, "property": { "candidates": [ @@ -400562,7 +400562,7 @@ "type": "string" }, "stream-ticket-url": { - "description": "个人 Stream 取票 URL;默认由 MCP base 派生 /stream/connections/ticket", + "description": "个人 Stream 取票 URL;当前默认预发 https://pre-mcp.dingtalk.com/stream/connections/ticket", "field_provenance": { "description": { "candidates": [ @@ -400570,7 +400570,7 @@ "precedence": "cobra_contract", "selected": true, "source": "cobra_usage", - "value": "个人 Stream 取票 URL;默认由 MCP base 派生 /stream/connections/ticket" + "value": "个人 Stream 取票 URL;当前默认预发 https://pre-mcp.dingtalk.com/stream/connections/ticket" }, { "precedence": "default", @@ -400582,7 +400582,7 @@ "precedence": "cobra_contract", "resolution": "highest_precedence", "source": "cobra_usage", - "value": "个人 Stream 取票 URL;默认由 MCP base 派生 /stream/connections/ticket" + "value": "个人 Stream 取票 URL;当前默认预发 https://pre-mcp.dingtalk.com/stream/connections/ticket" }, "property": { "candidates": [ @@ -402620,7 +402620,7 @@ "type": "string" }, "personal-event-base-url": { - "description": "个人事件控制面 base URL;默认由 MCP base 派生 /dws", + "description": "个人事件控制面 base URL;当前默认预发 https://pre-mcp.dingtalk.com/dws", "field_provenance": { "description": { "candidates": [ @@ -402628,7 +402628,7 @@ "precedence": "cobra_contract", "selected": true, "source": "cobra_usage", - "value": "个人事件控制面 base URL;默认由 MCP base 派生 /dws" + "value": "个人事件控制面 base URL;当前默认预发 https://pre-mcp.dingtalk.com/dws" }, { "precedence": "default", @@ -402640,7 +402640,7 @@ "precedence": "cobra_contract", "resolution": "highest_precedence", "source": "cobra_usage", - "value": "个人事件控制面 base URL;默认由 MCP base 派生 /dws" + "value": "个人事件控制面 base URL;当前默认预发 https://pre-mcp.dingtalk.com/dws" }, "property": { "candidates": [ @@ -403453,7 +403453,7 @@ "type": "boolean" }, "personal-event-base-url": { - "description": "个人事件控制面 base URL;默认由 MCP base 派生 /dws", + "description": "个人事件控制面 base URL;当前默认预发 https://pre-mcp.dingtalk.com/dws", "field_provenance": { "description": { "candidates": [ @@ -403461,7 +403461,7 @@ "precedence": "cobra_contract", "selected": true, "source": "cobra_usage", - "value": "个人事件控制面 base URL;默认由 MCP base 派生 /dws" + "value": "个人事件控制面 base URL;当前默认预发 https://pre-mcp.dingtalk.com/dws" }, { "precedence": "default", @@ -403473,7 +403473,7 @@ "precedence": "cobra_contract", "resolution": "highest_precedence", "source": "cobra_usage", - "value": "个人事件控制面 base URL;默认由 MCP base 派生 /dws" + "value": "个人事件控制面 base URL;当前默认预发 https://pre-mcp.dingtalk.com/dws" }, "property": { "candidates": [ diff --git a/internal/cli/schema_hints/index.json b/internal/cli/schema_hints/index.json index 37b04a52..1a1db002 100644 --- a/internal/cli/schema_hints/index.json +++ b/internal/cli/schema_hints/index.json @@ -718,6 +718,21 @@ "target": "event consume", "reason": "已审查的群解散事件 Skill 引用,固定映射到当前公开 event consume leaf" }, + "event consume user_oa_approval_task_created": { + "status": "alias", + "target": "event consume", + "reason": "已审查的 OA 审批任务创建事件 Skill 引用,固定映射到当前公开 event consume leaf" + }, + "event consume user_oa_approval_instance_finished": { + "status": "alias", + "target": "event consume", + "reason": "已审查的 OA 审批实例完成事件 Skill 引用,固定映射到当前公开 event consume leaf" + }, + "event consume user_oa_approval_task_created user_oa_approval_instance_finished": { + "status": "alias", + "target": "event consume", + "reason": "已审查的双 OA 事件 Skill 引用,固定映射到当前公开多事件 event consume leaf" + }, "event schema user_im_message_receive_at": { "status": "alias", "target": "event schema", @@ -798,6 +813,16 @@ "target": "event schema", "reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event schema leaf" }, + "event schema user_oa_approval_task_created": { + "status": "alias", + "target": "event schema", + "reason": "已审查的 OA 审批任务创建 event_key 引用,固定映射到当前公开 event schema leaf" + }, + "event schema user_oa_approval_instance_finished": { + "status": "alias", + "target": "event schema", + "reason": "已审查的 OA 审批实例完成 event_key 引用,固定映射到当前公开 event schema leaf" + }, "mail aisearch person": { "status": "alias", "target": "aisearch person", diff --git a/internal/cli/schema_hints/reference-review.json b/internal/cli/schema_hints/reference-review.json index 8ae91844..2c36dc85 100644 --- a/internal/cli/schema_hints/reference-review.json +++ b/internal/cli/schema_hints/reference-review.json @@ -661,6 +661,21 @@ "target": "event consume", "reason": "已审查的群解散事件 Skill 引用,固定映射到当前公开 event consume leaf" }, + "event consume user_oa_approval_task_created": { + "status": "alias", + "target": "event consume", + "reason": "已审查的 OA 审批任务创建事件 Skill 引用,固定映射到当前公开 event consume leaf" + }, + "event consume user_oa_approval_instance_finished": { + "status": "alias", + "target": "event consume", + "reason": "已审查的 OA 审批实例完成事件 Skill 引用,固定映射到当前公开 event consume leaf" + }, + "event consume user_oa_approval_task_created user_oa_approval_instance_finished": { + "status": "alias", + "target": "event consume", + "reason": "已审查的双 OA 事件 Skill 引用,固定映射到当前公开多事件 event consume leaf" + }, "event schema user_im_message_receive_at": { "status": "alias", "target": "event schema", @@ -741,6 +756,16 @@ "target": "event schema", "reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event schema leaf" }, + "event schema user_oa_approval_task_created": { + "status": "alias", + "target": "event schema", + "reason": "已审查的 OA 审批任务创建 event_key 引用,固定映射到当前公开 event schema leaf" + }, + "event schema user_oa_approval_instance_finished": { + "status": "alias", + "target": "event schema", + "reason": "已审查的 OA 审批实例完成 event_key 引用,固定映射到当前公开 event schema leaf" + }, "mail aisearch person": { "status": "alias", "target": "aisearch person", diff --git a/internal/event/personal/client_test.go b/internal/event/personal/client_test.go index 6ac034b6..23aac7d9 100644 --- a/internal/event/personal/client_test.go +++ b/internal/event/personal/client_test.go @@ -127,6 +127,8 @@ 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_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..528f4106 100644 --- a/internal/event/personal/output.go +++ b/internal/event/personal/output.go @@ -121,6 +121,17 @@ type GroupLifecycleEventOutput struct { Payload map[string]any `json:"payload" description:"群生命周期事件业务数据,字段以服务端实际推送为准" additional_properties:"true"` } +// OAEventOutput intentionally keeps the OA payload open until stable approval +// samples and status enums are available. Only top-level transport routing +// metadata is removed; all unknown business fields are preserved. +type OAEventOutput 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"` + Payload map[string]any `json:"payload" description:"OA 审批事件业务数据,字段以服务端实际推送为准" additional_properties:"true"` +} + type GroupMemberEventOutput struct { Type string `json:"type" description:"事件类型,固定为当前 event_key"` EventID string `json:"event_id" description:"事件 ID,可用于去重"` @@ -343,7 +354,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 +365,18 @@ func ProjectOutput(ev transport.Event) (any, error) { SubscribeID: base.SubscribeID, Payload: payload, }, nil + case isOAEvent(eventType): + payload, err := decodeConservativePayload(data.Payload) + if err != nil { + return ev, fmt.Errorf("decode personal OA payload: %w", err) + } + return OAEventOutput{ + Type: base.Type, + EventID: base.EventID, + Timestamp: base.Timestamp, + SubscribeID: base.SubscribeID, + Payload: payload, + }, nil default: return ev, fmt.Errorf("unsupported personal event type %q", eventType) } @@ -370,7 +393,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") @@ -651,6 +674,8 @@ func outputTypeForEvent(eventKey string) reflect.Type { return reflect.TypeOf(GroupMemberEventOutput{}) case isGroupLifecycleEvent(eventKey): return reflect.TypeOf(GroupLifecycleEventOutput{}) + case isOAEvent(eventKey): + return reflect.TypeOf(OAEventOutput{}) default: return reflect.TypeOf(baseEventOutput{}) } @@ -677,6 +702,11 @@ func isGroupLifecycleEvent(eventKey string) bool { eventKey == EventGroupDisbanded } +func isOAEvent(eventKey string) bool { + return eventKey == EventOAApprovalTaskCreated || + eventKey == EventOAApprovalInstanceFinished +} + 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..8b78bf4b 100644 --- a/internal/event/personal/output_test.go +++ b/internal/event/personal/output_test.go @@ -150,6 +150,28 @@ func personalGroupMemberData(eventKey string) string { }`, eventKey) } +func personalOAData(eventKey string) string { + return fmt.Sprintf(`{ + "eventId":"oa-event", + "eventKey":%q, + "occurredAtMs":1785229200123, + "subId":"oa-data-sub", + "payload":{ + "uid":100001, + "CORPID":"internal-corp", + "clientId":"internal-client", + "filterSubId":"internal-filter", + "bizid":"internal-biz", + "orgId":100002, + "sourceId":"open", + "processInstanceId":"process-instance-1", + "taskId":"approval-task-1", + "futureField":{"nested":true,"uid":"business-user-1"}, + "steps":[{"name":"审批","result":"agree"}] + } + }`, eventKey) +} + func TestCrossPlatformCoverageProjectOutputMessageEvents(t *testing.T) { for _, eventKey := range []string{EventMention, EventSingleChat, EventInChat, EventFromUser, EventAllSingleChat, EventAllGroupChat} { t.Run(eventKey, func(t *testing.T) { @@ -396,6 +418,69 @@ func TestCrossPlatformCoverageProjectOutputGroupLifecycleEvents(t *testing.T) { } } +func TestCrossPlatformCoverageProjectOutputOAEvents(t *testing.T) { + for _, eventKey := range []string{EventOAApprovalTaskCreated, EventOAApprovalInstanceFinished} { + t.Run(eventKey, func(t *testing.T) { + projected, err := ProjectOutput(transport.Event{ + EventID: "outer-event", + EventBornTime: 11, + EventType: eventKey, + SubscribeID: "outer-sub", + Data: personalOAData(eventKey), + }) + if err != nil { + t.Fatalf("ProjectOutput() error = %v", err) + } + got, ok := projected.(OAEventOutput) + if !ok { + t.Fatalf("ProjectOutput() type = %T, want OAEventOutput", projected) + } + if got.Type != eventKey || got.EventID != "oa-event" || got.Timestamp != 1785229200123 || got.SubscribeID != "outer-sub" { + t.Fatalf("common fields = %#v", got) + } + for _, internal := range []string{"uid", "CORPID", "clientId", "filterSubId", "bizid", "orgId", "sourceId"} { + if _, ok := got.Payload[internal]; ok { + t.Fatalf("payload retained internal field %q: %#v", internal, got.Payload) + } + } + if got.Payload["processInstanceId"] != "process-instance-1" || got.Payload["taskId"] != "approval-task-1" { + t.Fatalf("payload lost known business fields: %#v", got.Payload) + } + future, ok := got.Payload["futureField"].(map[string]any) + if !ok || future["nested"] != true || future["uid"] != "business-user-1" { + t.Fatalf("payload lost unknown or nested business fields: %#v", got.Payload["futureField"]) + } + steps, ok := got.Payload["steps"].([]any) + if !ok || len(steps) != 1 { + t.Fatalf("payload lost unknown array field: %#v", got.Payload["steps"]) + } + }) + } +} + +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.(OAEventOutput) + if !ok { + t.Fatalf("ProjectOutput() type = %T, want OAEventOutput", 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 +634,41 @@ 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"`}, + } + for _, eventKey := range []string{EventOAApprovalTaskCreated, 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 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..5b4178c4 100644 --- a/internal/event/personal/registry.go +++ b/internal/event/personal/registry.go @@ -23,22 +23,24 @@ 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" + EventOAApprovalInstanceFinished = "user_oa_approval_instance_finished" ) const ( @@ -273,6 +275,28 @@ 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: 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..e1b49481 100644 --- a/internal/event/personal/registry_test.go +++ b/internal/event/personal/registry_test.go @@ -46,12 +46,36 @@ func TestCatalogEnabledEvents(t *testing.T) { EventGroupMemberAdded, EventGroupMemberExited, EventGroupDisbanded, + EventOAApprovalTaskCreated, + 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, 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 +143,8 @@ func TestSchemaDocumentsDefaultToTransportEnvelope(t *testing.T) { EventGroupMemberAdded, EventGroupMemberExited, EventGroupDisbanded, + EventOAApprovalTaskCreated, + EventOAApprovalInstanceFinished, } { t.Run(eventKey, func(t *testing.T) { def, ok := Lookup(eventKey) @@ -433,6 +459,39 @@ func TestGroupLifecycleSchemaDocumentsUseConservativePayload(t *testing.T) { } } +func TestOAEventSchemaDocumentsUseConservativePayload(t *testing.T) { + wantProperties := []string{"type", "event_id", "timestamp", "subscribe_id", "payload"} + for _, eventKey := range []string{EventOAApprovalTaskCreated, EventOAApprovalInstanceFinished} { + t.Run(eventKey, func(t *testing.T) { + def, ok := Lookup(eventKey) + if !ok { + t.Fatalf("Lookup(%q) failed", 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(wantProperties) { + t.Fatalf("schema.properties = %#v, want exactly %d fields", doc.Schema["properties"], len(wantProperties)) + } + for _, name := range wantProperties { + 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{eventKey}) { + t.Fatalf("schema.properties.type.enum = %#v, want %q", eventType["enum"], eventKey) + } + payload := props["payload"].(map[string]any) + if payload["type"] != "object" || payload["additionalProperties"] != true { + t.Fatalf("schema.properties.payload = %#v, want open object", payload) + } + }) + } +} + func TestGroupMemberSchemaDocumentsMatchOutputDTO(t *testing.T) { wantProperties := []string{ "type", "event_id", "timestamp", "subscribe_id", "conversation_id", @@ -495,7 +554,12 @@ func TestBuildRuleParamMention(t *testing.T) { } func TestBuildRuleParamAllEvents(t *testing.T) { - for _, eventKey := range []string{EventAllSingleChat, EventAllGroupChat} { + for _, eventKey := range []string{ + EventAllSingleChat, + EventAllGroupChat, + EventOAApprovalTaskCreated, + EventOAApprovalInstanceFinished, + } { t.Run(eventKey, func(t *testing.T) { rule, param, err := BuildRuleParam(eventKey, RuleOptions{}) if err != nil { @@ -701,6 +765,8 @@ func TestSupportsMessageFilter(t *testing.T) { EventReadO2O, EventReactionGroup, EventGroupUpdated, + EventOAApprovalTaskCreated, + EventOAApprovalInstanceFinished, "unknown_event", } { if SupportsMessageFilter(eventKey) { diff --git a/skills/mono/SKILL.md b/skills/mono/SKILL.md index 2724e805..55ff2623 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/) 下的 `python scripts/.py` 已封装翻页/轮询/批量逻辑,遇到对应场景(如 AI 表格批量导入导出、AI 应用创建轮询、文档创建后写内容、钉盘目录树等)**优先调用脚本**而非手写多步命令。脚本均支持 `--dry-run` 预览、`--format json` 输出,失败时回退到手动步骤 -- **实时个人消息事件例外**:用户要监听消息、订阅事件、自动回复消息或事件驱动 Agent 时,必须走 `dws event consume ... --flatten` 长连接,不要写脚本轮询消息历史 +- **实时个人事件例外**:用户要监听消息或 OA 审批事件、订阅事件、自动回复消息或事件驱动 Agent 时,必须走 `dws event consume ... --flatten` 长连接,不要写脚本轮询消息历史或审批列表 ## Shortcut 与原子命令的使用原则 @@ -94,7 +94,7 @@ cli_version: ">=1.0.15" | `sheet` | 在线电子表格(axls):工作表 CRUD/区域读写/CSV 批量写入/行列增删/合并/查找替换/筛选视图/全局筛选/排序/下拉列表/条件格式/浮动图片/浮动图表/模板/导出 xlsx(单命令一站式) | [sheet.md](./references/products/sheet.md) | | `todo` | 待办:创建(含优先级/截止时间/循环)/查询/修改/标记完成/删除 | [todo.md](./references/products/todo.md) | | `wiki` | 知识库:空间创建/详情/列表/搜索 + 成员管理 | [wiki.md](./references/products/wiki.md) | -| `event` | 个人 IM 事件:监听消息接收、指定发送人、已读、撤回、表情回应,NDJSON 输出(实时驱动 Agent)| [event.md](./references/products/event.md) | +| `event` | 个人 IM/OA 事件:监听消息、群生命周期、审批任务创建与审批实例完成,NDJSON 输出(实时驱动 Agent)| [event.md](./references/products/event.md) | ## 意图判断决策树 @@ -120,7 +120,7 @@ cli_version: ">=1.0.15" 用户提到"在线电子表格/钉钉表格/axls/工作表/单元格读写/合并单元格/筛选视图/导出 xlsx" → `sheet` 用户提到"待办/TODO/任务提醒/循环待办" → `todo` 用户提到"创建知识库/知识库列表/搜索知识库空间/wiki/团队空间/知识库成员管理/我的文档个人空间" → `wiki` -用户提到"监听有人@我/监听单聊或群消息/监听所有单聊或群消息/监听某人发送的消息/监听消息已读/监听消息撤回/监听消息贴表情或表情回应/监听群成员加入/监听群成员退出/监听群改名或群解散/订阅个人 IM 事件/实时接收钉钉事件/个人事件流/event consume user_im_message_*/监听并自动回复消息/驱动 Agent 处理消息" → `event` +用户提到"监听有人@我/监听单聊或群消息/监听所有单聊或群消息/监听某人发送的消息/监听消息已读/监听消息撤回/监听消息贴表情或表情回应/监听群成员加入/监听群成员退出/监听群改名或群解散/监听待我审批的任务/监听审批任务创建/监听我发起的审批完成/监听审批实例完成/订阅个人 IM 或 OA 事件/实时接收钉钉事件/个人事件流/event consume user_im_message_*/event consume user_oa_approval_*/监听并自动回复消息/驱动 Agent 处理消息" → `event` 事件监听中,同一目标、同一过滤条件的多个兼容事件优先生成一个 `dws event consume [event_key...] --flatten`;不同用户、不同群或不同过滤条件拆成多个 consume 进程。 @@ -130,6 +130,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 a00ecdfd..5304845a 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。实时监听、自动回复、订阅事件都必须使用 `dws event consume`,不要写脚本轮询消息历史。 +通过个人 Stream 长连接监听当前用户的钉钉消息接收、全量消息、已读、撤回、表情回应、群生命周期、审批任务创建和审批实例完成事件,NDJSON 输出到 stdout,用于驱动事件触发的 Agent。实时监听、自动回复、订阅事件都必须使用 `dws event consume`,不要写脚本轮询消息历史或审批列表。 ## 运行方式 @@ -13,6 +13,7 @@ | Command | Purpose | |---|---| +| `dws event list --category oa` | 查看当前公开的 OA 个人事件目录 | | `dws event schema --flatten` | 查看 Agent 使用的顶层业务字段 schema | | `dws event consume [event_key...] --flatten [flags]` | 阻塞消费一个或多个兼容事件,事件写到 stdout,用 `-f ndjson` | | `dws event status --event ` | 查看个人订阅、bus、本地 consume | @@ -41,8 +42,10 @@ | `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_instance_finished` | 审批实例完成,发送给审批单发起人 | 无 | -只承认上表 16 个事件码。默认身份就是当前用户,不要额外加身份切换 flag。 +只承认上表 18 个事件码。默认身份就是当前用户,使用当前用户 OAuth 登录态,不要额外加身份切换 flag。两个 OA 事件订阅当前用户相关的全部审批事件,规则均为 `all`、空 `filterRule`,不需要目标参数。 ## Intent mapping @@ -69,20 +72,23 @@ | "监听并自动回复某人的单聊消息" | 先解析对端 userId,再启动 o2o consume;不要写轮询脚本 | | "同时监听同一人的单聊、已读和撤回" | 一个 consume 放入 3 个 event key,共享同一个 `--user` | | "同时监听同一群的消息、改名和解散" | 一个 consume 放入 3 个 event key,共享同一个 `--group` | -| "查看个人消息事件 schema" | `dws event schema --flatten` | +| "监听新的待我审批任务" / "有审批任务创建时通知我" | `event consume`,事件码 `user_oa_approval_task_created`,参数 `--flatten -f ndjson` | +| "监听我发起的审批何时完成" / "审批实例完成时通知我" | `event consume`,事件码 `user_oa_approval_instance_finished`,参数 `--flatten -f ndjson` | +| "同时监听审批任务创建和审批完成" | 一个 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. 从用户意图选择事件码;人名或群名先解析成必填 ID。 2. 需要了解字段时运行 `dws event schema --flatten`,读取 `schema.properties`;此模式的 `jq_root_path` 为 `.`。 3. 启动 `dws event consume [event_key...] ... --flatten -f ndjson`。单事件等待 `ready event_key=...`;多事件记录每条 `subscription event_key=... subscribe_id=...`,再等待整体 `ready event_count=...`。不要用 `sleep` 猜测。 -4. stdout 每行是一个扁平事件 JSON;消息、动作及群成员加入/退出事件读取顶层业务字段。群标题变更和群解散只读取公共字段和 `payload` 中实际存在的字段。 +4. stdout 每行是一个扁平事件 JSON;消息、动作及群成员加入/退出事件读取顶层业务字段。群标题变更、群解散和 OA 事件只读取公共字段和 `payload` 中实际存在的字段。 5. 需要确认监听状态时运行 `dws event status --event `,查看 `Subscriptions` 和 `Consumers`。 6. 任务完成后优雅结束 consume;本次新建的订阅会自动取消。复用已有订阅或需要从外部主动取消时,先运行 `dws event stop --dry-run`,向用户确认后再以 `--yes` 执行;自测可在 consume 加 `--max-events` 或 `--duration` 自动退出。 @@ -105,6 +111,8 @@ 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_instance_finished --flatten ``` ```bash @@ -126,6 +134,8 @@ 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_instance_finished --flatten -f ndjson ``` 同一目标、同一过滤条件的兼容事件优先使用一个多事件命令: @@ -146,9 +156,15 @@ dws event consume \ --group \ --flatten \ -f ndjson + +dws event consume \ + user_oa_approval_task_created \ + 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 `,但两个参数不能同时使用。 @@ -183,6 +199,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 事件同样只承诺 `type/event_id/timestamp/subscribe_id/payload`。`payload` 是允许任意业务字段的开放对象,内部路由字段会被移除;不要猜测 `taskId`、`processInstanceId` 或审批完成状态枚举。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 事件,测试投递使用别人或机器人发消息。 @@ -200,3 +217,4 @@ dws event stop --all --yes - multi skill: `skills/multi/dingtalk-event/SKILL.md` - IM reference: `skills/multi/dingtalk-event/references/event-im.md` +- OA reference: `skills/multi/dingtalk-event/references/event-oa.md` diff --git a/skills/multi/dingtalk-event/SKILL.md b/skills/multi/dingtalk-event/SKILL.md index 512c8ffb..b6386738 100644 --- a/skills/multi/dingtalk-event/SKILL.md +++ b/skills/multi/dingtalk-event/SKILL.md @@ -1,11 +1,11 @@ --- name: dingtalk-event -description: 钉钉个人 IM 事件长连接监听、订阅与消费,覆盖消息接收、全部单聊/群消息、指定发送人、已读、撤回、表情回应和群生命周期,输出 NDJSON 到 stdout。Use when 用户提到 监听个人消息事件、监听所有单聊或群消息、被@消息、监听单聊或群消息、监听某人发送的消息、监听消息已读、监听消息撤回、监听消息贴表情或表情回应、监听群成员加入、监听群成员退出、监听群改名或群解散、实时接收钉钉事件、用事件驱动 Agent。命令前缀:dws event。 +description: 钉钉个人 IM 事件与 OA 审批事件长连接监听、订阅与消费,覆盖消息接收、全部单聊/群消息、指定发送人、已读、撤回、表情回应、群生命周期、审批任务创建和审批实例完成,输出 NDJSON 到 stdout。Use when 用户提到 监听个人消息事件、监听所有单聊或群消息、被@消息、监听单聊或群消息、监听某人发送的消息、监听消息已读、监听消息撤回、监听消息贴表情或表情回应、监听群成员加入、监听群成员退出、监听群改名或群解散、监听待我审批的任务、监听我发起的审批完成、实时接收钉钉事件、用事件驱动 Agent。命令前缀:dws event。 --- -# 钉钉个人 IM 事件 +# 钉钉个人事件 -只使用 `dws event consume` 建立个人消息事件长连接。用户要求实时监听、订阅、自动回复或驱动 Agent 时,不要写轮询脚本,不要用消息历史查询模拟事件。 +只使用 `dws event consume` 建立个人事件长连接。用户要求实时监听、订阅、自动回复或驱动 Agent 时,不要写轮询脚本,不要用消息历史或审批列表查询模拟事件。 ## 运行方式 @@ -47,8 +47,10 @@ description: 钉钉个人 IM 事件长连接监听、订阅与消费,覆盖消 | `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_instance_finished` | 审批实例完成,发送给审批单发起人 | 无 | -只承认上表 16 个事件码。其它身份模式、应用凭证模式、非个人 IM 事件不在本 skill 范围内。 +只承认上表 18 个事件码。其它身份模式、应用凭证模式和未列出的个人事件不在本 skill 范围内。 ## Command rules @@ -65,10 +67,12 @@ description: 钉钉个人 IM 事件长连接监听、订阅与消费,覆盖消 - “监听群改名/群标题变更”使用 `user_im_group_updated`;“监听有人进群”使用 `user_im_group_member_added`;“监听有人退群”使用 `user_im_group_member_exited`;“监听群解散”使用 `user_im_group_disbanded`。群解散自测只能使用明确的测试群,并在执行解散操作前再次提示其不可逆影响。 - 用户要求执行“撤回消息”时使用 `dws chat`;只有“监听/订阅消息撤回”才使用 `dws event consume user_im_message_recall_*`。 - 用户说“贴标签”且语义是给消息贴表情时,按消息表情回应事件处理,event key 使用 `reaction`。 +- “有新的待我审批任务/审批任务创建”使用 `user_oa_approval_task_created`;“我发起的审批完成/审批实例完成”使用 `user_oa_approval_instance_finished`。两者都是当前用户相关的全部 OA 事件,不加 `--user`、`--open-dingtalk-id` 或 `--group`。 - 正常 Agent 消费统一显式使用 `--flatten -f ndjson`。抓一条样本可用 `--flatten --max-events 1 -f json`。`--format` 只控制 JSON 序列化,`--flatten` 才控制数据结构。 -- 同一目标、同一过滤条件的兼容事件优先放在一个 `consume` 命令中:用户类事件共享一个 `--user` 或 `--open-dingtalk-id`,群类事件共享一个 `--group`,无目标事件可加入任一类组合。 +- 同一目标、同一过滤条件的兼容事件优先放在一个 `consume` 命令中:用户类事件共享一个 `--user` 或 `--open-dingtalk-id`,群类事件共享一个 `--group`,无目标 IM 事件可加入任一类组合。 - 用户类与群类事件不能放进同一命令;不同用户、不同群或不同过滤条件必须启动多个 consume 进程。多事件命令不使用 `--subscribe-id`、`--rule`、`--event-types`、`--filter`、`--foreground`、`--force` 或 `--debug-raw-events`。 -- 多事件共享 `--query` / `--filter-json` 时,所选事件必须全部是消息接收事件;已读、撤回、表情回应或群生命周期事件混入后不能使用消息过滤参数。 +- 两个 OA 事件可放进同一 `consume`;每个事件建立独立订阅并共享 personal bus。OA 事件不支持 `--query` 或 `--filter-json`,包含 OA 事件的多事件命令也不能使用这两个消息过滤参数。 +- 多事件共享 `--query` / `--filter-json` 时,所选事件必须全部是 IM 消息接收事件;已读、撤回、表情回应、群生命周期或 OA 事件混入后不能使用消息过滤参数。 - 监听非默认组织时带 `--profile `;漏传会退回默认 profile 而失败。 - 自己发的消息不作为事件回来(`isSelfLoop` 过滤):边监听边 `dws chat message send` 回复不成环;测试投递用别人 / 机器人发(自发会看到 0 事件)。 - `--debug-raw-events` 只用于联调确认服务端推送是否到达本地连接;正常任务不要使用。它和 `--flatten` 互斥,`-f raw` 也不能与 `--flatten` 同时使用。 @@ -79,7 +83,7 @@ description: 钉钉个人 IM 事件长连接监听、订阅与消费,覆盖消 1. 从用户意图选择事件码;人名或群名先解析成必填 ID。 2. 需要了解字段时运行 `dws event schema --flatten`,读取 `schema.properties`;此模式的 `jq_root_path` 为 `.`。 3. 启动 `dws event consume [event_key...] ... --flatten -f ndjson`。单事件等待 `[event] ready event_key= bus_pid= subscribe_id=`;多事件先记录每条 `[event] subscription event_key= subscribe_id=`,再等待 `[event] ready event_count= bus_pid=`。不要用 `sleep` 猜测。 -4. stdout 每行是一个扁平事件 JSON;消息、动作及群成员加入/退出事件直接读取顶层业务字段。群标题变更和群解散只读取公共字段与 `payload` 中实际存在的字段。 +4. stdout 每行是一个扁平事件 JSON;消息、动作及群成员加入/退出事件直接读取顶层业务字段。群标题变更、群解散和 OA 事件只读取公共字段与 `payload` 中实际存在的字段。 5. 需要确认监听状态时运行 `dws event status --event `,查看 `Subscriptions` 和 `Consumers`。 6. 任务完成后优雅结束 consume;本次新建的订阅会自动取消。复用已有订阅或需要从外部主动取消时,先用 `dws event stop --dry-run` 预览,向用户确认后再加 `--yes`;临时测试可用 `--max-events` 或 `--duration` 自动退出。 @@ -197,6 +201,13 @@ dws event consume \ --flatten \ -f ndjson +# 当前用户相关的审批任务创建和审批实例完成(一个进程) +dws event consume \ + user_oa_approval_task_created \ + user_oa_approval_instance_finished \ + --flatten \ + -f ndjson + # 有界自测 dws event consume user_im_message_receive_at \ --duration 10m \ @@ -222,6 +233,7 @@ dws event consume user_im_message_receive_o2o \ - 引用回复读取可选的 `quoted_message`;合并转发读取可选的 `forward_messages` 数组。两者保留内部消息的 `message_id/conversation_id/sender/sender_open_dingtalk_id/content/create_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` 时以实际键为准,不猜测群标题、操作者等尚未确认的字段;完整原始协议用 `-f raw` 或 `--debug-raw-events` 排查。 +- OA 事件当前同样只承诺顶层 `type/event_id/timestamp/subscribe_id/payload`;`payload` 保留服务端实际业务字段并移除内部路由字段。不要猜测 `taskId`、`processInstanceId` 或完成状态枚举;payload 缺失或非法时会告警并回退输出原始 transport envelope。 - 群自动回复使用事件顶层 `conversation_id`;单聊自动回复使用顶层 `sender_open_dingtalk_id`。 - 已读事件读取顶层 `reader`、`reader_open_dingtalk_id`、`read_time`;撤回事件读取 `recaller`、`recaller_open_dingtalk_id`、`recall_time`。 - 表情回应事件读取顶层 `operator`、`operator_open_dingtalk_id`、`reaction_name`、`reaction_text`、`operation_type`、`operation_time`。 @@ -232,3 +244,4 @@ dws event consume user_im_message_receive_o2o \ | Topic | Reference | Coverage | |---|---|---| | IM | [references/event-im.md](references/event-im.md) | 十六类个人 IM 事件命令、参数、生命周期、输出解析、自测和排障 | +| OA | [references/event-oa.md](references/event-oa.md) | 两类个人 OA 审批事件、无参数订阅、双事件消费和保守 payload 契约 | 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..fd590e98 --- /dev/null +++ b/skills/multi/dingtalk-event/references/event-oa.md @@ -0,0 +1,91 @@ +# 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_instance_finished` | `all` | 审批实例完成,发送给审批单发起人 | 无 | + +只承认上表 2 个 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_instance_finished --flatten -f ndjson` | +| “同时监听审批任务创建和审批完成” | 一个 consume 放入两个 OA event key,不加目标或过滤参数 | +| “查看 OA 事件目录” | `dws event list --category oa` | +| “查看 OA 事件输出字段” | 对对应事件运行 `dws event schema --flatten` | + +审批任务创建事件只表达“任务已创建并投递给当前审批人”;审批实例完成事件只表达“当前用户发起的审批已完成”。首版不推断审批结果、任务 ID 或实例 ID 的具体 payload 字段。 + +## Commands + +查看稳定的扁平输出 schema: + +```bash +dws event schema user_oa_approval_task_created --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_instance_finished --flatten -f ndjson +``` + +同时监听两种事件: + +```bash +dws event consume \ + user_oa_approval_task_created \ + user_oa_approval_instance_finished \ + --flatten \ + -f ndjson +``` + +双事件 consume 会为两个 event key 分别创建订阅和逻辑 consumer,并共享当前组织的 personal bus、远程连接、stdout 和生命周期。不要给 OA 命令加 `--query` 或 `--filter-json`;这两个 flag 只用于兼容的 IM 消息接收事件。 + +## Output contract + +`--flatten` 模式只承诺以下稳定结构: + +```json +{ + "type": "user_oa_approval_task_created", + "event_id": "...", + "timestamp": 0, + "subscribe_id": "...", + "payload": {} +} +``` + +- `type` 是当前 event key;`event_id` 可用于去重;`timestamp` 是事件发生时间戳;`subscribe_id` 标识对应的独立订阅。 +- `payload` 是开放业务对象,schema 使用 `additionalProperties=true`。未知业务字段原样保留,内部路由字段 `uid/corpid/clientId/filterSubId/bizid/orgId/sourceId` 会被移除。 +- 不要假设 `payload` 一定包含 `taskId`、`processInstanceId`、`processCode`、审批结果或完成状态,也不要猜测状态枚举。始终以实际 payload 和 `dws event schema --flatten` 为准。 +- payload 缺失、为空或无法解析时,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=2 bus_pid=`。 +- 临时验证使用 `--max-events 1` 或 `--duration 10m`;任务完成后优雅结束 consume,本次新建的订阅会自动取消。 +- 外部停止已有订阅时先运行 `dws event stop --dry-run`,确认后再加 `--yes`。不要 `kill -9`,否则会跳过自动退订。